/* web/admin/main.js — admin panel bootstrap and rendering. * * Started as a pure dashboard (docs/admin-panel-architecture.md / the * phase-1 spec): every phase-1/2/3 card only ever reads. Phase 4 added * the first deliberate exception (feedback resolution) and phase 6 adds * three more, each a narrow, closed-set operations action, never a * free-form write surface (§3.1's line the architecture draws): the * maintenance-mode toggle, a manual vocabulary-column refresh, and * clearing the query-result cache. See web/admin/admin.js's own comments * on each corresponding method for why each stays inside that line. * * Reuses web/js/apikey.js (credentials) and web/js/state.js (theme / * backend base-URL persistence) unchanged -- an admin key is just a key * with the `admin` capability, so this page shares the exact same * localStorage-backed key store the analyst UI (web/) already uses, * rather than inventing a second one. * * Backend address: this page used to fall back to state.js's own * hardcoded "http://localhost:8000" placeholder default whenever nothing * had been saved to localStorage yet -- unlike web/ (main.js), it never * applied web/js/config.js's DEFAULT_BASE_URL/DEFAULT_API_PORT at all, so * a deployment that moved host had to type the new address into this * page's top bar by hand every time. It now resolves its default via * resolveDefaultBaseUrl exactly the same way web/ does (same two * constants, same precedence: `?base=` > saved value > config.js * default), and validates every address that reaches it (top-bar entry, * `?base=`, a saved value) through normalizeBaseUrl -- see the top-bar * wiring and loadPersisted() (state.js) for why: an operator once typed * "172.16.101.42:8076" (no scheme) into this exact top bar and every * request silently went to a RELATIVE path on the static file server * instead of the API. */ "use strict"; import { AdminApi, AdminUnauthorizedError, AdminForbiddenError, AdminApiError } from "./admin.js"; import { renderAccessRequestsList } from "./access-requests.js"; import { formatWhen } from "./time.js"; import { applyView, initialView, markAttention, readRememberedView, rememberView, renderViewSwitch, viewForSection, } from "./views.js"; import { getApiKey, setApiKey, clearApiKey, hasApiKey } from "../js/apikey.js"; import { state, loadPersisted, persistTheme, persistBaseUrl, applyTheme, resolveBootBaseUrl, resolveDefaultBaseUrl, normalizeBaseUrl, } from "../js/state.js"; import { DEFAULT_BASE_URL, DEFAULT_API_PORT } from "../js/config.js"; import { CARD_UNREACHABLE_TEXT, KIND_OTHER, createDiagnoser, hideDiagnosisBanner, showDiagnosisBanner, } from "../js/diagnosis.js"; const $ = (id) => document.getElementById(id); /* Decides, once per round of calls, whether the address this panel talks to * is the API at all (js/diagnosis.js), so thirteen failing cards produce one * page-level message and thirteen short pointers to it. Declared before the * bootstrap calls below for the reason CARDS is. */ const diagnoser = createDiagnoser({ getBaseUrl: () => state.baseUrl, getPageOrigin: () => location.origin, }); /* Every card this page fetches and re-renders, one at a time (see * refreshOne below). Declared here, before the top-level bootstrap calls * below, because those calls run refreshAll() synchronously as part of * page load -- a `const` referenced before its own declaration line has * executed is a ReferenceError (the temporal dead zone), not a "not yet * defined" value, so this array cannot sit below the code that runs * before the module finishes its first pass. */ const CARDS = [ "summary", "health", "cache", "config", "feedback", "accessRequests", "maintenance", "keys", "schemaDrift", "vocabulary", "quality", "usage", "authFailures", ]; /* Cards whose data comes from a real warehouse round trip beyond a * trivial read -- "health" (scripts.verify_deployment's checks: several * SELECT 1s, a catalogue scan, and -- only with the deep button below -- * a rolled-back CREATE TABLE DDL attempt, a WAITFOR probe and two tiny * model requests that look for a response cache) and * "schemaDrift" (a full get_table_names/get_columns reflection of every * table in every schema). 2026 warehouse-load audit, Required item 1: * these are excluded from the 30-second auto-refresh below -- they load * once when the page opens and again only when the operator presses that * card's own refresh button (which forces a fresh run past the server's * own cache, see refreshOne's `force` handling). Every other card reads * only the audit log, the application database, or in-process * bookkeeping -- cheap enough to keep on the ordinary cadence. */ const EXPENSIVE_CARDS = new Set(["health", "schemaDrift"]); /* "quality" is deliberately NOT in EXPENSIVE_CARDS. It reads the whole audit * log (files, not the warehouse), which would be too much to do every 30 * seconds -- so GET /admin/quality is cached on the server for * ADMIN_EXPENSIVE_CACHE_TTL_SECONDS (api/admin_result_cache.py, the same * cache the two cards above use), and the auto-refresh tick is served from * it. Its own refresh button forces a recompute (refreshOne's `force`). */ /* Cards whose 403 is an ordinary, expected outcome rather than a wrong * credential, and so must render inside the card instead of raising the * page-level "this key is not an admin key" banner. * * The panel's sections do not share one capability: `require_admin` * serves four of them, `require_operations_or_security` seven, and keys and * access requests need `operations` and `security` respectively, * so a key holding only some of the three legitimately sees a 403 on the * rest. Before phase 7 that produced a banner claiming the key was not an * admin key at all -- which is false, and points at the wrong fix. * * Maps each such card to the ONE capability it needs -- "keys" wants * `operations`, "accessRequests" (ADR-004 part 1, security-only both for * listing and acting) wants `security`; the two must not share one * hardcoded message, or one of them would tell the operator to issue the * wrong kind of key. */ const CARDS_WHERE_403_IS_A_CAPABILITY_GAP = new Map([ ["keys", "operations"], ["accessRequests", "security"], ]); // Same precedence as web/ (main.js): config.js default (or, when that is // empty, this page's own protocol/host + DEFAULT_API_PORT) first; // loadPersisted() then overrides it with a saved localStorage value if // one exists -- and now repairs/discards a bad one via normalizeBaseUrl // rather than loading it verbatim (state.js); `?base=` is the final, // highest-precedence override for a one-off load, validated the same // way before being trusted. const _bootParams = new URLSearchParams(location.search); state.baseUrl = resolveDefaultBaseUrl(DEFAULT_BASE_URL, DEFAULT_API_PORT, location); loadPersisted(); applyTheme(); const _rawBootBaseUrl = resolveBootBaseUrl(_bootParams, state.baseUrl); if (_rawBootBaseUrl !== state.baseUrl) { const _bootResult = normalizeBaseUrl(_rawBootBaseUrl); if (_bootResult.ok) { state.baseUrl = _bootResult.url; } else { showNotice( "error", `نشانی بک‌اند در ?base= نامعتبر است و نادیده گرفته شد — باید یک نشانی http:// یا https:// معتبر باشد. (?base=${_rawBootBaseUrl})`, ); } } let api = new AdminApi(state.baseUrl); wireTopbar(); tickClock(); setInterval(tickClock, 1000); refreshAll(); /* ── Topbar: theme, backend base URL, API key ─────────────────────── */ function wireTopbar() { $("theme-toggle").addEventListener("click", () => { const order = ["system", "light", "dark"]; const next = order[(order.indexOf(state.theme) + 1) % order.length]; persistTheme(next); applyTheme(); updateThemeLabel(); }); updateThemeLabel(); $("live-base-input").value = state.baseUrl; $("live-base-connect").addEventListener("click", () => { const val = $("live-base-input").value; const result = normalizeBaseUrl(val); if (!result.ok) { // Never silently used as-is (the exact "172.16.101.42:8076" // incident this page's normalisation exists for -- see this // file's module docstring): reject with a clear message and keep // whatever the panel is already connected to. showNotice( "error", "نشانی بک‌اند نامعتبر است — باید یک نشانی http:// یا https:// معتبر باشد (مثلاً http://172.16.101.42:8076).", ); return; } state.baseUrl = result.url; $("live-base-input").value = state.baseUrl; persistBaseUrl(state.baseUrl); api = new AdminApi(state.baseUrl); refreshAll(); }); $("live-key-save").addEventListener("click", () => { const val = $("live-key-input").value; if (!val.trim()) return; setApiKey(val); $("live-key-input").value = ""; updateKeyStatus(); hideForbiddenBanner(); refreshAll(); }); $("live-key-change").addEventListener("click", () => { $("live-key-entry").hidden = false; $("live-key-change").hidden = true; $("live-key-input").focus(); }); $("live-key-clear").addEventListener("click", () => { clearApiKey(); $("live-key-input").value = ""; updateKeyStatus(); refreshAll(); }); updateKeyStatus(); $("admin-refresh-all").addEventListener("click", refreshAll); // A card's OWN refresh button is a deliberate "run this now" from the // operator -- for the server-cached cards (the two expensive ones, and // "quality") this forces a fresh run past the server's own cache // (?refresh=1); refreshOne ignores `force` for every other card, which // has no such cache to bypass. document.querySelectorAll("[data-refresh]").forEach((btn) => { btn.addEventListener("click", () => { diagnoser.reset(); // a fresh question about the address, not last round's answer refreshOne(btn.dataset.refresh, { force: true }); }); }); const deepBtn = $("health-deep-btn"); if (deepBtn) { deepBtn.addEventListener("click", () => { const proceed = window.confirm( "این کار چند بررسیِ اضافی را روی انبار داده اجرا می‌کند: یک تلاش " + "CREATE TABLE (که همیشه Rollback می‌شود) برای اطمینان از دسترسی " + "فقط‌خواندنی، یک پروب WAITFOR که چند ثانیه طول می‌کشد، و — اگر بیش از " + "یک منبع داده تنظیم شده باشد — خواندن فهرست جدول‌ها و ستون‌های هر منبع " + "برای یافتن جدولی که در منبعِ دیگری است. همچنین دو درخواستِ کوچک به " + "مدل زبانی فرستاده می‌شود تا معلوم شود پاسخ‌ها از یک کشِ پاسخ " + "(response cache) نمی‌آیند. ادامه می‌دهید؟", ); if (!proceed) return; refreshOne("health", { force: true, deep: true }); }); } } function updateThemeLabel() { const labels = { system: "پوسته: سیستم", light: "پوسته: روشن", dark: "پوسته: تیره" }; $("theme-toggle-label").textContent = labels[state.theme]; } /* Same three states as the analyst UI (web/js/main.js) -- see that * function's comment. The panel's own version of the problem was worse: * an admin key is entered once and then every card on the page depends on * it, so an always-empty password box beside ten loaded cards is a * standing invitation to re-enter a key that was never lost. */ function updateKeyStatus(rejected = false) { const el = $("live-key-status"); const entry = $("live-key-entry"); const change = $("live-key-change"); const clear = $("live-key-clear"); const stored = hasApiKey(); if (rejected) { el.textContent = "کلید: رد شد"; el.className = "live-key-status unset"; } else if (stored) { el.textContent = "کلید: ذخیره شده ✓"; el.className = "live-key-status set"; } else { el.textContent = "کلید: تنظیم نشده"; el.className = "live-key-status unset"; } const showEntry = rejected || !stored; entry.hidden = !showEntry; change.hidden = showEntry; clear.hidden = !stored; } function tickClock() { const el = $("foot-time"); if (el) el.textContent = new Date().toLocaleString("fa-IR"); } /* ── Forbidden banner: "this key is not an admin key" ─────────────── * The phase-1 spec is explicit: a 403 must say this plainly rather than * leaving every card showing an empty/stuck loading state. */ function showForbiddenBanner(message) { const el = $("admin-forbidden-banner"); el.textContent = message; el.hidden = false; } function hideForbiddenBanner() { $("admin-forbidden-banner").hidden = true; } function showNotice(kind, message) { const area = $("admin-notice-area"); area.innerHTML = ""; const div = document.createElement("div"); div.className = `admin-banner admin-banner-${kind === "error" ? "error" : "warn"}`; div.textContent = message; area.appendChild(div); } function clearNotice() { $("admin-notice-area").innerHTML = ""; } /* ── Fetch + render, one card at a time ───────────────────────────── * Each card fails independently: a 403 on one call (all four will 403 * together, since it is the same key) still renders the others' error * state instead of the whole page going blank, and a network error on * one endpoint does not block the rest. CARDS itself is declared near * the top of this file, above the bootstrap calls that run it first. */ async function refreshAll() { hideForbiddenBanner(); clearNotice(); await refreshMany(CARDS); } /** Refresh exactly the named cards (a subset of CARDS), then update the * shared rail/timestamp. Used both by refreshAll (every card) and by the * 30s auto-refresh timer below (every card EXCEPT EXPENSIVE_CARDS). */ async function refreshMany(names) { diagnoser.reset(); await Promise.all(names.map((name) => refreshOne(name))); updateSummaryRail(); markLastUpdated(); } /** Snapshot the four rail numbers from the last successful payloads. */ const _rail = { maintenance: null, health: null, feedback: null, cache: null }; function updateSummaryRail() { setRailItem("rail-maintenance", _rail.maintenance); setRailItem("rail-checks", _rail.health); setRailItem("rail-feedback", _rail.feedback); setRailItem("rail-cache", _rail.cache); // The same facts mark the view buttons (views.js), so a failed check or // open feedback is seen from any view, not only from the rail. markAttention(document, { status: ["rail-maintenance", "rail-checks"].some((id) => $(id)?.classList.contains("crit")), quality: Boolean($("rail-feedback")?.classList.contains("warn")), }); } function setRailItem(id, data) { const el = $(id); if (!el) return; const val = el.querySelector(".rail-val"); const sub = el.querySelector(".rail-sub"); el.classList.remove("good", "warn", "crit"); if (!data) { val.textContent = "—"; sub.textContent = "—"; return; } if (id === "rail-maintenance") { const on = !!(data.enabled || data.active || data.on); val.textContent = on ? "روشن" : "خاموش"; sub.textContent = on ? (data.note || "تحلیل‌گران متوقف") : "تحلیل‌گران فعال"; el.classList.add(on ? "crit" : "good"); } else if (id === "rail-checks") { const checks = data.checks || []; const failed = checks.filter((c) => c.status === "FAIL").length; // "N از M", not "N / M": with Persian text around it, a slash between // two numbers was laid out right to left and read as "21 / 2". val.textContent = failed ? `${fmtNum(failed)} از ${fmtNum(checks.length)}` : fmtNum(checks.length); sub.textContent = failed ? "بررسی ناموفق" : "همه موفق"; el.classList.add(failed ? "crit" : "good"); } else if (id === "rail-feedback") { const open = data.open ?? data.total_open ?? (data.stats && data.stats.open) ?? null; const n = open === null ? (Array.isArray(data.feedback) ? data.feedback.length : "—") : open; val.textContent = n === "—" ? n : fmtNum(n); sub.textContent = "باز"; el.classList.add(Number(n) > 0 ? "warn" : "good"); } else if (id === "rail-cache") { const size = data.size ?? data.entries ?? "—"; val.textContent = size === "—" ? size : fmtNum(size); sub.textContent = "ورودی"; el.classList.add("good"); } } function markLastUpdated() { const el = $("admin-last-updated"); if (!el) return; const d = new Date(); el.textContent = `آخرین: ${d.toLocaleTimeString("fa-IR")}`; } /* Auto-refresh every 30s — one cadence instead of ↻ on every section. Manual buttons remain for a single card after a write action. 2026 warehouse-load audit, Required item 1: EXPENSIVE_CARDS ("health", "schemaDrift") are excluded here -- with an admin tab left open all day, re-running scripts.verify_deployment's checks and a full schema reflection every 30 seconds was a material, avoidable share of the warehouse load a DBA reported. Those two cards still load once at bootstrap (refreshAll() above, called at module load) and again on their own refresh button; they simply do not repeat on a timer. */ const AUTO_REFRESH_MS = 30_000; const AUTO_REFRESH_CARDS = CARDS.filter((name) => !EXPENSIVE_CARDS.has(name)); setInterval(() => { if (document.hidden) return; refreshMany(AUTO_REFRESH_CARDS); }, AUTO_REFRESH_MS); /* Grouped views (views.js): the nav opens with one button per view and * only that view's sections are shown. A link to a section in another view * (a bookmark, a summary-rail item) switches to that view first. */ const RAIL_TARGETS = { "rail-maintenance": "sec-maintenance", "rail-checks": "sec-health", "rail-feedback": "sec-feedback", "rail-cache": "sec-cache", }; (function wireViews() { // The views are a convenience: if setting them up fails for any reason // (a page without the nav, an older browser), every section stays shown // -- the page as it was -- and the rest of the panel is unaffected. try { wireViewsUnguarded(); } catch { try { applyView(document, "all"); } catch { /* sections were never hidden */ } } })(); function wireViewsUnguarded() { const nav = $("admin-jump"); if (!nav || typeof document.querySelectorAll !== "function") return; let storage = null; try { storage = globalThis.localStorage || null; } catch { storage = null; } const hash = () => String((globalThis.location && globalThis.location.hash) || ""); const select = (viewId) => rememberView(storage, applyView(document, viewId)); const goTo = (id) => { const target = document.getElementById(id); if (!target) return; const owner = viewForSection(id); if (owner && target.hidden) select(owner); if (typeof target.scrollIntoView === "function") target.scrollIntoView({ block: "start" }); }; renderViewSwitch(nav, (viewId) => { select(viewId); if (typeof globalThis.scrollTo === "function") globalThis.scrollTo({ top: 0 }); }); applyView(document, initialView(hash(), readRememberedView(storage))); if (hash().length > 1) goTo(hash().slice(1)); if (typeof globalThis.addEventListener === "function") { globalThis.addEventListener("hashchange", () => goTo(hash().slice(1))); } for (const [railId, sectionId] of Object.entries(RAIL_TARGETS)) { const item = $(railId); if (!item) continue; item.setAttribute("role", "link"); item.tabIndex = 0; item.classList.add("is-link"); item.addEventListener("click", () => goTo(sectionId)); item.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); goTo(sectionId); } }); } } /* Scroll-spy for the sticky jump nav so the active section is obvious * while scrolling ten cards. IntersectionObserver, not a scroll handler. */ (function wireJumpNavSpy() { const nav = $("admin-jump"); if (!nav || typeof IntersectionObserver !== "function") return; const links = [...nav.querySelectorAll("a[href^='#']")]; const map = new Map(); for (const a of links) { const id = a.getAttribute("href").slice(1); const sec = document.getElementById(id); if (sec) map.set(sec, a); } if (!map.size) return; const obs = new IntersectionObserver( (entries) => { for (const e of entries) { if (!e.isIntersecting) continue; links.forEach((a) => a.classList.remove("on")); const a = map.get(e.target); if (a) a.classList.add("on"); } }, { rootMargin: "-20% 0px -60% 0px", threshold: 0 }, ); map.forEach((_a, sec) => obs.observe(sec)); })(); async function refreshOne(name, { force = false, deep = false } = {}) { const body = $(`${name}-body`); const btn = document.querySelector(`[data-refresh="${name}"]`); if (btn) btn.disabled = true; body.innerHTML = '

