Ctrl K
ComunidadGuías…

web/admin/main.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/* 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 = '<p class="admin-loading">در حال بارگذاری…</p>';
  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 = `<p class="admin-loading">${CARD_UNREACHABLE_TEXT}</p>`;
      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 =
        `<p class="admin-loading">این بخش به نقش <code>${capability}</code> نیاز دارد و کلید شما آن را ندارد. ` +
        `کلیدی با <code>--${capability}</code> (یا <code>--full-admin</code>) صادر کنید — ` +
        `<code>python -m scripts.issue_api_key</code>.</p>`;
    } else if (err instanceof AdminForbiddenError) {
      body.innerHTML =
        '<p class="admin-loading">این کلید برای این بخش دسترسی مدیریتی ندارد. ' +
        "توضیح در هشدار بالای صفحه آمده است.</p>";
      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()
        ? '<p class="admin-loading">سرور کلید را نپذیرفت (۴۰۱). توضیح در پیام بالای صفحه آمده است.</p>'
        : '<p class="admin-loading">کلید API وارد نشده است. یک کلید مدیریتی وارد کنید.</p>';
      showNotice(
        "error",
        hasApiKey()
          ? "سرور کلید ذخیره‌شده را نپذیرفت (۴۰۱). کلید پاک نشده — اگر سرور تازه ری‌استارت شده دوباره امتحان کنید، وگرنه کلید مدیریتی تازه‌ای وارد کنید."
          : "کلید API وارد نشده است. یک کلید مدیریتی وارد کنید.",
      );
    } else if (err instanceof AdminApiError) {
      body.innerHTML = `<p class="admin-loading">خطا: ${escapeHtml(err.message)}</p>`;
    } else {
      body.innerHTML = `<p class="admin-loading">خطای غیرمنتظره: ${escapeHtml(String(err))}</p>`;
    }
  } 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, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&#39;");
}

/** 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 ? `<span class="admin-stat-unit">${escapeHtml(unit)}</span>` : "";
  const s = sub ? `<span class="admin-stat-sub" dir="ltr">${escapeHtml(sub)}</span>` : "";
  return (
    `<div class="admin-stat ${cls || ""}">` +
    `<span class="admin-stat-label">${escapeHtml(label)}</span>` +
    `<span class="admin-stat-value">${escapeHtml(value)}${u}</span>${s}</div>`
  );
}

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(`<span class="admin-mode-pill ${modeClass}">${escapeHtml(modeLabel)}</span>`);

  // ── 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(`<div class="admin-stats">${tiles.join("")}</div>`);

  const range = report.time_range || {};
  if (range.start || range.end) {
    out.push(
      `<p class="admin-loading">از ${whenHtml(range.start)} تا ${whenHtml(range.end)}</p>`,
    );
  }

  if (report.records_by_model && Object.keys(report.records_by_model).length) {
    out.push('<p class="admin-section-title">به تفکیک مدل</p>');
    out.push(twoColumnTable("مدل", "تعداد", Object.entries(report.records_by_model)));
  }

  const fr = report.finish_reason_distribution;
  if (fr && Object.keys(fr).length) {
    out.push('<p class="admin-section-title">دلیل پایان تولید</p>');
    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 `<span title="${escapeHtml(title)}">${escapeHtml(text)}</span>`;
}

function twoColumnTable(headA, headB, entries) {
  const rows = entries.map(
    ([k, v]) =>
      `<tr><td dir="ltr">${escapeHtml(k)}</td><td class="num">${escapeHtml(fmtNum(v))}</td></tr>`,
  );
  return (
    '<div class="admin-table-wrap"><table class="admin-table"><thead><tr>' +
    `<th>${escapeHtml(headA)}</th><th>${escapeHtml(headB)}</th>` +
    `</tr></thead><tbody>${rows.join("")}</tbody></table></div>`
  );
}

function kv(label, value) {
  return `<dt>${escapeHtml(label)}</dt><dd dir="ltr">${escapeHtml(value)}</dd>`;
}

/** 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 '<p class="admin-cache-age">همین الان اجرا شد</p>';
  }
  const age = Math.round(cache.age_seconds ?? 0);
  return (
    `<p class="admin-cache-age">نتیجهٔ کش‌شده — ${fmtNum(age)} ثانیه پیش ` +
    `(حداکثر ${fmtNum(cache.ttl_seconds ?? 0)} ثانیه)</p>`
  );
}

function renderHealth(payload) {
  const body = $("health-body");
  const checks = payload.checks || [];
  if (!checks.length) {
    body.innerHTML = '<p class="admin-loading">هیچ بررسی‌ای ثبت نشده است.</p>';
    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
    ? `<p class="admin-rail-summary has-failures"><strong>${fmtNum(failed)} بررسی ناموفق</strong> از ${fmtNum(checks.length)}</p>`
    : `<p class="admin-rail-summary"><strong>هر ${fmtNum(checks.length)} بررسی موفق</strong></p>`;

  // 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
      ? `<span class="admin-check-detail">${escapeHtml(c.detail)}</span>`
      : "";
    return (
      `<div class="admin-check ${cls}">` +
      `<span class="admin-check-mark" aria-hidden="true">${mark}</span>` +
      `<span><span class="admin-check-name">${escapeHtml(c.name)}</span>` +
      `<span class="admin-status-pill admin-status-${cls}" style="margin-inline-start:6px">${escapeHtml(c.status)}</span>` +
      detail +
      "</span></div>"
    );
  };

  // 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
    ? '<details class="admin-checks-passed">' +
      `<summary>${fmtNum(passed)} بررسی موفق</summary>` +
      `<div class="admin-rail">${passedCards.join("")}</div></details>`
    : "";

  const deepNote = payload.deep
    ? '<p class="admin-cache-age">شامل بررسی‌های عمیق (DDL/WAITFOR)</p>'
    : "";

  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 =
    `<div class="admin-stats">${tiles.join("")}</div>` +
    `<dl class="admin-kv">${rows.join("")}</dl>`;
}

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 `<tr>
      <td dir="ltr">${escapeHtml(f.file)}</td>
      <td><span class="admin-status-pill admin-status-${cls}">${escapeHtml(status)}</span></td>
      <td class="num">${f.count === undefined || f.count === null ? "—" : escapeHtml(fmtNum(f.count))}</td>
      <td>${escapeHtml(f.error || "")}</td>
    </tr>`;
  });
  body.innerHTML = (
    `<p class="admin-loading" dir="ltr">${escapeHtml(payload.project_config_dir || "")}</p>` +
    '<div class="admin-table-wrap"><table class="admin-table"><thead><tr>' +
    "<th>فایل</th><th>وضعیت</th><th>تعداد ورودی</th><th>خطا</th></tr></thead><tbody>" +
    rows.join("") +
    "</tbody></table></div>"
  );
}

/* ── 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 = `<div class="admin-stats">${tiles.join("")}</div>`;

  const body = $("feedback-body");
  if (!rows.length) {
    body.innerHTML = '<p class="admin-loading">بازخوردی یافت نشد.</p>';
    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
    ? `<p class="admin-check-detail">${escapeHtml(audit.question || "")}</p>`
    : '<p class="admin-check-detail">(دیگر قابل بازیابی از گزارش ممیزی نیست)</p>';
  const sqlLine = audit && audit.generated_sql
    ? `<pre class="admin-loading" dir="ltr" style="white-space:pre-wrap">${escapeHtml(audit.generated_sql)}</pre>`
    : "";
  const guardLine = audit && audit.guard
    ? `<span class="admin-status-pill admin-status-${audit.guard.verdict === "allowed" ? "pass" : "fail"}">${escapeHtml(audit.guard.verdict || "")}</span>`
    : "";

  const noteLine = row.note ? `<p class="admin-check-detail">یادداشت تحلیل‌گر: ${escapeHtml(row.note)}</p>` : "";

  let actionArea;
  if (row.status === "resolved") {
    actionArea = (
      `<p class="admin-check-detail">نتیجه: ${escapeHtml(RESOLUTION_LABELS[row.resolution_outcome] || row.resolution_outcome || "")}` +
      (row.resolution_note ? ` — ${escapeHtml(row.resolution_note)}` : "") +
      `</p>`
    );
  } else {
    const options = RESOLUTION_OUTCOMES
      .map((o) => `<option value="${o}">${escapeHtml(RESOLUTION_LABELS[o])}</option>`)
      .join("");
    actionArea = (
      `<div class="admin-feedback-actions" data-feedback-id="${row.feedback_id}">` +
      `<select class="admin-toggle fb-outcome">${options}</select>` +
      `<input type="text" class="fb-note" placeholder="یادداشت (برای «نقص نیست» الزامی است)">` +
      `<button class="admin-btn-refresh" data-resolve-id="${row.feedback_id}" type="button">ثبت نتیجه</button>` +
      `</div>`
    );
  }

  return (
    `<div class="admin-check ${cls}" data-row-id="${row.feedback_id}">` +
    `<span class="admin-check-mark" aria-hidden="true">${row.status === "resolved" ? "✓" : "●"}</span>` +
    `<span>` +
    `<span class="admin-check-name">#${row.feedback_id} · ${escapeHtml(CATEGORY_LABELS[row.category] || row.category)}</span> ` +
    guardLine +
    questionLine + sqlLine + noteLine + actionArea +
    `</span></div>`
  );
}

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
    ? ""
    : `<input type="text" id="maintenance-note-input" class="fb-note" placeholder="یادداشت برای تحلیل‌گران (اختیاری)">`;
  const btnLabel = state.active ? "خاموش کردن" : "روشن کردن";

  body.innerHTML =
    `<p class="admin-rail-summary ${state.active ? "has-failures" : ""}"><strong>${escapeHtml(label)}</strong></p>` +
    (rows.length ? `<dl class="admin-kv">${rows.join("")}</dl>` : "") +
    `<div class="admin-feedback-actions">${noteField}` +
    `<button class="admin-btn-refresh" id="maintenance-toggle-btn" type="button">${escapeHtml(btnLabel)}</button>` +
    `</div>`;

  $("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) => `<td dir="ltr">${list.map(escapeHtml).join(", ")}</td>`;
  const rows = items.map((m) => {
    const suggested = Array.isArray(m.suggested_datasource)
      ? `[${m.suggested_datasource.join(", ")}]`
      : String(m.suggested_datasource);
    return (
      `<tr><td dir="ltr">${escapeHtml(m.table)}</td>${cell(m.missing_from || [])}` +
      `${cell(m.found_in || [])}<td dir="ltr">datasource: ${escapeHtml(suggested)}</td></tr>`
    );
  });
  return (
    '<div class="admin-table-wrap"><table class="admin-table"><thead><tr>' +
    "<th>جدول</th><th>در این منبع نیست</th><th>در این منبع پیدا شد</th>" +
    "<th>مقدار پیشنهادی</th>" +
    `</tr></thead><tbody>${rows.join("")}</tbody></table></div>`
  );
}

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('<p class="admin-rail-summary"><strong>انحرافی یافت نشد</strong></p>');
  } else {
    if (misplaced.length) {
      out.push('<p class="admin-section-title">جدول در منبع دادهٔ دیگری است (datasource را در schema.yaml اصلاح کنید)</p>');
      out.push(misplacedTablesTable(misplaced));
    }
    if (report.warehouse_only && report.warehouse_only.length) {
      out.push('<p class="admin-section-title">فقط در انبار داده (فعلاً غیرقابل پرس‌وجو)</p>');
      out.push(`<p dir="ltr" class="admin-check-detail">${report.warehouse_only.map(escapeHtml).join(", ")}</p>`);
    }
    if (report.schema_only && report.schema_only.length) {
      out.push('<p class="admin-section-title">فقط در schema.yaml (در اجرا ناموفق خواهد شد)</p>');
      out.push(`<p dir="ltr" class="admin-check-detail">${report.schema_only.map(escapeHtml).join(", ")}</p>`);
    }
    if (report.type_changed && report.type_changed.length) {
      out.push('<p class="admin-section-title">نوع ستون تغییر کرده</p>');
      out.push(twoColumnTable(
        "ستون", "نوع قبلی → نوع فعلی",
        report.type_changed.map((c) => [c.column, `${c.previous_type} → ${c.current_type}`]),
      ));
    }
  }
  if (!report.baseline_available) {
    out.push('<p class="admin-loading">اولین اجرا — مبنایی برای مقایسهٔ نوع ستون هنوز ثبت نشده است.</p>');
  }
  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 =
    "<thead><tr><th>ستون</th><th>وضعیت</th><th>تعداد مقدار</th>" +
    "<th>آخرین بروزرسانی</th><th></th></tr></thead>";
  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
      ? `<span class="admin-status-pill admin-status-fail">آخرین تلاش ناموفق</span>`
      : "";

    const tr = document.createElement("tr");
    tr.innerHTML =
      `<td dir="ltr">${escapeHtml(c.table)}.${escapeHtml(c.column)}</td>` +
      `<td><span class="admin-status-pill admin-status-${freshCls}">${escapeHtml(freshLabel)}</span> ${failureNote}</td>` +
      `<td class="num">${c.value_count === null ? "—" : escapeHtml(fmtNum(c.value_count))}</td>` +
      `<td>${whenHtml(c.fetched_at)}</td>`;

    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 (
    `<tr><td dir="ltr">${escapeHtml(label)}</td>` +
    `<td class="num">${escapeHtml(fmtNum(s.count ?? 0))}</td>` +
    `<td class="num" dir="ltr">${fmtMs(s.p50)}</td>` +
    `<td class="num" dir="ltr">${fmtMs(s.p95)}</td></tr>`
  );
}

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 (
    `<tr><td dir="ltr">${escapeHtml(name)}</td>` +
    `<td class="num">${escapeHtml(fmtNum(m.questions ?? 0))}</td>` +
    `<td class="num" dir="ltr">${fmtRate(m.execution_success_rate)}</td>` +
    `<td class="num" dir="ltr">${fmtRate(m.guard_rejection_rate)}</td>` +
    `<td class="num" dir="ltr">${fmtRate(m.needed_correction_rate)}</td>` +
    `<td class="num" dir="ltr">${fmtRate(m.empty_result_rate)}</td>` +
    `<td class="num" dir="ltr">${fmtRate(m.out_of_scope_rate)}</td>` +
    `<td class="num">${rated}</td>` +
    `<td class="num" dir="ltr">${fmtMs(total.p50)}</td>` +
    `<td class="num" dir="ltr">${fmtMs(total.p95)}</td></tr>`
  );
}

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]) =>
      `<tr><td dir="ltr">${escapeHtml(bucket)}</td>` +
      `<td class="num">${escapeHtml(fmtNum(e.count ?? 0))}</td>` +
      `<td class="num" dir="ltr">${fmtRate(e.rate)}</td></tr>`,
  );

  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 = [
    `<p class="admin-section-title">${escapeHtml(title)}</p>`,
    `<div class="admin-stats">${tiles.join("")}</div>`,
  ];
  if (classRows.length) {
    out.push(
      '<div class="admin-table-wrap"><table class="admin-table"><thead><tr>' +
      "<th>دستهٔ خطا</th><th>تعداد</th><th>نرخ از کل پرسش‌ها</th>" +
      `</tr></thead><tbody>${classRows.join("")}</tbody></table></div>`,
    );
  }
  if (sourceRows.length) {
    out.push(
      '<div class="admin-table-wrap"><table class="admin-table"><thead><tr>' +
      "<th>منبع داده</th><th>پرسش‌ها</th><th>اجرای موفق</th><th>گارد</th><th>اصلاح</th>" +
      "<th>خالی</th><th>خارج از دامنه</th><th>تأیید / گزارش</th><th>p50 (ms)</th><th>p95 (ms)</th>" +
      `</tr></thead><tbody>${sourceRows.join("")}</tbody></table></div>`,
    );
  }
  out.push(
    '<div class="admin-table-wrap"><table class="admin-table"><thead><tr>' +
    "<th>مرحله</th><th>نمونه‌ها</th><th>p50 (ms)</th><th>p95 (ms)</th>" +
    `</tr></thead><tbody>${stageRows.join("")}</tbody></table></div>`,
  );
  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('<p class="admin-loading">داده‌ای در این بازه یافت نشد.</p>');
  } else {
    out.push(...sections);
  }
  out.push(
    '<p class="admin-cache-age">سهم تأییدشده «حد پایین» است، نه دقت: تحلیل‌گران فقط بخشی از پاسخ‌ها را ارزیابی می‌کنند و پاسخ ارزیابی‌نشده درست‌شمرده نمی‌شود.</p>',
    '<p class="admin-cache-age">مراحل زمان‌بندی llm (ttft و generation) فقط با <code>LLM_STREAM_TIMINGS=true</code> نمایش داده می‌شوند.</p>',
  );
  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(
      '<p class="admin-rail-summary"><strong>محدودیت نرخ تاکنون برای هیچ‌کس فعال نشده است</strong></p>',
    );
  }
  const entries = Object.values(principals);
  if (!entries.length) {
    out.push('<p class="admin-loading">داده‌ای در این بازه یافت نشد.</p>');
    body.innerHTML = out.join("");
    return;
  }
  const rows = entries.map((p) => {
    const lat = p.latency_ms || {};
    return `<tr>
      <td dir="ltr">${escapeHtml(p.principal_id)}</td>
      <td class="num">${escapeHtml(fmtNum(p.queries))}</td>
      <td class="num">${escapeHtml(fmtNum(p.failures))}</td>
      <td class="num" dir="ltr">${lat.p50 !== null && lat.p50 !== undefined ? Math.round(lat.p50) : "—"}</td>
      <td class="num" dir="ltr">${lat.p95 !== null && lat.p95 !== undefined ? Math.round(lat.p95) : "—"}</td>
      <td class="num">${escapeHtml(fmtNum(p.rate_limit_hits))}</td>
    </tr>`;
  });
  out.push(
    '<div class="admin-table-wrap"><table class="admin-table"><thead><tr>' +
    "<th>تحلیل‌گر</th><th>پرس‌وجوها</th><th>ناموفق</th><th>p50 (ms)</th><th>p95 (ms)</th><th>برخورد با محدودیت</th>" +
    `</tr></thead><tbody>${rows.join("")}</tbody></table></div>`,
  );
  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 =
    `<div class="admin-stats">${tiles.join("")}</div>` +
    (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 = '<p class="admin-loading">هیچ کلیدی ثبت نشده است.</p>';
    return;
  }

  const wrap = document.createElement("div");
  wrap.className = "admin-table-wrap";
  const table = document.createElement("table");
  table.className = "admin-table";
  table.innerHTML =
    "<thead><tr><th>شناسه</th><th>نام</th><th>وضعیت</th><th>منبع</th>" +
    "<th>ستون‌های ممنوع</th><th>اقدام</th></tr></thead>";
  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) ? '<span class="key-role">operations</span>' : "") +
      (security.has(row.principal_id) ? '<span class="key-role">security</span>' : "");
    const revoked = Boolean(row.revoked_at);
    const toggleAct = row.disabled_at ? "enable" : "disable";
    const toggleLabel = row.disabled_at ? "فعال کردن" : "غیرفعال کردن";

    const actions = revoked
      ? ""
      : `<button type="button" data-act="${toggleAct}">${toggleLabel}</button>` +
        '<button type="button" data-act="acl">ستون‌ها</button>' +
        '<button type="button" data-act="role">نقش‌ها</button>' +
        '<button type="button" data-act="revoke" class="key-danger">ابطال</button>';

    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 =
      `<td><code>${escapeHtml(row.principal_id)}</code>${roleTags}</td>` +
      `<td>${escapeHtml(row.name)}</td>` +
      `<td><span class="admin-status-pill admin-status-${st.cls}">${st.label}</span></td>` +
      `<td>${escapeHtml(row.source === "imported_from_env" ? ".env" : "پنل")}</td>` +
      `<td>${escapeHtml(deniedLabel)}</td>` +
      `<td class="key-actions">${actions}</td>`;
    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 =
    "<h3>کلید خام — همین حالا کپی کنید</h3>" +
    "<p>این مقدار فقط همین یک‌بار نمایش داده می‌شود و هیچ‌جا ذخیره نشده است. " +
    "اگر این صفحه را ببندید، بازیابی‌پذیر نیست.</p>" +
    `<code class="keys-reveal-value" dir="ltr">${escapeHtml(payload.raw_key)}</code>` +
    `<p>${fmtNum(payload.raw_key.length)} نویسه. این چیزی است که صاحب کلید در فیلد ` +
    "بالای صفحه می‌گذارد — در <code>.env</code> نمی‌رود.</p>" +
    '<p class="keys-reveal-warn">این کلید با <strong>همهٔ ستون‌ها ممنوع</strong> صادر ' +
    "شده است: احراز هویت می‌شود ولی تا وقتی یک ادمین امنیت با دکمهٔ «ستون‌ها» " +
    "محدودیتش را باز نکند، هیچ پرس‌وجویی برایش کار نمی‌کند.</p>" +
    '<button type="button" id="keys-reveal-dismiss">کپی کردم، ببند</button>';
  $("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;
  }
});