/* 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.
این کلید برای این بخش دسترسی مدیریتی ندارد. ' + "توضیح در هشدار بالای صفحه آمده است.
"; 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 ( `از ${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(headA)} | ${escapeHtml(headB)} | ` + `
|---|
همین الان اجرا شد
'; } 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 ( `شامل بررسیهای عمیق (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 = `${escapeHtml(payload.project_config_dir || "")}
` + '| فایل | وضعیت | تعداد ورودی | خطا |
|---|
بازخوردی یافت نشد.
'; 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 = ( `${escapeHtml(label)}
` + (rows.length ? `| جدول | در این منبع نیست | در این منبع پیدا شد | " + "مقدار پیشنهادی | " + `
|---|
انحرافی یافت نشد
'); } 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 = "${escapeHtml(title)}
`, `| دستهٔ خطا | تعداد | نرخ از کل پرسشها | " + `
|---|
| منبع داده | پرسشها | اجرای موفق | گارد | اصلاح | " + "خالی | خارج از دامنه | تأیید / گزارش | p50 (ms) | p95 (ms) | " + `
|---|
| مرحله | نمونهها | p50 (ms) | p95 (ms) | " + `
|---|
دادهای در این بازه یافت نشد.
'); } else { out.push(...sections); } out.push( 'سهم تأییدشده «حد پایین» است، نه دقت: تحلیلگران فقط بخشی از پاسخها را ارزیابی میکنند و پاسخ ارزیابینشده درستشمرده نمیشود.
', 'مراحل زمانبندی llm (ttft و generation) فقط با LLM_STREAM_TIMINGS=true نمایش داده میشوند.
محدودیت نرخ تاکنون برای هیچکس فعال نشده است
', ); } 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 `| تحلیلگر | پرسوجوها | ناموفق | p50 (ms) | p95 (ms) | برخورد با محدودیت | " + `
|---|
هیچ کلیدی ثبت نشده است.
'; return; } const wrap = document.createElement("div"); wrap.className = "admin-table-wrap"; const table = document.createElement("table"); table.className = "admin-table"; table.innerHTML = "${escapeHtml(row.principal_id)}${roleTags}این مقدار فقط همین یکبار نمایش داده میشود و هیچجا ذخیره نشده است. " + "اگر این صفحه را ببندید، بازیابیپذیر نیست.
" + `${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; } });