در حال بارگذاری…

'; try { if (name === "summary") { const includeExamples = $("include-examples-toggle").checked; renderSummary(await api.summary(includeExamples)); } else if (name === "health") { const payload = await api.healthChecks({ refresh: force, deep }); _rail.health = payload; renderHealth(payload); } else if (name === "cache") { const payload = await api.cache(); _rail.cache = payload; renderCache(payload); } else if (name === "config") { renderConfig(await api.config()); } else if (name === "feedback") { const status = $("feedback-status-filter").value; const [stats, list] = await Promise.all([api.feedbackStats(), api.feedbackList(status)]); _rail.feedback = { ...(stats || {}), ...(list || {}) }; renderFeedback(stats, list.feedback || []); } else if (name === "accessRequests") { const payload = await api.accessRequestsList(); renderAccessRequestsCard(payload.access_requests || []); } else if (name === "maintenance") { const payload = await api.maintenanceState(); _rail.maintenance = payload; renderMaintenance(payload); } else if (name === "keys") { renderKeys(await loadKeysCard()); } else if (name === "schemaDrift") { renderSchemaDrift(await api.schemaDrift({ refresh: force })); } else if (name === "vocabulary") { renderVocabulary(await api.vocabularyStatus()); } else if (name === "quality") { renderQuality(await api.quality({ days: Number($("quality-days").value) || 7, refresh: force })); } else if (name === "usage") { renderUsage(await api.usage()); } else if (name === "authFailures") { renderAuthFailures(await api.authFailures()); } hideForbiddenBanner(); hideDiagnosisBanner($("admin-diagnosis-banner")); } catch (err) { // A card is never left blank: every branch below writes its own text into // the card body. The page-level banner (403) and notice (401) say what is // wrong for the whole page; the card says it for itself, so someone looking // at this card alone, or scrolled away from the top of the page, is not // left with an empty box and no explanation. // // A wrong address (or a refused/unreachable one) is the same for every // card: it is said once, in the banner at the top, and each card points // to it. Only a failure that diagnosis.js recognises goes this way; a // 401, a 403 or a 500 is the API answering and keeps its own message. const diagnosis = await diagnoser.diagnose(err); if (diagnosis.kind === KIND_OTHER) hideDiagnosisBanner($("admin-diagnosis-banner")); // the API answered if (diagnosis.kind !== KIND_OTHER) { body.innerHTML = `

${CARD_UNREACHABLE_TEXT}

`; showDiagnosisBanner($("admin-diagnosis-banner"), diagnosis, "admin"); } else if (err instanceof AdminForbiddenError && CARDS_WHERE_403_IS_A_CAPABILITY_GAP.has(name)) { const capability = CARDS_WHERE_403_IS_A_CAPABILITY_GAP.get(name); body.innerHTML = `

این بخش به نقش ${capability} نیاز دارد و کلید شما آن را ندارد. ` + `کلیدی با --${capability} (یا --full-admin) صادر کنید — ` + `python -m scripts.issue_api_key.

`; } else if (err instanceof AdminForbiddenError) { body.innerHTML = '

این کلید برای این بخش دسترسی مدیریتی ندارد. ' + "توضیح در هشدار بالای صفحه آمده است.

"; showForbiddenBanner( "این کلید، کلید مدیریتی نیست — این کلید معتبر است ولی دسترسی مدیریتی ندارد. " + (err.message || ""), ); } else if (err instanceof AdminUnauthorizedError) { // Marks the stored key as rejected rather than only saying "enter a // key": with a key already stored, that message read as "you are // signed out" when the real state was "the server said no to what // you have". The key itself is kept -- it is unrecoverable, and a // 401 can be a restarted server rather than a bad key. updateKeyStatus(hasApiKey()); body.innerHTML = hasApiKey() ? '

سرور کلید را نپذیرفت (۴۰۱). توضیح در پیام بالای صفحه آمده است.

' : '

کلید API وارد نشده است. یک کلید مدیریتی وارد کنید.

'; showNotice( "error", hasApiKey() ? "سرور کلید ذخیره‌شده را نپذیرفت (۴۰۱). کلید پاک نشده — اگر سرور تازه ری‌استارت شده دوباره امتحان کنید، وگرنه کلید مدیریتی تازه‌ای وارد کنید." : "کلید API وارد نشده است. یک کلید مدیریتی وارد کنید.", ); } else if (err instanceof AdminApiError) { body.innerHTML = `

خطا: ${escapeHtml(err.message)}

`; } else { body.innerHTML = `

خطای غیرمنتظره: ${escapeHtml(String(err))}

`; } } finally { if (btn) btn.disabled = false; } } $("include-examples-toggle").addEventListener("change", () => refreshOne("summary")); $("quality-days").addEventListener("change", () => refreshOne("quality")); $("feedback-status-filter").addEventListener("change", () => refreshOne("feedback")); /* ── Cache clear: say the cost BEFORE doing it (spec §5) ────────────── */ $("cache-clear-btn").addEventListener("click", async () => { const stats = _lastCacheStats || (await api.cache().catch(() => null)); const size = stats ? fmtNum(stats.size ?? 0) : "نامشخص"; const proceed = window.confirm( `با پاک کردن کش، ${size} ورودی ذخیره‌شده حذف می‌شود و درخواست‌های بعدی هزینهٔ کامل (بدون کش) خواهند داشت. ادامه می‌دهید؟`, ); if (!proceed) return; try { await api.cacheClear(); showNotice("warn", "کش پاک شد."); await refreshOne("cache"); } catch (err) { showNotice("error", `پاک کردن کش ناموفق بود: ${err.message || err}`); } }); /* ── Renderers ───────────────────────────────────────────────────── */ function escapeHtml(s) { /* This used to be the `textContent` -> `innerHTML` idiom, which only * encodes what matters inside a *text node* (&, < and >). Two call * sites (renderKeys' data-pid/data-hash, the vocabulary card's * data-vocab-refresh) used its output to build an HTML *attribute* * instead, where `"` and `'` are exactly the characters that matter -- * the audit's finding 2 reproduced a real onmouseover attribute break- * out through that gap. Encode all five characters explicitly so this * function is safe wherever the next caller puts it, text node or * attribute, rather than being correct only for the position it was * first written for. */ return String(s) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } /** Format a rate that may arrive as 0..1, as a percentage, or as null. */ function asPercent(value) { if (value === null || value === undefined || value === "") return null; const n = Number(value); if (Number.isNaN(n)) return null; return n <= 1 ? n * 100 : n; } function statTile({ label, value, unit, sub, cls }) { const u = unit ? `${escapeHtml(unit)}` : ""; const s = sub ? `${escapeHtml(sub)}` : ""; return ( `
` + `${escapeHtml(label)}` + `${escapeHtml(value)}${u}${s}
` ); } function renderSummary(report) { const body = $("summary-body"); const out = []; const modeClass = report.mode === "aggregate_with_examples" ? "examples" : ""; const modeLabel = report.mode === "aggregate_with_examples" ? "شامل نمونهٔ سؤال‌های واقعی — این خروجی را بی‌ملاحظه از سرور خارج نکنید" : "تجمیعی — بدون سؤال یا SQL خام"; out.push(`${escapeHtml(modeLabel)}`); // ── The figures, leading. ──────────────────────────────────────────── const tiles = []; tiles.push(statTile({ label: "پرس‌وجوها", value: fmtNum(report.record_count ?? 0) })); const lat = report.latency?.overall_ms; if (lat && lat.count) { tiles.push(statTile({ label: "تأخیر p50", value: fmtNum(Math.round(lat.p50)), unit: "ms", sub: `p95 ${Math.round(lat.p95)} · p99 ${Math.round(lat.p99)}`, })); } const ft = report.failure_taxonomy; if (ft && (ft.success_count || ft.failure_count)) { const total = ft.success_count + ft.failure_count; const rate = total ? (ft.success_count / total) * 100 : 0; tiles.push(statTile({ label: "نرخ موفقیت", value: rate.toFixed(1), unit: "٪", sub: `${fmtNum(ft.failure_count)} ناموفق از ${fmtNum(total)}`, cls: rate < 90 ? "is-degraded" : "", })); } const cb = report.cache_behaviour || {}; const t0 = asPercent(cb.t0_rate); if (t0 !== null) { tiles.push(statTile({ label: "پاسخ از کش", value: t0.toFixed(1), unit: "٪" })); } // The keystone: when prefix reuse breaks, nothing errors and every // request pays full prefill. See admin.css's header. // server_hit_rate counts only calls whose server reported cached_tokens; // prefix_cache_hit_rate is the older prompt-size estimate (runbook 20.8), // used only when no call reported it. const serverRate = asPercent(cb.server_hit_rate); const prefix = serverRate !== null ? serverRate : asPercent(cb.prefix_cache_hit_rate); if (prefix !== null) { const source = serverRate !== null ? " (گزارش سرور)" : " (برآورد از اندازهٔ پرامپت)"; tiles.push(statTile({ label: "بازاستفادهٔ prefix", value: prefix.toFixed(1), unit: "٪", sub: (prefix < 50 ? "پایین — هر درخواست prefill کامل می‌دهد" : "سالم") + source, cls: prefix < 50 ? "is-degraded" : "is-keystone", })); } const cr = report.correction_rounds; if (cr && cr.total !== undefined) { tiles.push(statTile({ label: "دورهای اصلاح", value: fmtNum(cr.total), sub: cr.mean !== undefined ? `میانگین ${Number(cr.mean).toFixed(2)}` : "", })); } out.push(`
${tiles.join("")}
`); const range = report.time_range || {}; if (range.start || range.end) { out.push( `

از ${whenHtml(range.start)} تا ${whenHtml(range.end)}

`, ); } if (report.records_by_model && Object.keys(report.records_by_model).length) { out.push('

به تفکیک مدل

'); out.push(twoColumnTable("مدل", "تعداد", Object.entries(report.records_by_model))); } const fr = report.finish_reason_distribution; if (fr && Object.keys(fr).length) { out.push('

دلیل پایان تولید

'); out.push(twoColumnTable("finish_reason", "تعداد", Object.entries(fr))); } body.innerHTML = out.join(""); } function fmtNum(n) { return Number(n).toLocaleString("fa-IR"); } /** An escaped HTML fragment for a server timestamp: the readable Persian * date (time.js) with the original ISO string as its title. */ function whenHtml(value) { const { text, title } = formatWhen(value); if (!title) return escapeHtml(text || "—"); return `${escapeHtml(text)}`; } function twoColumnTable(headA, headB, entries) { const rows = entries.map( ([k, v]) => `${escapeHtml(k)}${escapeHtml(fmtNum(v))}`, ); return ( '
' + `` + `${rows.join("")}
${escapeHtml(headA)}${escapeHtml(headB)}
` ); } function kv(label, value) { return `
${escapeHtml(label)}
${escapeHtml(value)}
`; } /** A small "این نتیجه از کش است / همین الان اجرا شد" line for the * server-cached cards (health, schemaDrift, quality) -- api/admin_result_cache.py's * `cache` block on each response. 2026 warehouse-load audit, Required * item 1: showing the age is how an operator tells a genuinely fresh * result from one that is up to ADMIN_EXPENSIVE_CACHE_TTL_SECONDS old. */ function formatCacheAge(cache) { if (!cache) return ""; if (!cache.cached) { return '

همین الان اجرا شد

'; } const age = Math.round(cache.age_seconds ?? 0); return ( `

نتیجهٔ کش‌شده — ${fmtNum(age)} ثانیه پیش ` + `(حداکثر ${fmtNum(cache.ttl_seconds ?? 0)} ثانیه)

` ); } function renderHealth(payload) { const body = $("health-body"); const checks = payload.checks || []; if (!checks.length) { body.innerHTML = '

هیچ بررسی‌ای ثبت نشده است.

'; return; } const failed = checks.filter((c) => c.status === "FAIL").length; const passed = checks.filter((c) => c.status === "PASS").length; // The count comes first and names the failures, so an operator who reads // one line has read the thing that matters. const summary = failed ? `

${fmtNum(failed)} بررسی ناموفق از ${fmtNum(checks.length)}

` : `

هر ${fmtNum(checks.length)} بررسی موفق

`; // Failures first: sorting by severity means the thing needing action is // never below the fold on a narrow screen. const order = { FAIL: 0, SKIP: 1, PASS: 2 }; const sorted = checks.slice().sort( (a, b) => (order[a.status] ?? 3) - (order[b.status] ?? 3), ); const card = (c) => { const cls = c.status === "PASS" ? "pass" : c.status === "FAIL" ? "fail" : "skip"; const mark = c.status === "PASS" ? "✓" : c.status === "FAIL" ? "✕" : "–"; const detail = c.detail ? `${escapeHtml(c.detail)}` : ""; return ( `
` + `` + `${escapeHtml(c.name)}` + `${escapeHtml(c.status)}` + detail + "
" ); }; // What needs reading (FAIL, then SKIP) is shown as cards; the passed // checks, usually most of them, fold into one line that opens to the // same cards. Nothing is dropped: a PASS detail (an `advice:`) is one // click away. const attention = sorted.filter((c) => c.status !== "PASS").map(card); const passedCards = sorted.filter((c) => c.status === "PASS").map(card); const passedBlock = passedCards.length ? '
' + `${fmtNum(passed)} بررسی موفق` + `
${passedCards.join("")}
` : ""; const deepNote = payload.deep ? '

شامل بررسی‌های عمیق (DDL/WAITFOR)

' : ""; body.innerHTML = summary + formatCacheAge(payload.cache) + deepNote + attention.join("") + passedBlock; } /** Admin panel phase 6 §5: the panel must show what clearing will cost * BEFORE the operator clicks clear, not only echo it back afterward. * Kept from the most recent GET /admin/cache (phase 1, read-only) so the * confirm dialog below is never stale by more than one refresh cycle. */ let _lastCacheStats = null; function renderCache(stats) { _lastCacheStats = stats; const body = $("cache-body"); const hits = Number(stats.hits ?? 0); const misses = Number(stats.misses ?? 0); const total = hits + misses; const rate = total ? (hits / total) * 100 : null; const tiles = [ statTile({ label: "اندازهٔ فعلی", value: fmtNum(stats.size ?? 0), sub: stats.max_size !== undefined ? `از ${fmtNum(stats.max_size)}` : "" }), ]; if (rate !== null) { tiles.push(statTile({ label: "نرخ اصابت", value: rate.toFixed(1), unit: "٪", sub: `${fmtNum(hits)} / ${fmtNum(total)}` })); } const rows = [kv("فعال", stats.enabled ? "بله" : "خیر")]; rows.push(kv("evictions", fmtNum(stats.evictions ?? 0))); if (stats.sql_index_size !== undefined) rows.push(kv("نمایهٔ SQL", fmtNum(stats.sql_index_size))); if (stats.ttl_seconds !== undefined) rows.push(kv("TTL", `${fmtNum(stats.ttl_seconds)} s`)); body.innerHTML = `
${tiles.join("")}
` + `
${rows.join("")}
`; } function renderConfig(payload) { const body = $("config-body"); const files = payload.files || []; const rows = files.map((f) => { const cls = f.loaded ? "pass" : "fail"; const status = f.loaded ? "بارگذاری شد" : "خطا"; return ` ${escapeHtml(f.file)} ${escapeHtml(status)} ${f.count === undefined || f.count === null ? "—" : escapeHtml(fmtNum(f.count))} ${escapeHtml(f.error || "")} `; }); body.innerHTML = ( `

${escapeHtml(payload.project_config_dir || "")}

` + '
' + "" + rows.join("") + "
فایلوضعیتتعداد ورودیخطا
" ); } /* ── Feedback triage (admin panel phase 4, spec §3, §5) ────────────── * The one card on this page that both reads AND writes -- see * admin.js's own comment on resolveFeedback for why that write is * narrow (a fixed, closed-set decision) rather than the free-form write * surface §3.1 of the architecture forbids. */ const RESOLUTION_OUTCOMES = ["alias_fix", "rule_fix", "golden_case", "not_a_defect"]; const RESOLUTION_LABELS = { alias_fix: "اصلاح مترادف/نام مستعار", rule_fix: "اصلاح قاعدهٔ کسب‌وکار", golden_case: "تبدیل به پروندهٔ طلایی", not_a_defect: "نقص نیست", }; const CATEGORY_LABELS = { wrong_number: "عدد اشتباه", different_question: "پاسخ به سؤال دیگر", wrong_filter_or_period: "فیلتر/بازهٔ زمانی اشتباه", other: "سایر", }; function renderFeedback(stats, rows) { const statsBody = $("feedback-stats-body"); const tiles = [ statTile({ label: "کل بازخوردها", value: fmtNum(stats.flags_total ?? 0) }), statTile({ label: "باز", value: fmtNum(stats.flags_open ?? 0), cls: stats.flags_open ? "is-degraded" : "", }), statTile({ label: "اندازهٔ مجموعهٔ طلایی", value: fmtNum(stats.golden_set_size ?? 0), sub: stats.golden_set_pending ? `${fmtNum(stats.golden_set_pending)} در انتظار پاسخ` : "همه دارای پاسخ تأییدشده", }), ]; if (stats.baseline) { tiles.push(statTile({ label: "دقت آخرین baseline", value: Number(stats.baseline.accuracy_pct).toFixed(1), unit: "٪", sub: `${escapeHtml(stats.baseline.mode)} · ${escapeHtml(formatWhen(stats.baseline.generated_at || "").text)}`, })); } else { tiles.push(statTile({ label: "دقت آخرین baseline", value: "—", sub: "هنوز baseline ثبت نشده" })); } statsBody.innerHTML = `
${tiles.join("")}
`; const body = $("feedback-body"); if (!rows.length) { body.innerHTML = '

بازخوردی یافت نشد.

'; return; } body.innerHTML = rows.map(feedbackRowHtml).join(""); body.querySelectorAll("[data-resolve-id]").forEach((btn) => { btn.addEventListener("click", () => resolveFeedbackRow(btn)); }); } function feedbackRowHtml(row) { const audit = row.audit; const cls = row.status === "resolved" ? "pass" : ""; const questionLine = audit ? `

${escapeHtml(audit.question || "")}

` : '

(دیگر قابل بازیابی از گزارش ممیزی نیست)

'; const sqlLine = audit && audit.generated_sql ? `
${escapeHtml(audit.generated_sql)}
` : ""; const guardLine = audit && audit.guard ? `${escapeHtml(audit.guard.verdict || "")}` : ""; const noteLine = row.note ? `

یادداشت تحلیل‌گر: ${escapeHtml(row.note)}

` : ""; let actionArea; if (row.status === "resolved") { actionArea = ( `

نتیجه: ${escapeHtml(RESOLUTION_LABELS[row.resolution_outcome] || row.resolution_outcome || "")}` + (row.resolution_note ? ` — ${escapeHtml(row.resolution_note)}` : "") + `

` ); } else { const options = RESOLUTION_OUTCOMES .map((o) => ``) .join(""); actionArea = ( `
` + `` + `` + `` + `
` ); } return ( `
` + `` + `` + `#${row.feedback_id} · ${escapeHtml(CATEGORY_LABELS[row.category] || row.category)} ` + guardLine + questionLine + sqlLine + noteLine + actionArea + `
` ); } async function resolveFeedbackRow(btn) { const wrap = btn.closest("[data-feedback-id]"); const feedbackId = wrap.dataset.feedbackId; const outcome = wrap.querySelector(".fb-outcome").value; const note = wrap.querySelector(".fb-note").value; btn.disabled = true; try { await api.resolveFeedback(feedbackId, { outcome, note }); await refreshOne("feedback"); } catch (err) { showNotice("error", `ثبت نتیجه ناموفق بود: ${err.message || err}`); btn.disabled = false; } } /* ── ADR-004 part 1: "Request access" triage queue -- security only ── * The DOM building itself lives in access-requests.js (renderAccessRequestsList), * kept separate and import-testable under Node the same way * web/js/render/feedback.js is -- see that module's own docstring. This * function is only the fetch-card glue: which host element, and what the * approve/deny buttons actually do once clicked. */ function renderAccessRequestsCard(rows) { const body = $("accessRequests-body"); renderAccessRequestsList(body, rows, { onApprove: (requestId, btn) => approveAccessRequestRow(requestId, btn), onDeny: (requestId, reason, btn) => denyAccessRequestRow(requestId, reason, btn), }); } async function approveAccessRequestRow(requestId, btn) { btn.disabled = true; try { await api.approveAccessRequest(requestId); await refreshOne("accessRequests"); } catch (err) { showNotice("error", `تأیید درخواست دسترسی ناموفق بود: ${err.message || err}`); btn.disabled = false; } } async function denyAccessRequestRow(requestId, reason, btn) { if (!reason || !reason.trim()) { showNotice("error", "برای رد درخواست، نوشتن دلیل الزامی است."); return; } btn.disabled = true; try { await api.denyAccessRequest(requestId, reason.trim()); await refreshOne("accessRequests"); } catch (err) { showNotice("error", `رد درخواست دسترسی ناموفق بود: ${err.message || err}`); btn.disabled = false; } } /* ── Admin panel phase 6: maintenance mode ─────────────────────────── * A switch, not a trap (docs/admin-panel-architecture.md / phase 6 * spec §1) -- this card is the one place on the page that can change * whether analyst queries are being answered at all, so its state is * rendered as a severity banner, the same visual language the * deployment-checks rail already uses for a failure. */ function renderMaintenance(state) { const body = $("maintenance-body"); const cls = state.active ? "fail" : "pass"; const label = state.active ? "روشن — پرس‌وجوهای جدید رد می‌شوند" : "خاموش — سامانه عادی کار می‌کند"; const rows = []; if (state.note) rows.push(kv("یادداشت", state.note)); if (state.since) rows.push(kv("از زمان", state.since)); if (state.actor_principal_id) rows.push(kv("توسط", state.actor_principal_id)); const noteField = state.active ? "" : ``; const btnLabel = state.active ? "خاموش کردن" : "روشن کردن"; body.innerHTML = `

${escapeHtml(label)}

` + (rows.length ? `
${rows.join("")}
` : "") + `
${noteField}` + `` + `
`; $("maintenance-toggle-btn").addEventListener("click", async () => { const btn = $("maintenance-toggle-btn"); btn.disabled = true; try { if (state.active) { await api.setMaintenance(false); } else { const noteInput = $("maintenance-note-input"); await api.setMaintenance(true, noteInput ? noteInput.value : ""); } await refreshOne("maintenance"); } catch (err) { showNotice("error", `تغییر حالت تعمیر ناموفق بود: ${err.message || err}`); btn.disabled = false; } }); } /* ── Admin panel phase 6: schema drift -- read-only, proposes nothing ── */ /** Tables found in a different data source than schema.yaml assigns them * (report.misplaced_tables, schema_data/drift.py). The names stay LTR; the * last column is the exact value to put under the table's `datasource:`. */ function misplacedTablesTable(items) { const cell = (list) => `${list.map(escapeHtml).join(", ")}`; const rows = items.map((m) => { const suggested = Array.isArray(m.suggested_datasource) ? `[${m.suggested_datasource.join(", ")}]` : String(m.suggested_datasource); return ( `${escapeHtml(m.table)}${cell(m.missing_from || [])}` + `${cell(m.found_in || [])}datasource: ${escapeHtml(suggested)}` ); }); return ( '
' + "" + "" + `${rows.join("")}
جدولدر این منبع نیستدر این منبع پیدا شدمقدار پیشنهادی
` ); } function renderSchemaDrift(report) { const body = $("schemaDrift-body"); const misplaced = report.misplaced_tables || []; const noDrift = !misplaced.length && (!report.warehouse_only || !report.warehouse_only.length) && (!report.schema_only || !report.schema_only.length) && (!report.type_changed || !report.type_changed.length); const out = [formatCacheAge(report.cache)]; if (noDrift) { out.push('

انحرافی یافت نشد

'); } else { if (misplaced.length) { out.push('

جدول در منبع دادهٔ دیگری است (datasource را در schema.yaml اصلاح کنید)

'); out.push(misplacedTablesTable(misplaced)); } if (report.warehouse_only && report.warehouse_only.length) { out.push('

فقط در انبار داده (فعلاً غیرقابل پرس‌وجو)

'); out.push(`

${report.warehouse_only.map(escapeHtml).join(", ")}

`); } if (report.schema_only && report.schema_only.length) { out.push('

فقط در schema.yaml (در اجرا ناموفق خواهد شد)

'); out.push(`

${report.schema_only.map(escapeHtml).join(", ")}

`); } if (report.type_changed && report.type_changed.length) { out.push('

نوع ستون تغییر کرده

'); out.push(twoColumnTable( "ستون", "نوع قبلی → نوع فعلی", report.type_changed.map((c) => [c.column, `${c.previous_type} → ${c.current_type}`]), )); } } if (!report.baseline_available) { out.push('

اولین اجرا — مبنایی برای مقایسهٔ نوع ستون هنوز ثبت نشده است.

'); } body.innerHTML = out.join(""); } /* ── Admin panel phase 6: vocabulary freshness + manual refresh ─────── */ function renderVocabulary(payload) { const body = $("vocabulary-body"); const columns = payload.columns || []; const wrap = document.createElement("div"); wrap.className = "admin-table-wrap"; const table = document.createElement("table"); table.className = "admin-table"; table.innerHTML = "ستونوضعیتتعداد مقدار" + "آخرین بروزرسانی"; const tbody = document.createElement("tbody"); columns.forEach((c) => { const freshCls = c.cached ? (c.is_fresh ? "pass" : "skip") : "fail"; const freshLabel = c.cached ? (c.is_fresh ? "تازه" : "کهنه") : "هرگز"; const failureNote = c.last_failure ? `آخرین تلاش ناموفق` : ""; const tr = document.createElement("tr"); tr.innerHTML = `${escapeHtml(c.table)}.${escapeHtml(c.column)}` + `${escapeHtml(freshLabel)} ${failureNote}` + `${c.value_count === null ? "—" : escapeHtml(fmtNum(c.value_count))}` + `${whenHtml(c.fetched_at)}`; const actionTd = document.createElement("td"); const btn = document.createElement("button"); btn.className = "admin-btn-refresh"; btn.type = "button"; btn.textContent = "بازخوانی"; /* Same reasoning as renderKeys' data-pid/data-hash: table/column * reach the DOM as `dataset` assignments, never interpolated into a * quoted `data-vocab-refresh="..."` attribute string, so neither * value can break out of an attribute regardless of its contents. */ btn.dataset.vocabRefresh = `${c.table}|${c.column}`; actionTd.appendChild(btn); tr.appendChild(actionTd); tbody.appendChild(tr); }); table.appendChild(tbody); wrap.appendChild(table); body.innerHTML = ""; body.appendChild(wrap); body.querySelectorAll("[data-vocab-refresh]").forEach((btn) => { btn.addEventListener("click", async () => { const [table, column] = btn.dataset.vocabRefresh.split("|"); btn.disabled = true; try { const result = await api.vocabularyRefresh(table, column); if (!result.ok) { showNotice("error", `بازخوانی ${table}.${column} ناموفق بود: ${result.error}`); } await refreshOne("vocabulary"); } catch (err) { showNotice("error", `بازخوانی ناموفق بود: ${err.message || err}`); btn.disabled = false; } }); }); } /* ── Real-use quality ──────────────────────────────────────────────── * * One computation (observability/quality.py), two surfaces: this card and * scripts/analyze_audit_log.py's section. Two notes belong on the card and * are not optional: * * - the confirmed share is NOT accuracy. Analysts rate only some answers, * and an unrated answer is not a correct one; * - the llm stage split (ttft / generation) exists only for calls made with * LLM_STREAM_TIMINGS=true, so it is often empty -- say why, not "no data". * * Every field is read defensively: an older server, or an empty log, must * leave a sentence in the card, never a thrown error or a blank box. */ const QUALITY_STAGES = ["plan", "prompt", "llm", "guard", "execute", "interpret"]; /** A 0..1 rate as a percentage string, or an em dash when it has no denominator. */ function fmtRate(rate) { if (rate === null || rate === undefined || Number.isNaN(Number(rate))) return "—"; return `${(Number(rate) * 100).toFixed(1)}%`; } /** Milliseconds rounded, or an em dash. */ function fmtMs(value) { if (value === null || value === undefined || Number.isNaN(Number(value))) return "—"; return String(Math.round(Number(value))); } function qualityStageRow(label, stats) { const s = stats || {}; return ( `${escapeHtml(label)}` + `${escapeHtml(fmtNum(s.count ?? 0))}` + `${fmtMs(s.p50)}` + `${fmtMs(s.p95)}` ); } function qualitySourceRow(name, m) { const ratings = m.ratings || {}; const rated = ratings.available ? `${fmtNum(ratings.confirmed ?? 0)} / ${fmtNum(ratings.flagged ?? 0)}` : "—"; const total = (m.latency && m.latency.total_ms) || {}; return ( `${escapeHtml(name)}` + `${escapeHtml(fmtNum(m.questions ?? 0))}` + `${fmtRate(m.execution_success_rate)}` + `${fmtRate(m.guard_rejection_rate)}` + `${fmtRate(m.needed_correction_rate)}` + `${fmtRate(m.empty_result_rate)}` + `${fmtRate(m.out_of_scope_rate)}` + `${rated}` + `${fmtMs(total.p50)}` + `${fmtMs(total.p95)}` ); } function qualitySection(title, section) { const overall = (section && section.overall) || null; if (!overall) return ""; const ratings = overall.ratings || {}; const latency = overall.latency || {}; const total = latency.total_ms || {}; const tiles = [ statTile({ label: "پرسش‌ها", value: fmtNum(overall.questions ?? 0) }), statTile({ label: "اجرای موفق", value: fmtRate(overall.execution_success_rate), sub: `${overall.executed ?? 0}` }), statTile({ label: "ردشده توسط گارد", value: fmtRate(overall.guard_rejection_rate), sub: `${overall.guard_rejected ?? 0}` }), statTile({ label: "نیازمند اصلاح", value: fmtRate(overall.needed_correction_rate), sub: `${overall.needed_correction ?? 0}` }), statTile({ label: "نتیجهٔ خالی (از اجراشده‌ها)", value: fmtRate(overall.empty_result_rate), sub: `${overall.empty_results ?? 0}` }), statTile({ label: "خارج از دامنه", value: fmtRate(overall.out_of_scope_rate), sub: `${overall.out_of_scope ?? 0}` }), statTile({ label: "تأییدشده / گزارش‌شده", value: ratings.available ? `${fmtNum(ratings.confirmed ?? 0)} / ${fmtNum(ratings.flagged ?? 0)}` : "—" }), statTile({ label: "سهم تأییدشده از ارزیابی‌شده‌ها", value: ratings.available ? fmtRate(ratings.confirmed_share_of_rated) : "—", sub: "حد پایین، نه دقت", }), statTile({ label: "تأخیر کل p50", value: fmtMs(total.p50), unit: "ms" }), statTile({ label: "تأخیر کل p95", value: fmtMs(total.p95), unit: "ms" }), ]; const classes = Object.entries(overall.error_rate_by_class || {}); const classRows = classes.map( ([bucket, e]) => `${escapeHtml(bucket)}` + `${escapeHtml(fmtNum(e.count ?? 0))}` + `${fmtRate(e.rate)}`, ); const bySource = Object.entries((section && section.by_datasource) || {}); const sourceRows = bySource.map(([name, m]) => qualitySourceRow(name, m)); const byStage = latency.by_stage_ms || {}; const split = latency.llm_split || {}; const stageRows = QUALITY_STAGES.map((stage) => qualityStageRow(stage, byStage[stage])); stageRows.push(qualityStageRow("llm · ttft", split.ttft_ms)); stageRows.push(qualityStageRow("llm · generation", split.generation_ms)); const out = [ `

${escapeHtml(title)}

`, `
${tiles.join("")}
`, ]; if (classRows.length) { out.push( '
' + "" + `${classRows.join("")}
دستهٔ خطاتعدادنرخ از کل پرسش‌ها
`, ); } if (sourceRows.length) { out.push( '
' + "" + "" + `${sourceRows.join("")}
منبع دادهپرسش‌هااجرای موفقگارداصلاحخالیخارج از دامنهتأیید / گزارشp50 (ms)p95 (ms)
`, ); } out.push( '
' + "" + `${stageRows.join("")}
مرحلهنمونه‌هاp50 (ms)p95 (ms)
`, ); return out.join(""); } function renderQuality(report) { const body = $("quality-body"); const days = report.window_days ?? 7; const sections = [ qualitySection(`${fmtNum(days)} روز گذشته`, report.recent), qualitySection("کل دوره", report.all_time), ].filter(Boolean); const out = [formatCacheAge(report.cache)]; if (!sections.length) { out.push('

داده‌ای در این بازه یافت نشد.

'); } else { out.push(...sections); } out.push( '

سهم تأییدشده «حد پایین» است، نه دقت: تحلیل‌گران فقط بخشی از پاسخ‌ها را ارزیابی می‌کنند و پاسخ ارزیابی‌نشده درست‌شمرده نمی‌شود.

', '

مراحل زمان‌بندی llm (ttft و generation) فقط با LLM_STREAM_TIMINGS=true نمایش داده می‌شوند.

', ); body.innerHTML = out.join(""); } /* ── Admin panel phase 6: per-analyst usage and rate-limit pressure ──── */ function renderUsage(report) { const body = $("usage-body"); const principals = report.principals || {}; const out = []; if (report.rate_limit_never_triggered) { out.push( '

محدودیت نرخ تاکنون برای هیچ‌کس فعال نشده است

', ); } const entries = Object.values(principals); if (!entries.length) { out.push('

داده‌ای در این بازه یافت نشد.

'); body.innerHTML = out.join(""); return; } const rows = entries.map((p) => { const lat = p.latency_ms || {}; return ` ${escapeHtml(p.principal_id)} ${escapeHtml(fmtNum(p.queries))} ${escapeHtml(fmtNum(p.failures))} ${lat.p50 !== null && lat.p50 !== undefined ? Math.round(lat.p50) : "—"} ${lat.p95 !== null && lat.p95 !== undefined ? Math.round(lat.p95) : "—"} ${escapeHtml(fmtNum(p.rate_limit_hits))} `; }); out.push( '
' + "" + `${rows.join("")}
تحلیل‌گرپرس‌وجوهاناموفقp50 (ms)p95 (ms)برخورد با محدودیت
`, ); body.innerHTML = out.join(""); } /* ── Admin panel phase 6 §9: failed-authentication visibility ───────── */ function renderAuthFailures(summary) { const body = $("authFailures-body"); const tiles = [ statTile({ label: "کل تلاش‌های ناموفق", value: fmtNum(summary.total ?? 0) }), statTile({ label: "از مسیرهای مدیریتی", value: fmtNum(summary.admin_path_total ?? 0) }), ]; const bySource = summary.by_source_ip || {}; const rows = Object.entries(bySource); body.innerHTML = `
${tiles.join("")}
` + (rows.length ? twoColumnTable("آدرس مبدأ", "تعداد", rows) : ""); } /* ── Admin panel phase 7: key lifecycle and roles ─────────────────── * * The routes behind this card have existed since phase 2 with nothing in * the UI calling them, so issuing a key or granting a role meant the CLI * plus a server restart -- and a revocation that phase 2 made immediate * was, in practice, "immediate once someone reaches a terminal". * * Three things here are not visible from the routes alone, and this card * exists to surface all three at the moment each one matters: * * 1. A key issued through the API starts with EVERY column denied * (appdb.key_store._maximally_restrictive_denied_columns). It * authenticates and can read nothing. That is deliberate -- issuing is * `operations`, and widening what a key can see is `security`'s -- but * an operations admin who does not know it has just minted a key that * looks broken to whoever they hand it to. * 2. Disable is reversible. Revoke is not. They are one button apart. * 3. The raw key exists exactly once, in the issue response. Nothing * stores it -- not the server, not this page. */ /** The two independent reads this card needs. Role holders are fetched * alongside the key list because a role attaches to a principal id rather * than to a key, so a row cannot show its own roles without them. */ async function loadKeysCard() { const [keys, ops, sec] = await Promise.all([ api.listKeys(), api.roleHolders("operations"), api.roleHolders("security"), ]); return { keys: keys.keys || [], operations: new Set(ops.principal_ids || []), security: new Set(sec.principal_ids || []), }; } function keyState(row) { if (row.revoked_at) return { label: "ابطال‌شده", cls: "fail" }; if (row.disabled_at) return { label: "غیرفعال", cls: "skip" }; return { label: "فعال", cls: "pass" }; } /** Each key's current `denied_columns`, by key hash, so the ACL prompt can * start from what is stored instead of an empty box (saving an empty box * clears the whole restriction). Filled by `renderKeys`. */ const deniedByHash = new Map(); /** A scoped entry (join-only: `schema.Table.Col`, `Source:schema.Table.Col`, * `Source:Col`) is any entry with a `.` or `:`; a plain name denies the * column everywhere. Mirrors security/column_policy.py. */ function isScopedEntry(entry) { return entry.includes(".") || entry.includes(":"); } function renderKeys({ keys, operations, security }) { const body = $("keys-body"); deniedByHash.clear(); if (!keys.length) { body.innerHTML = '

هیچ کلیدی ثبت نشده است.

'; return; } const wrap = document.createElement("div"); wrap.className = "admin-table-wrap"; const table = document.createElement("table"); table.className = "admin-table"; table.innerHTML = "شناسهناموضعیتمنبع" + "ستون‌های ممنوعاقدام"; const tbody = document.createElement("tbody"); keys.forEach((row) => { const st = keyState(row); const denied = row.denied_columns || []; /* "No restriction" and "some columns" are different kinds of fact, and * a bare count makes the first one read as zero of something rather * than the absence of a restriction. */ deniedByHash.set(row.key_sha256, denied); const joinOnly = denied.filter(isScopedEntry).length; const deniedLabel = denied.length === 0 ? "بدون محدودیت" : `${fmtNum(denied.length)} ستون` + (joinOnly ? ` (${fmtNum(joinOnly)} فقط برای JOIN)` : ""); const roleTags = (operations.has(row.principal_id) ? 'operations' : "") + (security.has(row.principal_id) ? 'security' : ""); const revoked = Boolean(row.revoked_at); const toggleAct = row.disabled_at ? "enable" : "disable"; const toggleLabel = row.disabled_at ? "فعال کردن" : "غیرفعال کردن"; const actions = revoked ? "" : `` + '' + '' + ''; const tr = document.createElement("tr"); /* Set through the element's own `dataset`, not interpolated into a * quoted `data-hash="..."` / `data-pid="..."` attribute string * (finding 2's actual sink). A value assigned this way is DOM text, * never re-parsed as markup, so a principal_id containing a `"` * cannot break out of an attribute -- there is no such attribute * string for it to break out of, whatever escapeHtml does or does * not encode. */ tr.dataset.hash = row.key_sha256; tr.dataset.pid = row.principal_id; tr.innerHTML = `${escapeHtml(row.principal_id)}${roleTags}` + `${escapeHtml(row.name)}` + `${st.label}` + `${escapeHtml(row.source === "imported_from_env" ? ".env" : "پنل")}` + `${escapeHtml(deniedLabel)}` + `${actions}`; tbody.appendChild(tr); }); table.appendChild(tbody); wrap.appendChild(table); body.innerHTML = ""; body.appendChild(wrap); const note = document.createElement("p"); note.className = "admin-loading"; note.textContent = "«غیرفعال کردن» برگشت‌پذیر است؛ «ابطال» نیست — ردیف بایگانی می‌شود و هرگز " + "حذف نمی‌شود، تا بازگرداندن دیتابیس به دیروز کلیدی را که نشت کرده دوباره " + "زنده نکند."; body.appendChild(note); body.querySelectorAll("button[data-act]").forEach((btn) => { btn.addEventListener("click", () => onKeyAction(btn)); }); } async function onKeyAction(btn) { const tr = btn.closest("tr"); const hash = tr.dataset.hash; const pid = tr.dataset.pid; const act = btn.dataset.act; try { if (act === "disable" || act === "enable") { btn.disabled = true; await (act === "disable" ? api.disableKey(hash) : api.enableKey(hash)); } else if (act === "revoke") { /* Typing the id, rather than confirming a yes/no: every row's * buttons sit in the same position, and this is the only one that * cannot be undone. A confirm dialog defends against not reading; * this defends against clicking the right button on the wrong row. */ const typed = window.prompt( "ابطال دائمی و بازگشت‌ناپذیر است. برای تأیید، شناسهٔ کلید را تایپ کنید:\n" + pid, ); if (typed !== pid) return; btn.disabled = true; await api.revokeKey(hash); } else if (act === "acl") { const answer = window.prompt( "محدودیت‌های ستونی این کلید، جدا شده با کاما. مقدار فعلی در کادر است.\n" + "نام ساده (مثلاً NationalID): ستون در همه‌جا ممنوع است.\n" + "schema.Table.Col : ستون فقط در JOIN ... ON مجاز است (در هر منبع).\n" + "Source:schema.Table.Col : همان، فقط وقتی پرس‌وجو روی منبع Source اجرا می‌شود.\n" + "Source:Col : همان، برای هر جدول Source که این ستون را دارد.\n" + "خالی بگذارید تا هیچ محدودیتی نداشته باشد.", (deniedByHash.get(hash) || []).join(", "), ); if (answer === null) return; const columns = answer.split(",").map((c) => c.trim()).filter(Boolean); btn.disabled = true; await api.updateKeyAcl(hash, columns); } else if (act === "role") { const answer = window.prompt( "نقش‌های " + pid + "\n" + "برای اعطا: operations یا security\n" + "برای سلب: -operations یا -security", ); if (!answer) return; const grant = !answer.trim().startsWith("-"); const capability = grant ? answer.trim() : answer.trim().slice(1).trim(); if (capability !== "operations" && capability !== "security") { showNotice("error", "نقش باید operations یا security باشد."); return; } btn.disabled = true; await api.changeRole(pid, capability, grant); } await refreshOne("keys"); } catch (err) { /* 409 is the server refusing to remove the last holder of a role, and * 403 is this key lacking `security` for an ACL or role change. Both * are real answers rather than failures of this page, so the server's * own message is shown instead of a generic one. */ showNotice("error", err.message || String(err)); btn.disabled = false; } } /* The one-time reveal. Deliberately not written to localStorage, not put * in the URL, and never re-rendered by a later refresh: this is the only * moment the raw key exists anywhere outside this page's memory, and * every additional place it lands is a place it can be read from later. * It also lives outside `keys-body` so a background refresh of the list * cannot re-render it away while the key it shows is still the only copy * in existence. */ function revealIssuedKey(payload) { const el = $("keys-reveal"); el.hidden = false; el.innerHTML = "

کلید خام — همین حالا کپی کنید

" + "

این مقدار فقط همین یک‌بار نمایش داده می‌شود و هیچ‌جا ذخیره نشده است. " + "اگر این صفحه را ببندید، بازیابی‌پذیر نیست.

" + `${escapeHtml(payload.raw_key)}` + `

${fmtNum(payload.raw_key.length)} نویسه. این چیزی است که صاحب کلید در فیلد ` + "بالای صفحه می‌گذارد — در .env نمی‌رود.

" + '

این کلید با همهٔ ستون‌ها ممنوع صادر ' + "شده است: احراز هویت می‌شود ولی تا وقتی یک ادمین امنیت با دکمهٔ «ستون‌ها» " + "محدودیتش را باز نکند، هیچ پرس‌وجویی برایش کار نمی‌کند.

" + ''; $("keys-reveal-dismiss").addEventListener("click", () => { el.hidden = true; el.innerHTML = ""; }); } $("keys-issue-btn").addEventListener("click", async () => { const principalId = window.prompt("شناسهٔ پایدار کلید (مثلاً analyst-2):"); if (!principalId || !principalId.trim()) return; const name = window.prompt("نام قابل خواندن (برای لاگ و ممیزی):"); if (!name || !name.trim()) return; const btn = $("keys-issue-btn"); btn.disabled = true; try { revealIssuedKey(await api.issueKey(principalId.trim(), name.trim())); await refreshOne("keys"); } catch (err) { showNotice("error", `صدور کلید ناموفق بود: ${err.message || err}`); } finally { btn.disabled = false; } });