Spaces:
Sleeping
Sleeping
fix: 4 hardening fixes — sticky-retry, canned-msg copy, admin refresh wiring, recall privacy
2acdc9e | <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <!-- Cache-busting: force fresh load on every visit --> | |
| <meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate, max-age=0"> | |
| <meta http-equiv="Pragma" content="no-cache"> | |
| <meta http-equiv="Expires" content="0"> | |
| <meta name="robots" content="noindex,nofollow"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Admin Control Panel</title> | |
| <style> | |
| /* ──────────────────────────────────────────────────────────────────── | |
| * Admin panel — restyled to the front-page editorial-fintech design | |
| * system. Token values mirror frontend/src/app/globals.css :root | |
| * exactly; font stacks mirror layout.tsx (Fraunces display serif + | |
| * Plus Jakarta Sans body) with the same fallback chains as globals.css | |
| * @theme inline. Self-contained (no CDN): the Google families are the | |
| * preferred face, falling back to the identical system stack the front | |
| * page declares. Legacy variable NAMES (--bg/--text/--blue/--green …) | |
| * are kept so the existing render JS that references them is untouched | |
| * — only their VALUES are remapped to the light/premium tokens. | |
| * ──────────────────────────────────────────────────────────────────── */ | |
| :root { | |
| /* globals.css :root design tokens */ | |
| --background: #fafaf9; | |
| --foreground: #1c1917; | |
| --primary-token: #0f766e; /* deep teal */ | |
| --primary-foreground: #ffffff; | |
| --surface-muted: #f5f5f4; | |
| --muted-foreground: #57534e; | |
| --border-token: #e7e5e4; | |
| --card-token: #ffffff; | |
| --accent: #fef3c7; /* warm amber */ | |
| --error: #dc2626; | |
| /* Font stacks — mirror globals.css @theme inline / layout.tsx */ | |
| --font-display: "Fraunces", ui-serif, Georgia, "Times New Roman", serif; | |
| --font-body: "Plus Jakarta Sans", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; | |
| --font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace; | |
| /* Legacy aliases (kept for the render JS) remapped to light tokens */ | |
| --bg: var(--background); | |
| --card: var(--card-token); | |
| --border: var(--border-token); | |
| --text: var(--foreground); | |
| --muted: var(--muted-foreground); | |
| --green: #15803d; /* light-theme success */ | |
| --yellow: #b45309; /* light-theme amber */ | |
| --red: var(--error); | |
| --blue: var(--primary-token); /* teal stands in for legacy blue */ | |
| --primary: var(--primary-token); | |
| /* Shared radii / shadow language from the front page */ | |
| --radius: 14px; | |
| --radius-sm: 11px; | |
| --shadow-card: | |
| 0 1px 2px color-mix(in srgb, var(--foreground) 4%, transparent), | |
| 0 18px 48px -34px color-mix(in srgb, var(--foreground) 30%, transparent); | |
| } | |
| * { box-sizing: border-box; } | |
| body { | |
| margin: 0; | |
| padding: 28px; | |
| background: | |
| radial-gradient(120% 60% at 50% -8%, | |
| color-mix(in srgb, var(--primary) 8%, transparent) 0%, | |
| transparent 60%), | |
| linear-gradient(180deg, | |
| color-mix(in srgb, var(--primary) 3%, var(--background)) 0%, | |
| var(--background) 38%); | |
| background-attachment: fixed; | |
| color: var(--text); | |
| font-family: var(--font-body); | |
| font-size: 14px; | |
| line-height: 1.55; | |
| min-height: 100vh; | |
| -webkit-font-smoothing: antialiased; | |
| } | |
| .mono { | |
| font-family: var(--font-mono); | |
| } | |
| h1 { | |
| margin: 0 0 24px 0; | |
| font-family: var(--font-display); | |
| font-optical-sizing: auto; | |
| font-size: 26px; | |
| font-weight: 600; | |
| letter-spacing: -0.014em; | |
| color: var(--foreground); | |
| } | |
| h2 { | |
| margin: 0 0 16px 0; | |
| font-family: var(--font-display); | |
| font-optical-sizing: auto; | |
| font-size: 19px; | |
| font-weight: 600; | |
| letter-spacing: -0.012em; | |
| color: var(--foreground); | |
| } | |
| h3 { | |
| margin: 0 0 12px 0; | |
| font-family: var(--font-display); | |
| font-size: 15px; | |
| font-weight: 600; | |
| letter-spacing: -0.008em; | |
| color: var(--text); | |
| } | |
| .container { | |
| max-width: 1500px; | |
| margin: 0 auto; | |
| padding: 0 8px; | |
| } | |
| @media (max-width: 1540px) { | |
| .container { max-width: 100%; } | |
| } | |
| @media (max-width: 720px) { | |
| body { padding: 16px; } | |
| .container { padding: 0; } | |
| } | |
| .card { | |
| background: var(--card); | |
| border: 1px solid var(--border); | |
| border-radius: 22px; | |
| padding: 24px; | |
| margin-bottom: 22px; | |
| box-shadow: var(--shadow-card); | |
| } | |
| .gate-card { | |
| max-width: 420px; | |
| margin: 80px auto 0; | |
| } | |
| .row { display: flex; align-items: center; gap: 12px; } | |
| .row-between { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; } | |
| input[type="password"], input[type="text"] { | |
| width: 100%; | |
| padding: 10px 12px; | |
| background: var(--card); | |
| color: var(--text); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius-sm); | |
| font-size: 14px; | |
| font-family: inherit; | |
| transition: border-color 0.18s ease, box-shadow 0.18s ease; | |
| } | |
| input[type="password"]::placeholder, input[type="text"]::placeholder { color: var(--muted-foreground); } | |
| input[type="password"]:focus, input[type="text"]:focus { | |
| outline: none; | |
| border-color: var(--primary); | |
| box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 14%, transparent); | |
| } | |
| input[type="checkbox"] { accent-color: var(--primary); } | |
| button { | |
| padding: 8px 15px; | |
| background: var(--card); | |
| color: var(--text); | |
| border: 1px solid var(--border); | |
| border-radius: 10px; | |
| cursor: pointer; | |
| font-size: 13px; | |
| font-weight: 500; | |
| font-family: inherit; | |
| transition: background 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease; | |
| } | |
| button:hover { | |
| border-color: color-mix(in srgb, var(--primary) 40%, var(--border)); | |
| background: color-mix(in srgb, var(--primary) 5%, var(--card)); | |
| } | |
| button:active { transform: scale(0.98); } | |
| button:disabled { opacity: 0.5; cursor: not-allowed; } | |
| button:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; } | |
| button.primary { | |
| background: linear-gradient(150deg, | |
| color-mix(in srgb, var(--primary) 94%, white) 0%, | |
| var(--primary) 60%); | |
| color: var(--primary-foreground); | |
| border-color: var(--primary); | |
| font-weight: 600; | |
| box-shadow: 0 8px 20px -10px color-mix(in srgb, var(--primary) 60%, transparent); | |
| } | |
| button.primary:hover { | |
| background: linear-gradient(150deg, | |
| color-mix(in srgb, var(--primary) 98%, white) 0%, | |
| color-mix(in srgb, var(--primary) 88%, black) 60%); | |
| transform: translateY(-1px); | |
| } | |
| button.danger { color: var(--red); border-color: color-mix(in srgb, var(--red) 45%, var(--border)); } | |
| button.danger:hover { background: color-mix(in srgb, var(--red) 7%, var(--card)); border-color: var(--red); } | |
| .icon-btn { padding: 4px 10px; font-size: 14px; line-height: 1; } | |
| .muted { color: var(--muted); } | |
| .small { font-size: 12px; } | |
| /* Tab nav */ | |
| .tabnav { | |
| display: flex; | |
| gap: 4px; | |
| margin-bottom: 20px; | |
| border-bottom: 1px solid var(--border); | |
| flex-wrap: wrap; | |
| } | |
| .tabnav button { | |
| background: transparent; | |
| border: 1px solid transparent; | |
| border-bottom: none; | |
| border-radius: 12px 12px 0 0; | |
| padding: 11px 18px; | |
| color: var(--muted); | |
| font-weight: 600; | |
| font-size: 13px; | |
| margin-bottom: -1px; | |
| cursor: pointer; | |
| transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease; | |
| } | |
| .tabnav button:hover { | |
| color: var(--foreground); | |
| background: color-mix(in srgb, var(--primary) 6%, var(--card)); | |
| border-color: color-mix(in srgb, var(--primary) 18%, var(--border)); | |
| transform: none; | |
| } | |
| .tabnav button.active { | |
| color: var(--primary); | |
| border-color: var(--border); | |
| border-bottom-color: var(--card); | |
| background: var(--card); | |
| box-shadow: | |
| 0 -1px 0 0 var(--primary) inset, | |
| 0 -2px 10px -6px color-mix(in srgb, var(--primary) 50%, transparent); | |
| } | |
| .tabpane { display: none; } | |
| .tabpane.active { display: block; } | |
| /* Counter pills */ | |
| .counters { display: flex; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; } | |
| .counter { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| padding: 9px 14px; | |
| background: var(--surface-muted); | |
| border: 1px solid var(--border); | |
| border-radius: 12px; | |
| font-weight: 600; | |
| } | |
| .counter .dot { | |
| width: 10px; height: 10px; border-radius: 50%; | |
| } | |
| .counter.healthy .dot { background: var(--green); } | |
| .counter.degraded .dot { background: var(--yellow); } | |
| .counter.down .dot { background: var(--red); } | |
| .counter .num { font-weight: 600; font-size: 16px; } | |
| /* Status pill */ | |
| .status { | |
| display: inline-block; | |
| padding: 2px 10px; | |
| border-radius: 999px; | |
| font-size: 11px; | |
| font-weight: 600; | |
| text-transform: uppercase; | |
| letter-spacing: 0.04em; | |
| border: 1px solid transparent; | |
| } | |
| .status.healthy { background: color-mix(in srgb, var(--green) 12%, var(--card)); color: var(--green); border-color: color-mix(in srgb, var(--green) 35%, var(--border)); } | |
| .status.degraded { background: color-mix(in srgb, var(--yellow) 12%, var(--card)); color: var(--yellow); border-color: color-mix(in srgb, var(--yellow) 35%, var(--border)); } | |
| .status.down { background: color-mix(in srgb, var(--red) 11%, var(--card)); color: var(--red); border-color: color-mix(in srgb, var(--red) 35%, var(--border)); } | |
| .status.unknown { background: var(--surface-muted); color: var(--muted); border-color: var(--border); } | |
| /* Tables — wrapped in a horizontally-scrollable shell so narrow | |
| viewports scroll the table, never the page. */ | |
| .table-wrap { | |
| width: 100%; | |
| overflow-x: auto; | |
| -webkit-overflow-scrolling: touch; | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius); | |
| background: var(--card); | |
| } | |
| .table-wrap::-webkit-scrollbar { height: 8px; } | |
| .table-wrap::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; } | |
| .table-wrap::-webkit-scrollbar-thumb:hover { background: var(--muted-foreground); } | |
| table { width: 100%; border-collapse: collapse; font-size: 13px; } | |
| thead th { | |
| text-align: left; | |
| padding: 12px 14px; | |
| background: var(--surface-muted); | |
| border-bottom: 1px solid var(--border); | |
| color: var(--muted); | |
| font-weight: 700; | |
| font-size: 11px; | |
| text-transform: uppercase; | |
| letter-spacing: 0.05em; | |
| white-space: nowrap; | |
| } | |
| tbody td { | |
| padding: 12px 14px; | |
| border-bottom: 1px solid var(--border); | |
| vertical-align: middle; | |
| } | |
| tbody tr:last-child td { border-bottom: none; } | |
| tbody tr:nth-child(even) td { background: color-mix(in srgb, var(--surface-muted) 50%, var(--card)); } | |
| tbody tr:hover td { background: color-mix(in srgb, var(--primary) 6%, var(--card)); } | |
| /* Sortable column header */ | |
| thead th.sortable { | |
| cursor: pointer; | |
| user-select: none; | |
| } | |
| thead th.sortable:hover { color: var(--foreground); } | |
| thead th.sortable .sort-ind { margin-left: 4px; color: var(--primary); } | |
| /* Chains */ | |
| .chains-grid { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); | |
| gap: 20px; | |
| } | |
| .chain-card h2 .role-tag { | |
| font-family: var(--font-mono); | |
| font-size: 12px; | |
| color: var(--primary); | |
| margin-left: 6px; | |
| } | |
| .chain-list { display: flex; flex-direction: column; gap: 8px; margin: 12px 0 16px; } | |
| .chain-item { | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| padding: 11px 14px; | |
| background: var(--surface-muted); | |
| border: 1px solid var(--border); | |
| border-radius: 12px; | |
| } | |
| .chain-item .label { | |
| flex: 1; | |
| font-family: var(--font-mono); | |
| font-size: 13px; | |
| word-break: break-all; | |
| } | |
| .chain-item .primary-star { | |
| color: var(--yellow); | |
| font-size: 14px; | |
| } | |
| .chain-item .move-buttons { display: flex; gap: 4px; } | |
| /* Toast */ | |
| #toast { | |
| position: fixed; | |
| bottom: 24px; | |
| right: 24px; | |
| padding: 13px 18px; | |
| border-radius: var(--radius); | |
| background: color-mix(in srgb, var(--card) 92%, transparent); | |
| -webkit-backdrop-filter: saturate(180%) blur(10px); | |
| backdrop-filter: saturate(180%) blur(10px); | |
| border: 1px solid var(--border); | |
| color: var(--text); | |
| font-size: 13px; | |
| font-weight: 500; | |
| max-width: 360px; | |
| box-shadow: 0 18px 48px -24px color-mix(in srgb, var(--foreground) 40%, transparent); | |
| opacity: 0; | |
| transform: translateY(10px); | |
| transition: opacity 0.2s ease, transform 0.2s ease; | |
| pointer-events: none; | |
| z-index: 1000; | |
| } | |
| #toast.show { opacity: 1; transform: translateY(0); } | |
| #toast.success { border-color: var(--green); } | |
| #toast.error { border-color: var(--red); } | |
| #toast.info { border-color: var(--primary); } | |
| /* Role badge — colored pill (BRAIN) */ | |
| .role-badge { | |
| display: inline-block; | |
| padding: 3px 10px; | |
| border-radius: 6px; | |
| font-size: 11px; | |
| font-weight: 700; | |
| letter-spacing: 0.06em; | |
| color: #fff; | |
| vertical-align: middle; | |
| margin-right: 8px; | |
| font-family: var(--font-mono); | |
| } | |
| /* Compact summary blocks above the chain table */ | |
| .summary-row { | |
| display: grid; | |
| grid-template-columns: 1fr 1fr; | |
| gap: 12px; | |
| margin: 12px 0 16px; | |
| } | |
| .summary-block { | |
| padding: 12px 14px; | |
| background: var(--surface-muted); | |
| border: 1px solid var(--border); | |
| border-radius: 12px; | |
| } | |
| .summary-block .label { | |
| font-size: 10px; | |
| text-transform: uppercase; | |
| letter-spacing: 0.08em; | |
| color: var(--muted); | |
| margin-bottom: 6px; | |
| } | |
| .summary-block .model-name { | |
| font-family: var(--font-mono); | |
| font-size: 13px; | |
| word-break: break-all; | |
| line-height: 1.35; | |
| } | |
| .summary-block .stats-line { | |
| margin-top: 6px; | |
| font-size: 11px; | |
| color: var(--muted); | |
| } | |
| .summary-block .stats-line .sep { | |
| margin: 0 6px; | |
| color: color-mix(in srgb, var(--muted) 55%, transparent); | |
| } | |
| .primary-mark { color: var(--green); margin-right: 4px; } | |
| /* Chain table */ | |
| .chain-table { width: 100%; border-collapse: collapse; font-size: 12px; margin-bottom: 12px; } | |
| .chain-table thead th { | |
| text-align: left; | |
| padding: 9px 10px; | |
| background: var(--surface-muted); | |
| border-bottom: 1px solid var(--border); | |
| color: var(--muted); | |
| font-weight: 700; | |
| font-size: 10px; | |
| text-transform: uppercase; | |
| letter-spacing: 0.05em; | |
| } | |
| .chain-table tbody td { | |
| padding: 8px 8px; | |
| border-bottom: 1px solid var(--border); | |
| vertical-align: middle; | |
| } | |
| .chain-table tbody tr:last-child td { border-bottom: none; } | |
| .chain-table .model-cell { | |
| font-family: var(--font-mono); | |
| word-break: break-all; | |
| max-width: 280px; | |
| } | |
| .chain-table .num-cell { text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; } | |
| .chain-table .icon-btn { padding: 2px 7px; font-size: 12px; line-height: 1.2; } | |
| .chain-table tbody tr.is-primary td { background: color-mix(in srgb, var(--green) 8%, var(--card)); } | |
| /* KI-162: simple 2-table layout for LLM Chain section (replaces legacy .llm-health-chains grid) */ | |
| .llm-simple-tables { display: flex; flex-direction: column; gap: 18px; } | |
| .llm-simple-tables .simple-table-block h3 { | |
| margin: 0 0 8px; | |
| padding: 12px 14px 0; | |
| font-family: var(--font-display); | |
| font-size: 14px; | |
| font-weight: 600; | |
| letter-spacing: -0.008em; | |
| color: var(--text); | |
| } | |
| /* LLM Chain tables scroll horizontally on narrow viewports rather | |
| than overflowing the page; carded to match the front-page system. */ | |
| .llm-simple-tables .simple-table-block { | |
| overflow-x: auto; | |
| -webkit-overflow-scrolling: touch; | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius); | |
| background: var(--card); | |
| box-shadow: 0 1px 2px color-mix(in srgb, var(--foreground) 4%, transparent); | |
| } | |
| .llm-simple-tables .simple-table-block::-webkit-scrollbar { height: 8px; } | |
| .llm-simple-tables .simple-table-block::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; } | |
| .llm-simple-tables table { | |
| width: 100%; | |
| min-width: 520px; | |
| border-collapse: collapse; | |
| font-size: 12px; | |
| } | |
| .llm-simple-tables thead th { | |
| text-align: left; | |
| padding: 9px 10px; | |
| background: var(--surface-muted); | |
| border-bottom: 1px solid var(--border); | |
| font-weight: 700; | |
| color: var(--muted); | |
| text-transform: uppercase; | |
| letter-spacing: 0.05em; | |
| font-size: 10px; | |
| } | |
| .llm-simple-tables tbody td { | |
| padding: 8px; | |
| border-bottom: 1px solid var(--border); | |
| vertical-align: middle; | |
| } | |
| .llm-simple-tables tbody tr:last-child td { border-bottom: none; } | |
| .llm-simple-tables td.model-name, | |
| .llm-simple-tables td.mono { | |
| font-family: var(--font-mono); | |
| word-break: break-all; | |
| } | |
| .llm-simple-tables td.use-label { | |
| font-weight: 600; | |
| white-space: nowrap; | |
| } | |
| .llm-simple-tables td.healthy-cell { text-align: center; width: 80px; } | |
| .llm-simple-tables .health-dot { | |
| display: inline-block; | |
| width: 10px; | |
| height: 10px; | |
| border-radius: 50%; | |
| } | |
| .llm-simple-tables .health-dot.ok { background: var(--green, #3fb950); } | |
| .llm-simple-tables .health-dot.bad { background: var(--red, #f85149); } | |
| .llm-simple-tables td.used-by-cell { color: var(--muted); } | |
| .llm-simple-tables td.probed-cell { | |
| color: var(--muted); | |
| white-space: nowrap; | |
| font-variant-numeric: tabular-nums; | |
| } | |
| /* KI-162: legacy .llm-health-chain* + .avail-pill CSS removed — replaced by .llm-simple-tables above */ | |
| /* Status badges — three colors: ✓ green / ⚠ amber / ✗ red */ | |
| .health-badge { | |
| display: inline-block; | |
| padding: 2px 8px; | |
| border-radius: 6px; | |
| font-size: 11px; | |
| font-weight: 700; | |
| letter-spacing: 0.04em; | |
| vertical-align: middle; | |
| font-family: var(--font-mono); | |
| } | |
| .health-badge.ok { background: color-mix(in srgb, var(--green) 12%, var(--card)); color: var(--green); border: 1px solid color-mix(in srgb, var(--green) 38%, var(--border)); } | |
| .health-badge.warn { background: color-mix(in srgb, var(--yellow) 12%, var(--card)); color: var(--yellow); border: 1px solid color-mix(in srgb, var(--yellow) 38%, var(--border)); } | |
| .health-badge.bad { background: color-mix(in srgb, var(--red) 11%, var(--card)); color: var(--red); border: 1px solid color-mix(in srgb, var(--red) 38%, var(--border)); } | |
| .health-badge.unknown { background: var(--surface-muted); color: var(--muted); border: 1px solid var(--border); } | |
| /* Candidate health grid */ | |
| .llm-health-candidates { | |
| overflow-x: auto; | |
| } | |
| .llm-health-candidates table { font-size: 12px; } | |
| .llm-health-candidates td.mono, | |
| .llm-health-candidates td.model-cell { | |
| font-family: var(--font-mono); | |
| word-break: break-all; | |
| max-width: 280px; | |
| } | |
| .llm-health-candidates td.num { text-align: right; font-variant-numeric: tabular-nums; } | |
| .llm-health-candidates td.chain-mem { | |
| font-size: 11px; | |
| color: var(--muted); | |
| } | |
| .llm-health-candidates td.credits { | |
| font-family: var(--font-mono); | |
| font-size: 11px; | |
| } | |
| /* Recent turns table */ | |
| .llm-health-recent table { font-size: 12px; } | |
| .llm-health-recent td { vertical-align: middle; } | |
| .llm-health-recent td.mono { | |
| font-family: var(--font-mono); | |
| font-size: 11px; | |
| word-break: break-all; | |
| max-width: 240px; | |
| } | |
| .llm-health-recent td.num { text-align: right; font-variant-numeric: tabular-nums; } | |
| .llm-health-recent .served-diff { color: var(--yellow); } | |
| .unverified-flag { | |
| display: inline-block; | |
| margin-left: 6px; | |
| padding: 1px 6px; | |
| font-size: 10px; | |
| letter-spacing: 0.04em; | |
| text-transform: uppercase; | |
| border-radius: 6px; | |
| background: color-mix(in srgb, var(--yellow) 14%, var(--card)); | |
| color: var(--yellow); | |
| border: 1px solid color-mix(in srgb, var(--yellow) 35%, var(--border)); | |
| } | |
| .hidden { display: none ; } | |
| .actions { display: flex; gap: 8px; flex-wrap: wrap; } | |
| .gate-error { | |
| margin-top: 12px; | |
| color: var(--red); | |
| font-size: 13px; | |
| } | |
| .last-updated { | |
| font-size: 12px; | |
| color: var(--muted); | |
| } | |
| /* Performance tab */ | |
| .metric-grid { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); | |
| gap: 12px; | |
| margin-bottom: 16px; | |
| } | |
| .metric-tile { | |
| padding: 14px 16px; | |
| background: | |
| linear-gradient(180deg, | |
| color-mix(in srgb, var(--primary) 4%, var(--card)) 0%, | |
| var(--card) 60%); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius); | |
| box-shadow: 0 1px 2px color-mix(in srgb, var(--foreground) 4%, transparent); | |
| } | |
| .metric-tile .value { font-family: var(--font-display); } | |
| .metric-tile .label { | |
| font-size: 10px; | |
| text-transform: uppercase; | |
| letter-spacing: 0.08em; | |
| color: var(--muted); | |
| margin-bottom: 6px; | |
| } | |
| .metric-tile .value { | |
| font-size: 20px; | |
| font-weight: 600; | |
| font-variant-numeric: tabular-nums; | |
| } | |
| .metric-tile .sub { | |
| font-size: 11px; | |
| color: var(--muted); | |
| margin-top: 2px; | |
| } | |
| .perf-sub { | |
| margin-top: 16px; | |
| padding-top: 16px; | |
| border-top: 1px solid var(--border); | |
| } | |
| .perf-sub:first-of-type { margin-top: 0; padding-top: 0; border-top: none; } | |
| /* Performance tab tables (gold by_brain / by_type breakdown + the | |
| Last-24h LLM-usage role table). Constrain width so they read well | |
| in the widened container; the .table-wrap scrolls on narrow | |
| viewports. overflow-wrap is intentionally normal so role / brain | |
| names are never split mid-word. */ | |
| #performance-body .table-wrap { max-width: 920px; } | |
| #performance-body table { width: 100%; min-width: 460px; } | |
| #performance-body td { overflow-wrap: normal; word-break: normal; } | |
| #performance-body td.mono { word-break: break-word; } | |
| #performance-body .metric-grid { max-width: 920px; } | |
| .empty-state { | |
| padding: 28px 24px; | |
| text-align: center; | |
| color: var(--muted-foreground); | |
| font-size: 13px; | |
| background: | |
| linear-gradient(180deg, | |
| color-mix(in srgb, var(--primary) 3%, var(--card)) 0%, | |
| var(--card) 60%); | |
| border: 1px dashed color-mix(in srgb, var(--primary) 22%, var(--border)); | |
| border-radius: var(--radius); | |
| box-shadow: inset 0 1px 2px color-mix(in srgb, var(--foreground) 3%, transparent); | |
| } | |
| .empty-state strong { color: var(--foreground); font-weight: 600; } | |
| .perf-caption { | |
| color: var(--muted); | |
| font-size: 12px; | |
| margin-bottom: 12px; | |
| } | |
| /* Pie / bar surrogate */ | |
| .bar-list { display: flex; flex-direction: column; gap: 6px; } | |
| .bar-row { | |
| display: grid; | |
| grid-template-columns: 120px 1fr 80px; | |
| align-items: center; | |
| gap: 10px; | |
| font-size: 12px; | |
| } | |
| .bar-row .bar-label { color: var(--text); } | |
| .bar-row .bar-track { | |
| height: 8px; | |
| background: var(--surface-muted); | |
| border: 1px solid var(--border); | |
| border-radius: 999px; | |
| overflow: hidden; | |
| } | |
| .bar-row .bar-fill { | |
| height: 100%; | |
| background: var(--primary); | |
| border-radius: 999px; | |
| } | |
| .bar-row .bar-value { | |
| text-align: right; | |
| color: var(--muted); | |
| font-variant-numeric: tabular-nums; | |
| } | |
| /* Profiles / Named-visitors table — Image #14 sibling fix: a | |
| min-width so on narrow viewports the .table-wrap scrolls | |
| horizontally instead of crushing the columns. Full name / slug | |
| remain in the title attr (set in JS) so the ellipsis never hides | |
| data. */ | |
| .profile-table { min-width: 760px; } | |
| .profile-table td.name-cell { | |
| font-weight: 500; | |
| max-width: 200px; | |
| white-space: nowrap; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| } | |
| .profile-table td.slug-cell { | |
| font-family: var(--font-mono); | |
| font-size: 12px; | |
| color: var(--muted); | |
| max-width: 140px; | |
| white-space: nowrap; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| } | |
| .profile-table td.num-cell { text-align: right; font-variant-numeric: tabular-nums; } | |
| .profile-table td.time-cell { white-space: nowrap; font-size: 12px; } | |
| /* A5 — stale-data indicator + table footer */ | |
| .table-footer { | |
| margin-top: 6px; | |
| font-size: 11px; | |
| color: var(--muted); | |
| display: flex; | |
| justify-content: flex-end; | |
| gap: 6px; | |
| font-variant-numeric: tabular-nums; | |
| } | |
| /* LEGACY (KI-208) — .stale-badge rules retained but no longer referenced by | |
| the LLM Health header (consolidated into a single-line refresh-meta row). | |
| Still used by per-table footers via setFooter(). Safe to keep. */ | |
| .stale-badge { | |
| display: inline-block; | |
| padding: 1px 7px; | |
| border-radius: 6px; | |
| font-size: 10px; | |
| font-weight: 700; | |
| letter-spacing: 0.04em; | |
| vertical-align: middle; | |
| font-family: var(--font-mono); | |
| background: color-mix(in srgb, var(--yellow) 14%, var(--card)); | |
| color: var(--yellow); | |
| border: 1px solid color-mix(in srgb, var(--yellow) 38%, var(--border)); | |
| margin-left: 6px; | |
| } | |
| .stale-badge.fresh { | |
| background: color-mix(in srgb, var(--green) 11%, var(--card)); | |
| color: var(--green); | |
| border-color: color-mix(in srgb, var(--green) 35%, var(--border)); | |
| } | |
| .stale-badge.down { | |
| background: color-mix(in srgb, var(--red) 11%, var(--card)); | |
| color: var(--red); | |
| border-color: color-mix(in srgb, var(--red) 38%, var(--border)); | |
| } | |
| /* KI-208 — consolidated LLM Health header row: heading + last refresh + | |
| next refresh + refresh button on one flex line. */ | |
| .llm-health-header-row { | |
| display: flex; | |
| align-items: center; | |
| gap: 12px; | |
| flex-wrap: wrap; | |
| margin-bottom: 8px; | |
| } | |
| .llm-health-header-row h2 { margin: 0; } | |
| .llm-refresh-meta { | |
| color: var(--muted, #888); | |
| font-size: 12px; | |
| display: inline-flex; | |
| gap: 6px; | |
| align-items: center; | |
| } | |
| .llm-refresh-meta .dot-sep { opacity: 0.5; } | |
| #btn-refresh-llm-health { margin-left: auto; } | |
| /* A5 — Persona drift panel + recommendation history panel */ | |
| .collapsible { | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius); | |
| margin-top: 16px; | |
| background: var(--card); | |
| box-shadow: var(--shadow-card); | |
| overflow: hidden; | |
| } | |
| .collapsible > summary { | |
| list-style: none; | |
| cursor: pointer; | |
| padding: 12px 16px; | |
| font-size: 14px; | |
| font-weight: 600; | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: 10px; | |
| user-select: none; | |
| } | |
| .collapsible > summary::-webkit-details-marker { display: none; } | |
| .collapsible > summary::before { | |
| content: '▸'; | |
| color: var(--muted); | |
| transition: transform 0.12s ease; | |
| font-size: 12px; | |
| margin-right: 8px; | |
| } | |
| .collapsible[open] > summary::before { transform: rotate(90deg); } | |
| .collapsible .panel-body { | |
| padding: 0 16px 16px; | |
| } | |
| .drift-row-bad td { background: color-mix(in srgb, var(--red) 7%, var(--card)) ; } | |
| /* Persona Drift table — Image #14 fix. | |
| (1) The table sits inside .table-wrap so it scrolls horizontally on | |
| narrow viewports instead of squashing columns / overflowing the | |
| page. | |
| (2) auto layout + sensible min-widths so COMPLETENESS (and its | |
| header) is always fully visible and nothing is cramped. | |
| (3) CAPTURED / MISSING render as wrapping chips (see .slot-list / | |
| .slot-chip below) — overflow-wrap is NORMAL so a field like | |
| "location_tier" can never split mid-word. */ | |
| #drift-body table { table-layout: auto; min-width: 880px; } | |
| #drift-body th:nth-child(1), #drift-body td:nth-child(1) { min-width: 190px; } | |
| #drift-body th:nth-child(2), #drift-body td:nth-child(2) { min-width: 130px; white-space: nowrap; } | |
| #drift-body th:nth-child(3), #drift-body td:nth-child(3) { min-width: 230px; } | |
| #drift-body th:nth-child(4), #drift-body td:nth-child(4) { min-width: 230px; } | |
| #drift-body th:nth-child(5), #drift-body td:nth-child(5) { min-width: 130px; white-space: nowrap; } | |
| #drift-body td.time-cell { white-space: nowrap; } | |
| /* Token-wrapping slot lists for the CAPTURED / MISSING cells. Each | |
| field name is its own chip; the flex row wraps between chips so a | |
| name is NEVER broken across lines mid-word. Used by the Persona | |
| Drift table (and any future slot-list cell). */ | |
| .slot-list { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 5px 6px; | |
| overflow-wrap: normal; | |
| word-break: normal; | |
| } | |
| .slot-chip { | |
| display: inline-block; | |
| padding: 2px 8px; | |
| border-radius: 999px; | |
| font-family: var(--font-mono); | |
| font-size: 11px; | |
| line-height: 1.5; | |
| white-space: nowrap; | |
| background: var(--surface-muted); | |
| border: 1px solid var(--border); | |
| color: var(--muted-foreground); | |
| } | |
| .slot-chip.miss { | |
| background: color-mix(in srgb, var(--yellow) 12%, var(--card)); | |
| border-color: color-mix(in srgb, var(--yellow) 35%, var(--border)); | |
| color: var(--yellow); | |
| } | |
| .slot-list-empty { color: var(--muted-foreground); } | |
| .drift-pct { | |
| display: inline-block; | |
| min-width: 48px; | |
| text-align: right; | |
| font-variant-numeric: tabular-nums; | |
| font-weight: 600; | |
| } | |
| .drift-pct.bad { color: var(--red); } | |
| .drift-pct.warn { color: var(--yellow); } | |
| .drift-pct.ok { color: var(--green); } | |
| .outcome-pill { | |
| display: inline-block; | |
| padding: 2px 8px; | |
| border-radius: 4px; | |
| font-size: 10px; | |
| font-weight: 700; | |
| letter-spacing: 0.04em; | |
| font-family: var(--font-mono); | |
| } | |
| .outcome-pill.selected { background: color-mix(in srgb, var(--green) 12%, var(--card)); color: var(--green); border: 1px solid color-mix(in srgb, var(--green) 35%, var(--border)); } | |
| .outcome-pill.rejected { background: color-mix(in srgb, var(--red) 11%, var(--card)); color: var(--red); border: 1px solid color-mix(in srgb, var(--red) 35%, var(--border)); } | |
| .outcome-pill.shown { background: color-mix(in srgb, var(--primary) 11%, var(--card)); color: var(--primary); border: 1px solid color-mix(in srgb, var(--primary) 35%, var(--border)); } | |
| .outcome-pill.abandoned{ background: var(--surface-muted); color: var(--muted); border: 1px solid var(--border); } | |
| .outcome-pill.error { background: color-mix(in srgb, var(--yellow) 12%, var(--card)); color: var(--yellow); border: 1px solid color-mix(in srgb, var(--yellow) 35%, var(--border)); } | |
| /* Field completeness bar */ | |
| .field-bar { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 6px; | |
| } | |
| .field-bar .track { | |
| width: 60px; | |
| height: 6px; | |
| background: var(--surface-muted); | |
| border: 1px solid var(--border); | |
| border-radius: 999px; | |
| overflow: hidden; | |
| } | |
| .field-bar .fill { | |
| height: 100%; | |
| background: var(--green); | |
| border-radius: 999px; | |
| } | |
| .field-bar .field-count { | |
| font-size: 11px; | |
| color: var(--muted); | |
| font-variant-numeric: tabular-nums; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <!-- Gate (password) --> | |
| <div id="gate" class="card gate-card"> | |
| <h1>Admin Control Panel</h1> | |
| <p class="muted small">Enter the admin password to unlock.</p> | |
| <div style="margin-top: 16px;"> | |
| <input id="gate-password" type="password" placeholder="Admin password" autocomplete="current-password"> | |
| </div> | |
| <div style="margin-top: 12px;" class="row"> | |
| <button id="gate-unlock" class="primary">Unlock</button> | |
| <label class="row small muted" style="gap:6px;"> | |
| <input id="gate-remember" type="checkbox" checked> | |
| Remember on this device | |
| </label> | |
| </div> | |
| <div id="gate-error" class="gate-error hidden"></div> | |
| </div> | |
| <!-- Main app --> | |
| <div id="app" class="container hidden"> | |
| <div class="row-between" style="margin-bottom: 16px;"> | |
| <h1 style="margin: 0;">Admin Control Panel</h1> | |
| <div class="row"> | |
| <span id="last-updated" class="last-updated"></span> | |
| <button id="lock-btn">Lock</button> | |
| </div> | |
| </div> | |
| <!-- Tab navigation --> | |
| <nav class="tabnav" role="tablist"> | |
| <button id="tabbtn-profiles" class="active" role="tab" aria-selected="true" data-tab="profiles">Profile + Visitor Log</button> | |
| <button id="tabbtn-performance" role="tab" aria-selected="false" data-tab="performance">Performance</button> | |
| <button id="tabbtn-chain" role="tab" aria-selected="false" data-tab="chain">LLM Chain</button> | |
| </nav> | |
| <!-- Tab 1: Profile + Visitor Log --> | |
| <section id="tab-profiles" class="tabpane active" role="tabpanel"> | |
| <div class="card"> | |
| <div class="row-between" style="margin-bottom: 12px;"> | |
| <h2>Named visitors</h2> | |
| <div class="actions"> | |
| <span id="profiles-summary" class="muted small"></span> | |
| <button id="btn-refresh-profiles">Refresh</button> | |
| </div> | |
| </div> | |
| <div id="profiles-body"> | |
| <div class="empty-state">Loading…</div> | |
| </div> | |
| <div id="profiles-footer" class="table-footer"></div> | |
| </div> | |
| <!-- A5 fix #4: Persona Drift panel (collapsible) --> | |
| <details class="collapsible" id="drift-panel"> | |
| <summary> | |
| <span>Persona Drift <span class="muted small" id="drift-summary" style="margin-left:8px;"></span></span> | |
| <button id="btn-refresh-drift" type="button">Refresh</button> | |
| </summary> | |
| <div class="panel-body"> | |
| <div id="drift-body"> | |
| <div class="empty-state">Loading…</div> | |
| </div> | |
| <div id="drift-footer" class="table-footer"></div> | |
| </div> | |
| </details> | |
| <!-- A5 fix #5: Recommendation History panel (collapsible) --> | |
| <details class="collapsible" id="rec-panel"> | |
| <summary> | |
| <span>Recommendation History <span class="muted small" id="rec-summary" style="margin-left:8px;"></span></span> | |
| <button id="btn-refresh-rec" type="button">Refresh</button> | |
| </summary> | |
| <div class="panel-body"> | |
| <div id="rec-body"> | |
| <div class="empty-state">Loading…</div> | |
| </div> | |
| <div id="rec-footer" class="table-footer"></div> | |
| </div> | |
| </details> | |
| </section> | |
| <!-- Tab 2: Performance --> | |
| <section id="tab-performance" class="tabpane" role="tabpanel"> | |
| <div class="card"> | |
| <div class="row-between" style="margin-bottom: 12px;"> | |
| <h2>Performance snapshot</h2> | |
| <div class="actions"> | |
| <button id="btn-refresh-performance">Refresh</button> | |
| </div> | |
| </div> | |
| <div id="performance-body"> | |
| <div class="empty-state">Loading…</div> | |
| </div> | |
| <div id="performance-footer" class="table-footer"></div> | |
| </div> | |
| </section> | |
| <!-- Tab 3: LLM Chain — KI-164: stripped to ONLY the 2-table view. --> | |
| <section id="tab-chain" class="tabpane" role="tabpanel"> | |
| <!-- KI-207 (2026-05-15) — per user instruction "There should be nothing else | |
| in that LLM router part of the control panel." Only the 3 elements below | |
| may live inside this section: the h2 header (with snapshot timestamp | |
| span), the refresh button, and the chains container that holds the | |
| two generated tables (Currently In Use + All Eligible Models). --> | |
| <div class="llm-health-header-row"> | |
| <h2>LLM Health</h2> | |
| <span class="llm-refresh-meta"> | |
| <span>Last refresh: <span id="llm-last-refresh-ago">--</span></span> | |
| <span class="dot-sep">·</span> | |
| <span>Next in: <span id="llm-next-refresh-in">--</span></span> | |
| </span> | |
| <button id="btn-refresh-llm-health">Refresh now</button> | |
| </div> | |
| <div id="llm-health-chains" class="llm-simple-tables"></div> | |
| <div id="llm-health-footer" class="table-footer"></div> | |
| </section> | |
| </div> | |
| <!-- Toast --> | |
| <div id="toast" role="status" aria-live="polite"></div> | |
| <script> | |
| (function () { | |
| 'use strict'; | |
| // ----- State ----- | |
| var STATE = { | |
| password: '', | |
| // LLM Chain tab | |
| health: null, // { models: [...], counters: {...} } | |
| chains: null, // { brain: [...] } (single-brain stack) | |
| usage: null, // { brain: {...} } (single-brain stack) | |
| // KI-086 — LLM Health & Credits snapshot | |
| // Shape: { chains: [...], candidates: [...], recent_turns: [...], snapshot_ts } | |
| llmHealth: null, | |
| llmHealthPollTimer: null, | |
| llmHealthLoadedAt: null, | |
| lastUpdatedAt: null, | |
| chainLoaded: false, | |
| // Profiles tab | |
| profiles: null, | |
| profilesLoaded: false, | |
| profilesLoadedAt: null, | |
| profilesSort: { key: 'last_seen', dir: 'desc' }, | |
| // A5 — Persona Drift panel | |
| drift: null, | |
| driftLoaded: false, | |
| driftLoadedAt: null, | |
| // A5 — Recommendation History panel | |
| recHistory: null, | |
| recHistoryLoaded: false, | |
| recHistoryLoadedAt: null, | |
| // Performance tab | |
| performance: null, | |
| performanceLoaded: false, | |
| performanceLoadedAt: null, | |
| // Active tab | |
| activeTab: 'profiles' | |
| }; | |
| // KI-2xx (2026-05-15) — single-brain stack. fast_brain + judge roles | |
| // retired; backend payload only emits `brain`. | |
| var ROLES = ['brain']; | |
| var ROLE_COLORS = { | |
| brain: '#58a6ff' // blue | |
| }; | |
| var ROLE_LABELS = { | |
| brain: 'BRAIN' | |
| }; | |
| var STORAGE_KEY = 'admin_password'; | |
| // ----- Utilities ----- | |
| function $(id) { return document.getElementById(id); } | |
| function clearChildren(el) { | |
| if (!el) return; | |
| while (el.firstChild) el.removeChild(el.firstChild); | |
| } | |
| function createEl(tag, opts) { | |
| var el = document.createElement(tag); | |
| if (!opts) return el; | |
| if (opts.className) el.className = opts.className; | |
| if (opts.text != null) el.textContent = String(opts.text); | |
| if (opts.title) el.title = String(opts.title); | |
| if (opts.attrs) { | |
| for (var k in opts.attrs) { | |
| if (Object.prototype.hasOwnProperty.call(opts.attrs, k)) { | |
| el.setAttribute(k, opts.attrs[k]); | |
| } | |
| } | |
| } | |
| return el; | |
| } | |
| function fmtLatency(ms) { | |
| if (ms == null || isNaN(ms)) return '—'; | |
| var n = Number(ms); | |
| if (n < 1000) return Math.round(n) + ' ms'; | |
| return (n / 1000).toFixed(2) + ' s'; | |
| } | |
| function fmtTime(t) { | |
| if (!t) return '—'; | |
| try { | |
| var d = new Date(t); | |
| if (isNaN(d.getTime())) return String(t); | |
| return d.toLocaleString(); | |
| } catch (e) { | |
| return String(t); | |
| } | |
| } | |
| // IST timestamp formatting (en-IN locale) | |
| function fmtIST(t) { | |
| if (!t) return '—'; | |
| try { | |
| var d = new Date(t); | |
| if (isNaN(d.getTime())) return String(t); | |
| return d.toLocaleString('en-IN'); | |
| } catch (e) { | |
| return String(t); | |
| } | |
| } | |
| function normalizeStatus(s) { | |
| if (!s) return 'unknown'; | |
| var v = String(s).toLowerCase(); | |
| if (v === 'healthy' || v === 'ok' || v === 'up') return 'healthy'; | |
| if (v === 'degraded' || v === 'warn' || v === 'warning') return 'degraded'; | |
| if (v === 'down' || v === 'error' || v === 'failed' || v === 'unhealthy') return 'down'; | |
| return 'unknown'; | |
| } | |
| // ----- Toast ----- | |
| var toastTimer = null; | |
| function toast(msg, kind) { | |
| var el = $('toast'); | |
| el.className = ''; // clear | |
| el.textContent = String(msg); | |
| el.classList.add(kind || 'info'); | |
| // force reflow then animate | |
| void el.offsetWidth; | |
| el.classList.add('show'); | |
| if (toastTimer) clearTimeout(toastTimer); | |
| toastTimer = setTimeout(function () { | |
| el.classList.remove('show'); | |
| }, 3500); | |
| } | |
| // ----- API ----- | |
| function apiHeaders() { | |
| return { | |
| 'X-Admin-Password': STATE.password, | |
| 'Accept': 'application/json', | |
| 'Content-Type': 'application/json' | |
| }; | |
| } | |
| function apiGet(path) { | |
| return fetch(path, { method: 'GET', headers: apiHeaders(), cache: 'no-store' }) | |
| .then(function (res) { | |
| if (res.status === 404) { | |
| var err = new Error('Not authorized'); | |
| err.code = 404; | |
| throw err; | |
| } | |
| if (!res.ok) { | |
| var e = new Error('Request failed (' + res.status + ')'); | |
| e.code = res.status; | |
| throw e; | |
| } | |
| return res.json(); | |
| }); | |
| } | |
| function apiPost(path, body) { | |
| return fetch(path, { | |
| method: 'POST', | |
| headers: apiHeaders(), | |
| cache: 'no-store', | |
| body: body ? JSON.stringify(body) : null | |
| }).then(function (res) { | |
| if (res.status === 404) { | |
| var err = new Error('Not authorized'); | |
| err.code = 404; | |
| throw err; | |
| } | |
| if (!res.ok) { | |
| var e = new Error('Request failed (' + res.status + ')'); | |
| e.code = res.status; | |
| throw e; | |
| } | |
| // Tolerate empty bodies | |
| return res.text().then(function (t) { | |
| if (!t) return {}; | |
| try { return JSON.parse(t); } catch (_) { return {}; } | |
| }); | |
| }); | |
| } | |
| // For new endpoints that may not be deployed yet — treat 404 as | |
| // "pending deployment" rather than auth failure, since the user is | |
| // already authenticated against /api/admin/health by this point. | |
| function apiGetTolerant(path) { | |
| return fetch(path, { method: 'GET', headers: apiHeaders(), cache: 'no-store' }) | |
| .then(function (res) { | |
| if (res.status === 404) { | |
| var err = new Error('Endpoint not found'); | |
| err.code = 404; | |
| err.notDeployed = true; | |
| throw err; | |
| } | |
| if (!res.ok) { | |
| var e = new Error('Request failed (' + res.status + ')'); | |
| e.code = res.status; | |
| throw e; | |
| } | |
| return res.json(); | |
| }); | |
| } | |
| // ----- LLM Chain renderers (preserved from original) ----- | |
| function renderCounters() { | |
| var wrap = $('counters'); | |
| clearChildren(wrap); | |
| var counts = computeCounters(); | |
| var entries = [ | |
| { key: 'healthy', label: 'Healthy' }, | |
| { key: 'degraded', label: 'Degraded' }, | |
| { key: 'down', label: 'Down' } | |
| ]; | |
| for (var i = 0; i < entries.length; i++) { | |
| var e = entries[i]; | |
| var div = createEl('div', { className: 'counter ' + e.key }); | |
| var dot = createEl('span', { className: 'dot' }); | |
| var num = createEl('span', { className: 'num', text: counts[e.key] }); | |
| var lbl = createEl('span', { text: e.label }); | |
| div.appendChild(dot); | |
| div.appendChild(num); | |
| div.appendChild(lbl); | |
| wrap.appendChild(div); | |
| } | |
| } | |
| function computeCounters() { | |
| var c = { healthy: 0, degraded: 0, down: 0 }; | |
| if (STATE.health && STATE.health.counters && typeof STATE.health.counters === 'object') { | |
| c.healthy = Number(STATE.health.counters.healthy) || 0; | |
| c.degraded = Number(STATE.health.counters.degraded) || 0; | |
| c.down = Number(STATE.health.counters.down) || 0; | |
| return c; | |
| } | |
| var rows = healthRows(); | |
| for (var i = 0; i < rows.length; i++) { | |
| var s = normalizeStatus(rows[i].status); | |
| if (s === 'healthy') c.healthy++; | |
| else if (s === 'degraded') c.degraded++; | |
| else if (s === 'down') c.down++; | |
| } | |
| return c; | |
| } | |
| function healthRows() { | |
| if (!STATE.health) return []; | |
| if (Array.isArray(STATE.health.models)) return STATE.health.models; | |
| if (Array.isArray(STATE.health)) return STATE.health; | |
| // Map form: { "model-a": { status, latency_ms, ... } } | |
| var out = []; | |
| for (var k in STATE.health) { | |
| if (Object.prototype.hasOwnProperty.call(STATE.health, k)) { | |
| var v = STATE.health[k]; | |
| if (v && typeof v === 'object' && !Array.isArray(v) && k !== 'counters') { | |
| var row = Object.assign({}, v); | |
| if (!row.model) row.model = k; | |
| out.push(row); | |
| } | |
| } | |
| } | |
| return out; | |
| } | |
| function statusPill(status) { | |
| var s = normalizeStatus(status); | |
| var span = createEl('span', { className: 'status ' + s, text: s }); | |
| return span; | |
| } | |
| function renderHealthTable() { | |
| var tbody = $('health-tbody'); | |
| clearChildren(tbody); | |
| var rows = healthRows(); | |
| if (!rows.length) { | |
| var tr = createEl('tr'); | |
| var td = createEl('td', { text: 'No data.', attrs: { colspan: '5' } }); | |
| td.classList.add('muted'); | |
| tr.appendChild(td); | |
| tbody.appendChild(tr); | |
| return; | |
| } | |
| // Sort: down > degraded > healthy > unknown, then by model name | |
| var rank = { down: 0, degraded: 1, healthy: 2, unknown: 3 }; | |
| rows.slice().sort(function (a, b) { | |
| var ra = rank[normalizeStatus(a.status)]; | |
| var rb = rank[normalizeStatus(b.status)]; | |
| if (ra !== rb) return ra - rb; | |
| return String(a.model || '').localeCompare(String(b.model || '')); | |
| }).forEach(function (r) { | |
| var tr = createEl('tr'); | |
| var tdModel = createEl('td'); | |
| var modelSpan = createEl('span', { className: 'mono', text: r.model || '—' }); | |
| tdModel.appendChild(modelSpan); | |
| tr.appendChild(tdModel); | |
| var tdStatus = createEl('td'); | |
| tdStatus.appendChild(statusPill(r.status)); | |
| tr.appendChild(tdStatus); | |
| var tdLat = createEl('td', { text: fmtLatency(r.latency_ms != null ? r.latency_ms : r.latency) }); | |
| tr.appendChild(tdLat); | |
| var tdErr = createEl('td'); | |
| var errText = r.last_error ? String(r.last_error) : '—'; | |
| var errSpan = createEl('span', { text: errText, title: errText }); | |
| if (!r.last_error) errSpan.classList.add('muted'); | |
| else errSpan.classList.add('mono', 'small'); | |
| // truncate visually via CSS if long | |
| errSpan.style.display = 'inline-block'; | |
| errSpan.style.maxWidth = '320px'; | |
| errSpan.style.overflow = 'hidden'; | |
| errSpan.style.textOverflow = 'ellipsis'; | |
| errSpan.style.whiteSpace = 'nowrap'; | |
| errSpan.style.verticalAlign = 'middle'; | |
| tdErr.appendChild(errSpan); | |
| tr.appendChild(tdErr); | |
| var tdSuccess = createEl('td', { text: fmtTime(r.last_success) }); | |
| if (!r.last_success) tdSuccess.classList.add('muted'); | |
| tr.appendChild(tdSuccess); | |
| tbody.appendChild(tr); | |
| }); | |
| } | |
| function statusForModel(modelName) { | |
| var rows = healthRows(); | |
| for (var i = 0; i < rows.length; i++) { | |
| if (rows[i].model === modelName) return rows[i].status; | |
| } | |
| return 'unknown'; | |
| } | |
| function usageStatsForRole(role) { | |
| var u = STATE.usage && STATE.usage[role]; | |
| if (!u || !Array.isArray(u.by_model)) return { byModel: {}, total: 0, payload: u || null }; | |
| var map = {}; | |
| u.by_model.forEach(function (m) { if (m && m.model) map[m.model] = m; }); | |
| return { byModel: map, total: Number(u.total_calls_24h) || 0, payload: u }; | |
| } | |
| function fmtPct(x) { | |
| if (x == null || isNaN(x)) return '—'; | |
| return (Number(x) * 100).toFixed(1) + '%'; | |
| } | |
| function renderChains() { | |
| var grid = $('chains-grid'); | |
| clearChildren(grid); | |
| if (!STATE.chains) return; | |
| ROLES.forEach(function (role) { | |
| var chain = STATE.chains[role]; | |
| if (!Array.isArray(chain)) chain = []; | |
| var stats = usageStatsForRole(role); | |
| var payload = stats.payload || {}; | |
| var primaryModel = payload.primary_model || chain[0] || null; | |
| var nextBest = payload.next_best_recommended || null; | |
| var nextBestUnverified = !!payload.next_best_is_unverified; | |
| var card = createEl('div', { className: 'card chain-card' }); | |
| // --- Header: colored badge + role label + 24h call count --- | |
| var header = createEl('div', { className: 'row-between' }); | |
| var headerLeft = createEl('div'); | |
| var badge = createEl('span', { className: 'role-badge', text: ROLE_LABELS[role] || String(role).toUpperCase() }); | |
| badge.style.background = ROLE_COLORS[role] || '#444'; | |
| headerLeft.appendChild(badge); | |
| var roleTitle = createEl('span', { text: 'Chain configuration' }); | |
| roleTitle.style.fontSize = '15px'; | |
| roleTitle.style.fontWeight = '600'; | |
| headerLeft.appendChild(roleTitle); | |
| header.appendChild(headerLeft); | |
| var headerRight = createEl('div', { className: 'small muted' }); | |
| var totalCalls = stats.total; | |
| var sr = (payload && payload.success_rate != null) ? fmtPct(payload.success_rate) : '—'; | |
| headerRight.textContent = totalCalls + ' calls / 24h · ' + sr + ' success'; | |
| header.appendChild(headerRight); | |
| card.appendChild(header); | |
| // --- Summary row: Currently primary + Next best recommended --- | |
| var summary = createEl('div', { className: 'summary-row' }); | |
| var primaryBlock = createEl('div', { className: 'summary-block' }); | |
| var primaryLabel = createEl('div', { className: 'label', text: 'Currently primary' }); | |
| primaryBlock.appendChild(primaryLabel); | |
| var primaryNameLine = createEl('div', { className: 'model-name' }); | |
| var primaryStar = createEl('span', { className: 'primary-mark', text: '★' }); | |
| primaryStar.style.color = 'var(--green)'; | |
| primaryNameLine.appendChild(primaryStar); | |
| primaryNameLine.appendChild(document.createTextNode(primaryModel || '—')); | |
| primaryBlock.appendChild(primaryNameLine); | |
| var primaryStatsRow = stats.byModel[primaryModel]; | |
| var primaryStatsLine = createEl('div', { className: 'stats-line' }); | |
| if (primaryStatsRow) { | |
| primaryStatsLine.appendChild(document.createTextNode(fmtPct(primaryStatsRow.share) + ' share')); | |
| primaryStatsLine.appendChild(createEl('span', { className: 'sep', text: '·' })); | |
| primaryStatsLine.appendChild(document.createTextNode(fmtLatency(primaryStatsRow.avg_latency_ms) + ' avg')); | |
| primaryStatsLine.appendChild(createEl('span', { className: 'sep', text: '·' })); | |
| primaryStatsLine.appendChild(document.createTextNode(fmtPct(primaryStatsRow.success_rate) + ' success')); | |
| } else { | |
| primaryStatsLine.textContent = 'No 24h traffic recorded yet.'; | |
| } | |
| primaryBlock.appendChild(primaryStatsLine); | |
| summary.appendChild(primaryBlock); | |
| var nextBlock = createEl('div', { className: 'summary-block' }); | |
| var nextLabel = createEl('div', { className: 'label', text: 'Next best recommended' }); | |
| nextBlock.appendChild(nextLabel); | |
| var nextNameLine = createEl('div', { className: 'model-name' }); | |
| nextNameLine.appendChild(document.createTextNode(nextBest || '—')); | |
| if (nextBest && nextBestUnverified) { | |
| var flag = createEl('span', { className: 'unverified-flag', text: 'unverified' }); | |
| nextNameLine.appendChild(flag); | |
| } | |
| nextBlock.appendChild(nextNameLine); | |
| var nextStatsLine = createEl('div', { className: 'stats-line' }); | |
| if (nextBest) { | |
| var nextHealth = statusForModel(nextBest); | |
| var nextHealthRow = healthRowFor(nextBest); | |
| var latStr = nextHealthRow ? fmtLatency(nextHealthRow.latency_ms != null ? nextHealthRow.latency_ms : nextHealthRow.latency) : '—'; | |
| nextStatsLine.appendChild(document.createTextNode('health: ' + normalizeStatus(nextHealth))); | |
| nextStatsLine.appendChild(createEl('span', { className: 'sep', text: '·' })); | |
| nextStatsLine.appendChild(document.createTextNode(latStr + ' last probe')); | |
| if (nextBestUnverified) { | |
| nextStatsLine.appendChild(createEl('span', { className: 'sep', text: '·' })); | |
| nextStatsLine.appendChild(document.createTextNode('no healthy alternative — fallback choice')); | |
| } | |
| } else { | |
| nextStatsLine.textContent = 'No alternative in chain.'; | |
| } | |
| nextBlock.appendChild(nextStatsLine); | |
| summary.appendChild(nextBlock); | |
| card.appendChild(summary); | |
| // --- Full chain table --- | |
| var table = createEl('table', { className: 'chain-table' }); | |
| var thead = createEl('thead'); | |
| var headRow = createEl('tr'); | |
| ['#', 'Model', 'Status', '24h calls', 'Share', 'Avg ms', 'Success', 'Actions'].forEach(function (h) { | |
| var th = createEl('th', { text: h }); | |
| if (h === '24h calls' || h === 'Share' || h === 'Avg ms' || h === 'Success' || h === '#') { | |
| th.style.textAlign = 'right'; | |
| } | |
| headRow.appendChild(th); | |
| }); | |
| thead.appendChild(headRow); | |
| table.appendChild(thead); | |
| var tbody = createEl('tbody'); | |
| table.appendChild(tbody); | |
| card.appendChild(table); | |
| function rerenderTable() { renderChainTableRows(tbody, role, stats); } | |
| rerenderTable(); | |
| // --- Save / Reload actions --- | |
| var actions = createEl('div', { className: 'actions' }); | |
| var saveBtn = createEl('button', { className: 'primary', text: 'Save' }); | |
| saveBtn.onclick = function () { | |
| saveBtn.disabled = true; | |
| saveBtn.textContent = 'Saving…'; | |
| var order = (STATE.chains[role] || []).slice(); | |
| apiPost('/api/admin/chain', { role: role, order: order }) | |
| .then(function () { | |
| toast('Saved chain for ' + role, 'success'); | |
| }) | |
| .catch(function (err) { | |
| toast('Save failed for ' + role + ': ' + (err && err.message ? err.message : 'unknown'), 'error'); | |
| }) | |
| .then(function () { | |
| saveBtn.disabled = false; | |
| saveBtn.textContent = 'Save'; | |
| }); | |
| }; | |
| var revertBtn = createEl('button', { text: 'Reload' }); | |
| revertBtn.onclick = function () { | |
| refreshChain().catch(handleFetchErr); | |
| }; | |
| actions.appendChild(saveBtn); | |
| actions.appendChild(revertBtn); | |
| card.appendChild(actions); | |
| grid.appendChild(card); | |
| }); | |
| } | |
| function renderChainTableRows(tbody, role, stats) { | |
| clearChildren(tbody); | |
| var chain = STATE.chains[role] || []; | |
| if (!chain.length) { | |
| var tr = createEl('tr'); | |
| var td = createEl('td', { text: 'Empty chain.', attrs: { colspan: '8' } }); | |
| td.classList.add('muted'); | |
| tr.appendChild(td); | |
| tbody.appendChild(tr); | |
| return; | |
| } | |
| chain.forEach(function (model, idx) { | |
| var tr = createEl('tr'); | |
| if (idx === 0) tr.classList.add('is-primary'); | |
| var idxCell = createEl('td', { className: 'num-cell', text: String(idx + 1) }); | |
| tr.appendChild(idxCell); | |
| var modelCell = createEl('td', { className: 'model-cell' }); | |
| if (idx === 0) { | |
| var star = createEl('span', { className: 'primary-mark', text: '★ ' }); | |
| modelCell.appendChild(star); | |
| } | |
| modelCell.appendChild(document.createTextNode(model)); | |
| tr.appendChild(modelCell); | |
| var statusCell = createEl('td'); | |
| statusCell.appendChild(statusPill(statusForModel(model))); | |
| tr.appendChild(statusCell); | |
| var b = stats.byModel[model]; | |
| var callsCell = createEl('td', { className: 'num-cell', text: b ? String(b.calls) : '0' }); | |
| tr.appendChild(callsCell); | |
| var shareCell = createEl('td', { className: 'num-cell', text: b ? fmtPct(b.share) : '—' }); | |
| tr.appendChild(shareCell); | |
| var latCell = createEl('td', { className: 'num-cell', text: b ? fmtLatency(b.avg_latency_ms) : '—' }); | |
| tr.appendChild(latCell); | |
| var successCell = createEl('td', { className: 'num-cell', text: b ? fmtPct(b.success_rate) : '—' }); | |
| tr.appendChild(successCell); | |
| var actionsCell = createEl('td'); | |
| var moves = createEl('span', { className: 'move-buttons' }); | |
| var up = createEl('button', { className: 'icon-btn', text: '↑', title: 'Move up' }); | |
| up.disabled = (idx === 0); | |
| up.onclick = function () { | |
| if (idx === 0) return; | |
| var arr = STATE.chains[role]; | |
| var tmp = arr[idx - 1]; | |
| arr[idx - 1] = arr[idx]; | |
| arr[idx] = tmp; | |
| renderChainTableRows(tbody, role, stats); | |
| }; | |
| var down = createEl('button', { className: 'icon-btn', text: '↓', title: 'Move down' }); | |
| down.disabled = (idx === chain.length - 1); | |
| down.onclick = function () { | |
| var arr = STATE.chains[role]; | |
| if (idx === arr.length - 1) return; | |
| var tmp = arr[idx + 1]; | |
| arr[idx + 1] = arr[idx]; | |
| arr[idx] = tmp; | |
| renderChainTableRows(tbody, role, stats); | |
| }; | |
| moves.appendChild(up); | |
| moves.appendChild(down); | |
| actionsCell.appendChild(moves); | |
| tr.appendChild(actionsCell); | |
| tbody.appendChild(tr); | |
| }); | |
| } | |
| function healthRowFor(modelName) { | |
| var rows = healthRows(); | |
| for (var i = 0; i < rows.length; i++) { | |
| if (rows[i].model === modelName) return rows[i]; | |
| } | |
| return null; | |
| } | |
| // Backend's background probe runs on this cadence (see | |
| // backend/llm_health.py::background_probe_loop — currently 5 min). | |
| var PROBE_INTERVAL_MS = 5 * 60 * 1000; | |
| function setLastUpdated() { | |
| STATE.lastUpdatedAt = Date.now(); | |
| renderUpdatedLabel(); | |
| } | |
| function formatMinSec(ms) { | |
| if (ms < 0) ms = 0; | |
| var s = Math.floor(ms / 1000); | |
| if (s < 60) return s + 's'; | |
| var m = Math.floor(s / 60); | |
| var rem = s % 60; | |
| if (m < 60) return rem > 0 ? (m + 'm ' + rem + 's') : (m + 'm'); | |
| var h = Math.floor(m / 60); | |
| return h + 'h ' + (m % 60) + 'm'; | |
| } | |
| function probeTimestampMs() { | |
| if (STATE.health && STATE.health.updated_at) { | |
| var t = Date.parse(STATE.health.updated_at); | |
| if (!isNaN(t)) return t; | |
| } | |
| return STATE.lastUpdatedAt; | |
| } | |
| function renderUpdatedLabel() { | |
| var ago = $('llm-last-refresh-ago'); | |
| var next = $('llm-next-refresh-in'); | |
| if (!ago || !next) return; | |
| // Only render the LLM-Chain refresh timing when that tab is active — | |
| // irrelevant on Profiles / Performance. | |
| if (STATE.activeTab !== 'chain') { | |
| ago.textContent = '--'; | |
| next.textContent = '--'; | |
| return; | |
| } | |
| var probeAt = probeTimestampMs(); | |
| if (!probeAt) { ago.textContent = '--'; next.textContent = '--'; return; } | |
| var now = Date.now(); | |
| ago.textContent = formatMinSec(now - probeAt) + ' ago'; | |
| // KI-258b — never display "due now". If the auto-probe is overdue, | |
| // project forward to the NEXT interval boundary so the countdown | |
| // always shows a real minutes:seconds value the user can act on. | |
| var elapsed = now - probeAt; | |
| var intoNext = ((elapsed % PROBE_INTERVAL_MS) + PROBE_INTERVAL_MS) % PROBE_INTERVAL_MS; | |
| var untilNext = PROBE_INTERVAL_MS - intoNext; | |
| next.textContent = formatMinSec(untilNext); | |
| } | |
| setInterval(renderUpdatedLabel, 1000); | |
| // ----- Fetchers (LLM Chain) ----- | |
| function fetchHealth() { | |
| return apiGet('/api/admin/health').then(function (data) { | |
| STATE.health = data; | |
| }); | |
| } | |
| function fetchChains() { | |
| return apiGet('/api/admin/chain').then(function (data) { | |
| var chains = data && data.chains ? data.chains : data; | |
| var clean = {}; | |
| ROLES.forEach(function (r) { | |
| var c = chains && chains[r]; | |
| clean[r] = Array.isArray(c) ? c.slice() : []; | |
| }); | |
| STATE.chains = clean; | |
| }); | |
| } | |
| function fetchUsage() { | |
| return apiGet('/api/admin/usage').then(function (data) { | |
| STATE.usage = (data && typeof data === 'object') ? data : null; | |
| }).catch(function () { | |
| STATE.usage = null; | |
| }); | |
| } | |
| function refreshChain() { | |
| // KI-164: only the simplified 2-table view remains on the LLM Chain | |
| // tab. fetchHealth/fetchChains/fetchUsage targeted DOM elements that | |
| // no longer exist (#counters, #chains-grid, #health-tbody) so they're | |
| // no longer called. fetchLlmHealth + renderLlmHealth feed the 2 tables. | |
| // | |
| // KI-296 (2026-05-27) — fetchHealth IS called again here so STATE.health | |
| // (source for the top-left "Last refresh / Next in" timer at L1623-1652) | |
| // reflects the actual last-probe timestamp on tab entry, not the | |
| // login-time snapshot. The DOM elements fetchHealth used to feed are | |
| // gone, but the JSON it returns still drives the consolidated header | |
| // row's timing display. | |
| return Promise.all([fetchHealth(), fetchLlmHealth()]).then(function () { | |
| renderLlmHealth(); | |
| setLastUpdated(); | |
| renderUpdatedLabel(); | |
| STATE.chainLoaded = true; | |
| }); | |
| } | |
| // ----- KI-086: LLM Health & Credits ----- | |
| // Fetcher — tolerant of 404 (endpoint not deployed yet); leaves | |
| // STATE.llmHealth null so renderer falls back to pending-message. | |
| function fetchLlmHealth() { | |
| return apiGetTolerant('/api/admin/llm-health').then(function (data) { | |
| STATE.llmHealth = data; | |
| STATE.llmHealthLoadedAt = Date.now(); | |
| }).catch(function (err) { | |
| if (err && err.notDeployed) { | |
| STATE.llmHealth = { __notDeployed: true }; | |
| } else { | |
| STATE.llmHealth = null; | |
| } | |
| }); | |
| } | |
| function fmtCredits(remaining, unit, lowWater) { | |
| if (remaining == null || unit == null) return '—'; | |
| var n = Number(remaining); | |
| if (isNaN(n)) return '—'; | |
| var rounded; | |
| if (n >= 1000) rounded = (n / 1000).toFixed(1) + 'K'; | |
| else if (n >= 1) rounded = n.toFixed(0); | |
| else rounded = n.toFixed(3); | |
| var unitLabel; | |
| switch (unit) { | |
| case 'tokens_day': unitLabel = 'tokens/day'; break; | |
| case 'tokens_min': unitLabel = 'tokens/min'; break; | |
| case 'usd_balance': unitLabel = 'USD'; break; | |
| case 'requests_min': unitLabel = 'req/min'; break; | |
| default: unitLabel = String(unit); | |
| } | |
| var s = rounded + ' ' + unitLabel; | |
| if (lowWater != null && Number(lowWater) > 0) { | |
| s += ' (water ' + Number(lowWater).toFixed(unit === 'usd_balance' ? 2 : 0) + ')'; | |
| } | |
| return s; | |
| } | |
| function fmtDurationShort(s) { | |
| if (s == null) return '—'; | |
| var n = Number(s); | |
| if (!isFinite(n) || n <= 0) return '0s'; | |
| if (n < 60) return Math.round(n) + 's'; | |
| if (n < 3600) return Math.floor(n / 60) + 'm ' + Math.round(n % 60) + 's'; | |
| var h = Math.floor(n / 3600); | |
| var rem = n - h * 3600; | |
| var m = Math.floor(rem / 60); | |
| return h + 'h ' + m + 'm'; | |
| } | |
| // Pick badge severity for the candidate state. | |
| // ✓ ok — healthy + no degraded window | |
| // ⚠ warn — degraded OR rate-limited (credits at/below water) OR credits exhausting OR degraded_for_seconds>0 | |
| // ✗ bad — down OR no probe within 600s (status='unknown' AND probe age >0) | |
| function candidateBadge(row) { | |
| var st = (row.status || 'unknown').toLowerCase(); | |
| var degFor = row.degraded_for_seconds; | |
| var credsRem = row.credits_remaining; | |
| var credsLow = row.credits_low_water; | |
| if (st === 'down') return { cls: 'bad', symbol: '✗', label: 'DOWN' }; | |
| if (degFor != null && degFor > 0) { | |
| return { cls: 'warn', symbol: '⚠', label: 'DEGRADED ' + fmtDurationShort(degFor) }; | |
| } | |
| if (credsRem != null && credsLow != null && Number(credsRem) <= Number(credsLow)) { | |
| return { cls: 'warn', symbol: '⚠', label: 'RATE-LIMITED' }; | |
| } | |
| if (st === 'degraded') return { cls: 'warn', symbol: '⚠', label: 'DEGRADED' }; | |
| if (st === 'healthy') return { cls: 'ok', symbol: '✓', label: 'HEALTHY' }; | |
| return { cls: 'unknown', symbol: '?', label: 'UNKNOWN' }; | |
| } | |
| // KI-162 — simplified 2-table renderer for the LLM Chain section. | |
| // Table 1: "What's Live" — one row per chain role with the currently-elected model. | |
| // Table 2: "What's Available" — NIM-only pool deduped across chains, with Healthy + Used By. | |
| // Replaces the per-chain cards / chain-members lists / availability pills. | |
| // KI-2xx (2026-05-15) — single-brain stack. Removed Brain Fast + Judge; | |
| // only the Brain role remains across both Currently In Use and | |
| // All Eligible Models tables. | |
| var SIMPLE_USE_LABELS = { | |
| brain: 'Brain' | |
| }; | |
| var SIMPLE_USE_ORDER = ['brain']; | |
| function isRetiredModel(model) { | |
| // Post-KI-160 NIM-only: filter out any stale Groq / OpenRouter entries | |
| // that might still appear in chain_members[] payloads. | |
| if (!model) return true; | |
| var m = String(model).toLowerCase(); | |
| return ( | |
| m.indexOf('groq:') === 0 || | |
| m.indexOf('openrouter:') === 0 || | |
| m.indexOf('or:') === 0 | |
| ); | |
| } | |
| // KI-2xx (2026-05-15) — single-brain stack. Only the `brain` chain | |
| // remains; fast_brain + judge chains were retired with the merged | |
| // sales-brain provider. Mirrors backend/sales_brain.py + | |
| // backend/providers/tiered_brain_llm.py + nvidia_nim_llm.py chain order. | |
| // Tier 0 = Google AI Studio (Gemini) — PRIMARY. | |
| // Tier 1 = NVIDIA NIM (chain order matters; last entry = "Last resort"). | |
| // Tier 2 = OpenRouter free pool — backstop inside NIM-failed path. | |
| var CHAIN_DEFINITIONS = { | |
| brain: { | |
| tier0: { provider: 'Google AI Studio', model: 'google/gemini-2.5-flash' }, | |
| tier1: [ | |
| 'qwen/qwen3-next-80b-a3b-instruct', | |
| 'mistralai/mistral-large-3-675b-instruct-2512', | |
| 'meta/llama-4-maverick-17b-128e-instruct', | |
| 'nvidia/llama-3.3-nemotron-super-49b-v1.5' | |
| ], | |
| tier2: [ | |
| 'nvidia/nemotron-3-super-120b-a12b:free', | |
| 'qwen/qwen3-next-80b-a3b-instruct:free', | |
| 'google/gemma-4-31b-it:free' | |
| ] | |
| } | |
| }; | |
| // KI-187 — provider colors: blue for Google, green for NVIDIA NIM, | |
| // orange for OpenRouter. | |
| var PROVIDER_COLORS = { | |
| 'Google AI Studio': '#4285f4', | |
| 'NVIDIA NIM': '#76b900', | |
| 'OpenRouter': '#ffa657' | |
| }; | |
| function providerForModel(model) { | |
| if (!model) return 'NVIDIA NIM'; | |
| if (model.indexOf('google/gemini') === 0) return 'Google AI Studio'; | |
| if (model.indexOf(':free') !== -1) return 'OpenRouter'; | |
| return 'NVIDIA NIM'; | |
| } | |
| // KI-200 — compute the model that would ACTUALLY serve right now for a | |
| // given role. Strategy: | |
| // 1. If Tier 0 exists for this role AND Gemini is available, return Tier 0. | |
| // 2. Else fall back to NIM's elected primary from chains[role].current_primary. | |
| // TODO(backend): expose top-level `gemini_available: bool` in | |
| // /api/admin/llm-health response. Until then we ASSUME Gemini is | |
| // available (true) — matches the deployed Tier-0-primary state. | |
| function resolveCurrentlyInUse(role, chainsByRole, healthPayload) { | |
| var def = CHAIN_DEFINITIONS[role]; | |
| var geminiAvailable = (healthPayload && typeof healthPayload.gemini_available === 'boolean') | |
| ? healthPayload.gemini_available | |
| : true; // default-assume true; backend can override later | |
| if (def && def.tier0 && geminiAvailable) { | |
| return { model: def.tier0.model, provider: def.tier0.provider, tier: 'T0', isTier0: true }; | |
| } | |
| var c = chainsByRole[role]; | |
| if (c && c.current_primary && !isRetiredModel(c.current_primary)) { | |
| return { model: c.current_primary, provider: providerForModel(c.current_primary), tier: 'T1', isTier0: false }; | |
| } | |
| return { model: '—', provider: '—', tier: '—', isTier0: false }; | |
| } | |
| // KI-200 — build the unioned model list across all chains with | |
| // per-chain role labels. Returns array of: | |
| // { model, provider, tier, roles: { brain } } | |
| // where `roles.brain` is one of: | |
| // 'Primary' | '1st backup' | '2nd backup' | '3rd backup' | 'Last resort' | null | |
| function buildEligibleModelTable() { | |
| var BACKUP_LABELS = ['Primary', '1st backup', '2nd backup', '3rd backup', '4th backup', '5th backup']; | |
| var models = {}; // model -> entry | |
| function ensureEntry(model, tier) { | |
| if (!models[model]) { | |
| models[model] = { | |
| model: model, | |
| provider: providerForModel(model), | |
| tier: tier, | |
| roles: { brain: null }, | |
| // for sorting: minimum chain position across all chains (lower = more important) | |
| minPos: 999, | |
| anyPrimary: false | |
| }; | |
| } | |
| return models[model]; | |
| } | |
| Object.keys(CHAIN_DEFINITIONS).forEach(function (role) { | |
| var def = CHAIN_DEFINITIONS[role]; | |
| // Build the ordered chain for this role: | |
| // [tier0?, ...tier1, ...tier2]. The LAST entry overall is "Last resort". | |
| var ordered = []; | |
| if (def.tier0) ordered.push({ model: def.tier0.model, tier: 'T0' }); | |
| (def.tier1 || []).forEach(function (m) { ordered.push({ model: m, tier: 'T1' }); }); | |
| (def.tier2 || []).forEach(function (m) { ordered.push({ model: m, tier: 'T2' }); }); | |
| var lastIdx = ordered.length - 1; | |
| ordered.forEach(function (slot, idx) { | |
| var entry = ensureEntry(slot.model, slot.tier); | |
| // If a model appears in multiple chains/tiers, the tier sticks to | |
| // its first-seen value — but Tier 0 wins (lowest number) since | |
| // Gemini fast-lite/flash are unique per role and never collide. | |
| if (slot.tier === 'T0') entry.tier = 'T0'; | |
| else if (entry.tier !== 'T0' && slot.tier === 'T1') entry.tier = 'T1'; | |
| // (T2 only overrides nothing.) | |
| var label; | |
| if (idx === lastIdx) { | |
| label = 'Last resort'; | |
| } else if (idx < BACKUP_LABELS.length) { | |
| label = BACKUP_LABELS[idx]; | |
| } else { | |
| label = (idx + 1) + 'th backup'; | |
| } | |
| entry.roles[role] = label; | |
| if (idx < entry.minPos) entry.minPos = idx; | |
| if (label === 'Primary') entry.anyPrimary = true; | |
| }); | |
| }); | |
| // Sort: tier asc (T0 < T1 < T2), then primaries first, then by minPos. | |
| var TIER_RANK = { 'T0': 0, 'T1': 1, 'T2': 2 }; | |
| var arr = Object.keys(models).map(function (k) { return models[k]; }); | |
| arr.sort(function (a, b) { | |
| var ta = TIER_RANK[a.tier] != null ? TIER_RANK[a.tier] : 99; | |
| var tb = TIER_RANK[b.tier] != null ? TIER_RANK[b.tier] : 99; | |
| if (ta !== tb) return ta - tb; | |
| if (a.anyPrimary !== b.anyPrimary) return a.anyPrimary ? -1 : 1; | |
| if (a.minPos !== b.minPos) return a.minPos - b.minPos; | |
| return a.model.localeCompare(b.model); | |
| }); | |
| return arr; | |
| } | |
| function renderRoleLabelCell(label) { | |
| var td = createEl('td', { className: 'role-cell' }); | |
| td.style.textAlign = 'center'; | |
| if (!label) { | |
| td.textContent = '—'; | |
| td.style.color = 'var(--muted)'; | |
| } else if (label === 'Primary') { | |
| td.textContent = 'Primary'; | |
| td.style.color = 'var(--green)'; | |
| td.style.fontWeight = '600'; | |
| } else if (label === 'Last resort') { | |
| td.textContent = 'Last resort'; | |
| td.style.color = 'var(--muted)'; | |
| td.style.fontStyle = 'italic'; | |
| } else { | |
| td.textContent = label; | |
| td.style.color = 'var(--text)'; | |
| } | |
| return td; | |
| } | |
| // KI-202 (2026-05-15) — operator-facing Health cell renderer. Reads | |
| // the per-model snapshot built from `healthPayload.candidates` (a | |
| // {model -> snapshot} map populated by renderLlmSimpleTables before | |
| // each row). Returns a <td> element so the caller can append directly. | |
| // | |
| // Display contract (matches admin spec): | |
| // Live — green dot + "Live" (effective_status='healthy') | |
| // Off — [reason] — red dot + "Off — reason" (everything else) | |
| // Reasons come from backend `health_reason`; fallback "unknown" when | |
| // the field is absent. The full `last_error` (when present) is | |
| // surfaced via a tooltip on hover. | |
| function renderHealthCell(snap) { | |
| var td = createEl('td', { className: 'health-cell' }); | |
| td.style.whiteSpace = 'nowrap'; | |
| var dot = createEl('span', { className: 'health-dot' }); | |
| dot.style.marginRight = '6px'; | |
| if (!snap) { | |
| // No snapshot for this model — render "Off — unknown" rather | |
| // than blank so the operator sees the gap. | |
| dot.className = 'health-dot bad'; | |
| td.appendChild(dot); | |
| td.appendChild(document.createTextNode('Off — unknown')); | |
| return td; | |
| } | |
| var eff = (snap.effective_status || snap.status || 'unknown').toLowerCase(); | |
| var isLive = (eff === 'healthy'); | |
| dot.className = 'health-dot ' + (isLive ? 'ok' : 'bad'); | |
| td.appendChild(dot); | |
| if (isLive) { | |
| td.appendChild(document.createTextNode('Live')); | |
| } else { | |
| var reason = snap.health_reason; | |
| if (!reason) { | |
| if (eff === 'stale') reason = 'stale'; | |
| else if (eff === 'unknown') reason = 'unknown'; | |
| else reason = 'unknown'; | |
| } | |
| td.appendChild(document.createTextNode('Off — ' + reason)); | |
| } | |
| if (snap.last_error) { | |
| td.title = String(snap.last_error); | |
| } | |
| return td; | |
| } | |
| // Build {model -> candidate snapshot} from healthPayload.candidates | |
| // so renderHealthCell() can look up by model id in O(1). | |
| function buildCandidatesMap(healthPayload) { | |
| var out = {}; | |
| var cands = (healthPayload && healthPayload.candidates) || []; | |
| cands.forEach(function (c) { | |
| if (c && c.model) out[c.model] = c; | |
| }); | |
| return out; | |
| } | |
| // KI-200 — accepts the full llm-health payload so we can read | |
| // `gemini_available` (when backend exposes it). Caller signature | |
| // updated below in renderLlmHealth(). | |
| function renderLlmSimpleTables(host, healthPayload) { | |
| var chains = (healthPayload && healthPayload.chains) || []; | |
| var chainsByRole = {}; | |
| chains.forEach(function (c) { if (c && c.role) chainsByRole[c.role] = c; }); | |
| // KI-202 — model -> candidate snapshot map, used by renderHealthCell(). | |
| var candByModel = buildCandidatesMap(healthPayload); | |
| // ---- Table 1: Currently In Use ---- | |
| var liveBlock = createEl('div', { className: 'simple-table-block' }); | |
| liveBlock.appendChild(createEl('h3', { text: 'Currently In Use' })); | |
| var liveTable = createEl('table'); | |
| var liveThead = createEl('thead'); | |
| var liveHr = createEl('tr'); | |
| // KI-202 — Health column appended at end (5 cols total). | |
| ['Use', 'Current Model', 'Provider', 'Tier', 'Health'].forEach(function (h, i) { | |
| var th = createEl('th', { text: h }); | |
| if (i >= 2 && i <= 3) th.style.textAlign = 'center'; | |
| liveHr.appendChild(th); | |
| }); | |
| liveThead.appendChild(liveHr); | |
| liveTable.appendChild(liveThead); | |
| var liveTbody = createEl('tbody'); | |
| SIMPLE_USE_ORDER.forEach(function (role) { | |
| var live = resolveCurrentlyInUse(role, chainsByRole, healthPayload); | |
| var tr = createEl('tr'); | |
| if (live.isTier0) { | |
| // KI-200 — subtle green wash to signal "real primary serving traffic" | |
| tr.style.background = 'rgba(118, 185, 0, 0.06)'; | |
| } | |
| tr.appendChild(createEl('td', { className: 'use-label', text: SIMPLE_USE_LABELS[role] })); | |
| var modelCell = createEl('td', { className: 'mono' }); | |
| modelCell.textContent = live.model; | |
| tr.appendChild(modelCell); | |
| var provCell = createEl('td', { className: 'provider-cell' }); | |
| provCell.style.textAlign = 'center'; | |
| provCell.style.fontWeight = '600'; | |
| provCell.style.color = PROVIDER_COLORS[live.provider] || 'var(--muted)'; | |
| provCell.textContent = live.provider; | |
| tr.appendChild(provCell); | |
| var tierCell = createEl('td', { className: 'tier-cell' }); | |
| tierCell.style.textAlign = 'center'; | |
| tierCell.style.fontWeight = '600'; | |
| tierCell.style.color = live.tier === 'T0' ? 'var(--green)' : 'var(--muted)'; | |
| tierCell.textContent = live.tier; | |
| tr.appendChild(tierCell); | |
| // KI-202 — HEALTH cell. Tier-0 (Gemini) currently has no | |
| // health snapshot in `candidates` (probe loop only covers NIM/ | |
| // OpenRouter chain entries), so fall back to a "Live" pill when | |
| // gemini_available flag is set / defaulted true. For Tier-1 NIM | |
| // rows we look up by model id in the candidates map. | |
| var healthSnap = candByModel[live.model]; | |
| if (!healthSnap && live.isTier0) { | |
| // Synthesize a "live" snapshot — Gemini availability is assumed | |
| // until backend exposes a per-Gemini health row. | |
| healthSnap = { effective_status: 'healthy' }; | |
| } | |
| tr.appendChild(renderHealthCell(healthSnap)); | |
| liveTbody.appendChild(tr); | |
| }); | |
| liveTable.appendChild(liveTbody); | |
| liveBlock.appendChild(liveTable); | |
| host.appendChild(liveBlock); | |
| // ---- Table 2: All Eligible Models ---- | |
| var availBlock = createEl('div', { className: 'simple-table-block' }); | |
| availBlock.appendChild(createEl('h3', { text: 'All Eligible Models' })); | |
| var availTable = createEl('table'); | |
| var availThead = createEl('thead'); | |
| var availHr = createEl('tr'); | |
| // KI-202 — Health column appended at end. | |
| var availCols = ['Model', 'Provider', 'Tier'] | |
| .concat(SIMPLE_USE_ORDER.map(function (r) { return SIMPLE_USE_LABELS[r]; })) | |
| .concat(['Health']); | |
| availCols.forEach(function (h, i) { | |
| var th = createEl('th', { text: h }); | |
| if (i >= 1 && i < availCols.length - 1) th.style.textAlign = 'center'; | |
| availHr.appendChild(th); | |
| }); | |
| availThead.appendChild(availHr); | |
| availTable.appendChild(availThead); | |
| var availTbody = createEl('tbody'); | |
| var rows = buildEligibleModelTable(); | |
| rows.forEach(function (entry) { | |
| var tr = createEl('tr'); | |
| tr.appendChild(createEl('td', { className: 'model-name', text: entry.model })); | |
| var provCell = createEl('td', { className: 'provider-cell' }); | |
| provCell.style.textAlign = 'center'; | |
| provCell.style.fontWeight = '600'; | |
| provCell.style.color = PROVIDER_COLORS[entry.provider] || 'var(--muted)'; | |
| provCell.textContent = entry.provider; | |
| tr.appendChild(provCell); | |
| var tierCell = createEl('td', { className: 'tier-cell' }); | |
| tierCell.style.textAlign = 'center'; | |
| tierCell.style.fontWeight = '600'; | |
| tierCell.style.color = entry.tier === 'T0' ? 'var(--green)' : 'var(--muted)'; | |
| tierCell.textContent = entry.tier; | |
| tr.appendChild(tierCell); | |
| SIMPLE_USE_ORDER.forEach(function (role) { | |
| tr.appendChild(renderRoleLabelCell(entry.roles[role])); | |
| }); | |
| // KI-202 — HEALTH cell. Same Tier-0 fallback as Table 1. | |
| var entryHealthSnap = candByModel[entry.model]; | |
| if (!entryHealthSnap && entry.tier === 'T0') { | |
| entryHealthSnap = { effective_status: 'healthy' }; | |
| } | |
| tr.appendChild(renderHealthCell(entryHealthSnap)); | |
| availTbody.appendChild(tr); | |
| }); | |
| availTable.appendChild(availTbody); | |
| availBlock.appendChild(availTable); | |
| host.appendChild(availBlock); | |
| } | |
| function renderLlmHealthCandidates(rows) { | |
| var wrap = $('llm-health-candidates'); | |
| clearChildren(wrap); | |
| if (!rows || !rows.length) { | |
| wrap.appendChild(createEl('div', { className: 'empty-state', text: 'No candidates known yet (probe loop hasn’t run).' })); | |
| return; | |
| } | |
| var table = createEl('table'); | |
| var thead = createEl('thead'); | |
| var tr = createEl('tr'); | |
| ['Status', 'Provider', 'Model', 'Chain(s)', 'Probe age', 'Latency', 'Success', 'Credits', 'Reset / sin-bin'].forEach(function (h, i) { | |
| var th = createEl('th', { text: h }); | |
| if (i >= 4) th.style.textAlign = i === 8 ? 'left' : 'right'; | |
| tr.appendChild(th); | |
| }); | |
| thead.appendChild(tr); | |
| table.appendChild(thead); | |
| var tbody = createEl('tbody'); | |
| rows.forEach(function (r) { | |
| var row = createEl('tr'); | |
| var b = candidateBadge(r); | |
| var stCell = createEl('td'); | |
| var badge = createEl('span', { className: 'health-badge ' + b.cls, text: b.symbol + ' ' + b.label }); | |
| stCell.appendChild(badge); | |
| row.appendChild(stCell); | |
| row.appendChild(createEl('td', { className: 'mono', text: (r.provider || '—').toUpperCase() })); | |
| row.appendChild(createEl('td', { className: 'model-cell', text: r.model })); | |
| var chainsLbl = (r.chain_membership || []).map(function (rr) { return (ROLE_LABELS[rr] || rr).toUpperCase(); }).join(', '); | |
| row.appendChild(createEl('td', { className: 'chain-mem', text: chainsLbl || '—' })); | |
| row.appendChild(createEl('td', { className: 'num', text: r.probe_age_seconds != null ? fmtDurationShort(r.probe_age_seconds) + ' ago' : '—' })); | |
| row.appendChild(createEl('td', { className: 'num', text: r.latency_ms != null ? fmtLatency(r.latency_ms) : '—' })); | |
| row.appendChild(createEl('td', { className: 'num', text: r.success_rate != null ? (Number(r.success_rate) * 100).toFixed(0) + '%' : '—' })); | |
| row.appendChild(createEl('td', { className: 'credits', text: fmtCredits(r.credits_remaining, r.credits_unit, r.credits_low_water) })); | |
| var resetParts = []; | |
| if (r.credits_reset_in_seconds != null && r.credits_reset_in_seconds > 0) { | |
| resetParts.push('quota in ' + fmtDurationShort(r.credits_reset_in_seconds)); | |
| } | |
| if (r.degraded_for_seconds != null && r.degraded_for_seconds > 0) { | |
| resetParts.push('sin-bin ' + fmtDurationShort(r.degraded_for_seconds)); | |
| } | |
| row.appendChild(createEl('td', { text: resetParts.length ? resetParts.join(' · ') : '—' })); | |
| tbody.appendChild(row); | |
| }); | |
| table.appendChild(tbody); | |
| wrap.appendChild(table); | |
| } | |
| function renderLlmHealthRecent(turns) { | |
| var wrap = $('llm-health-recent'); | |
| clearChildren(wrap); | |
| if (!turns || !turns.length) { | |
| wrap.appendChild(createEl('div', { className: 'empty-state', text: 'No turns logged yet.' })); | |
| return; | |
| } | |
| var table = createEl('table'); | |
| var thead = createEl('thead'); | |
| var tr = createEl('tr'); | |
| ['Time', 'Chain', 'Elected primary', 'Served model', 'Latency', 'Result'].forEach(function (h, i) { | |
| var th = createEl('th', { text: h }); | |
| if (i === 4) th.style.textAlign = 'right'; | |
| tr.appendChild(th); | |
| }); | |
| thead.appendChild(tr); | |
| table.appendChild(thead); | |
| var tbody = createEl('tbody'); | |
| turns.forEach(function (t) { | |
| var row = createEl('tr'); | |
| row.appendChild(createEl('td', { text: fmtIST(t.ts) })); | |
| var chainCell = createEl('td'); | |
| if (t.role) { | |
| var badge = createEl('span', { className: 'role-badge', text: (ROLE_LABELS[t.role] || t.role).toUpperCase() }); | |
| badge.style.background = ROLE_COLORS[t.role] || '#444'; | |
| chainCell.appendChild(badge); | |
| } else { | |
| chainCell.textContent = '—'; | |
| } | |
| row.appendChild(chainCell); | |
| var electedPrimary = t.elected_primary || t.chain_primary || '—'; | |
| row.appendChild(createEl('td', { className: 'mono', text: electedPrimary })); | |
| var servedCell = createEl('td', { className: 'mono' }); | |
| var served = t.served_model || '—'; | |
| servedCell.textContent = served; | |
| if (electedPrimary !== '—' && served !== '—' && served !== electedPrimary) { | |
| // Served by backup/fallback — flag visually. | |
| servedCell.classList.add('served-diff'); | |
| servedCell.title = 'Served by backup/fallback — elected primary was unavailable.'; | |
| } | |
| row.appendChild(servedCell); | |
| row.appendChild(createEl('td', { className: 'num', text: t.latency_ms != null ? fmtLatency(t.latency_ms) : '—' })); | |
| var resultCell = createEl('td'); | |
| var ok = t.success === true; | |
| var resBadge = createEl('span', { className: 'health-badge ' + (ok ? 'ok' : 'bad'), text: (ok ? '✓ OK' : '✗ FAIL') }); | |
| resultCell.appendChild(resBadge); | |
| if (t.fallback_reason) { | |
| var fr = createEl('span', { className: 'small muted', text: ' ' + t.fallback_reason }); | |
| resultCell.appendChild(fr); | |
| } | |
| row.appendChild(resultCell); | |
| tbody.appendChild(row); | |
| }); | |
| table.appendChild(tbody); | |
| wrap.appendChild(table); | |
| } | |
| function renderLlmHealth() { | |
| var chainsHost = $('llm-health-chains'); | |
| if (!chainsHost) return; | |
| // KI-208 — snapshot timestamp moved out of the header into the | |
| // consolidated "Last refresh / Next in" row driven by renderUpdatedLabel. | |
| if (!STATE.llmHealth) { | |
| clearChildren(chainsHost); | |
| chainsHost.appendChild(createEl('div', { className: 'empty-state', text: 'Loading…' })); | |
| clearChildren($('llm-health-candidates')); | |
| clearChildren($('llm-health-recent')); | |
| return; | |
| } | |
| if (STATE.llmHealth.__notDeployed) { | |
| clearChildren(chainsHost); | |
| chainsHost.appendChild(buildPendingMessage('/api/admin/llm-health')); | |
| clearChildren($('llm-health-candidates')); | |
| clearChildren($('llm-health-recent')); | |
| return; | |
| } | |
| clearChildren(chainsHost); | |
| // KI-200 — pass the full payload so renderer can read gemini_available | |
| renderLlmSimpleTables(chainsHost, STATE.llmHealth); | |
| renderLlmHealthCandidates(STATE.llmHealth.candidates || []); | |
| renderLlmHealthRecent(STATE.llmHealth.recent_turns || []); | |
| } | |
| // Auto-poll every 30s while the LLM Chain tab is the active tab. | |
| // The backend's probe loop only refreshes every 5min anyway — we poll | |
| // faster than that because credit + degraded-window state changes | |
| // sub-tick (chat hot path stamps both) and the operator needs to see | |
| // those without manually clicking Refresh. | |
| // | |
| // KI-296 (2026-05-27) — also re-fetch /api/admin/health so STATE.health | |
| // (source for the top-left "Last refresh / Next in" timer) stays in | |
| // sync with whatever the background_probe_loop just did. Without | |
| // this, the timer projects "Next in: 5min" from a stale login-time | |
| // anchor and never re-syncs with reality. | |
| var LLM_HEALTH_POLL_INTERVAL_MS = 30000; | |
| function startLlmHealthPolling() { | |
| stopLlmHealthPolling(); | |
| STATE.llmHealthPollTimer = setInterval(function () { | |
| if (STATE.activeTab !== 'chain') return; | |
| Promise.all([fetchHealth(), fetchLlmHealth()]) | |
| .then(function () { renderLlmHealth(); renderUpdatedLabel(); }) | |
| .catch(function () { /* swallow */ }); | |
| }, LLM_HEALTH_POLL_INTERVAL_MS); | |
| } | |
| function stopLlmHealthPolling() { | |
| if (STATE.llmHealthPollTimer) { | |
| clearInterval(STATE.llmHealthPollTimer); | |
| STATE.llmHealthPollTimer = null; | |
| } | |
| } | |
| function handleFetchErr(err) { | |
| if (err && err.code === 404) { | |
| try { localStorage.removeItem(STORAGE_KEY); } catch (_) {} | |
| STATE.password = ''; | |
| showGate('Not authorized (404 = wrong password OR your IP not on allowlist)'); | |
| toast('Session lost. Re-enter password.', 'error'); | |
| } else { | |
| toast('Error: ' + (err && err.message ? err.message : 'unknown'), 'error'); | |
| } | |
| } | |
| // ----- Pending-deployment placeholder ----- | |
| function buildPendingMessage(target) { | |
| var msg = createEl('div', { className: 'empty-state' }); | |
| var strong = createEl('strong', { text: 'Pending deployment.' }); | |
| msg.appendChild(strong); | |
| msg.appendChild(createEl('br')); | |
| var sub = createEl('span', { className: 'muted small', text: 'The ' + target + ' endpoint is not live yet. Try refreshing in a minute.' }); | |
| msg.appendChild(sub); | |
| return msg; | |
| } | |
| // ----- Profiles tab ----- | |
| function fetchProfiles() { | |
| return apiGetTolerant('/api/admin/profiles').then(function (data) { | |
| STATE.profiles = data; | |
| STATE.profilesLoadedAt = Date.now(); | |
| }); | |
| } | |
| function renderProfiles() { | |
| var body = $('profiles-body'); | |
| var summary = $('profiles-summary'); | |
| clearChildren(body); | |
| if (summary) summary.textContent = ''; | |
| if (!STATE.profiles) { | |
| var empty = createEl('div', { className: 'empty-state', text: 'Loading…' }); | |
| body.appendChild(empty); | |
| return; | |
| } | |
| // Accept either { profiles: [...] } or [...] directly | |
| var profiles = Array.isArray(STATE.profiles) | |
| ? STATE.profiles | |
| : (Array.isArray(STATE.profiles.profiles) ? STATE.profiles.profiles : []); | |
| if (!profiles.length) { | |
| var msg = createEl('div', { className: 'empty-state' }); | |
| msg.appendChild(document.createTextNode("No named profiles yet — users haven't told the bot their name.")); | |
| body.appendChild(msg); | |
| return; | |
| } | |
| if (summary) { | |
| summary.textContent = profiles.length + ' named ' + (profiles.length === 1 ? 'profile' : 'profiles'); | |
| } | |
| // Sort | |
| var sortKey = STATE.profilesSort.key; | |
| var sortDir = STATE.profilesSort.dir; | |
| var sorted = profiles.slice().sort(function (a, b) { | |
| var va = a[sortKey]; | |
| var vb = b[sortKey]; | |
| // ISO date strings sort lexicographically as dates | |
| if (va == null && vb == null) return 0; | |
| if (va == null) return 1; | |
| if (vb == null) return -1; | |
| if (typeof va === 'number' && typeof vb === 'number') { | |
| return sortDir === 'asc' ? va - vb : vb - va; | |
| } | |
| var sa = String(va); | |
| var sb = String(vb); | |
| return sortDir === 'asc' ? sa.localeCompare(sb) : sb.localeCompare(sa); | |
| }); | |
| // Determine max field count for the completeness bar denominator | |
| var maxFields = 0; | |
| sorted.forEach(function (p) { | |
| var fc = countFields(p.profile_complete_fields); | |
| if (fc > maxFields) maxFields = fc; | |
| }); | |
| // Fallback denominator: assume a reasonable cap so partial bars still look meaningful | |
| var fieldDenom = Math.max(maxFields, 6); | |
| var table = createEl('table', { className: 'profile-table' }); | |
| var thead = createEl('thead'); | |
| var headRow = createEl('tr'); | |
| var cols = [ | |
| { key: 'name_display', label: 'Name' }, | |
| { key: 'slug', label: 'Slug' }, | |
| { key: 'first_seen', label: 'First seen' }, | |
| { key: 'last_seen', label: 'Last seen' }, | |
| { key: 'session_count', label: 'Sessions' }, | |
| { key: 'profile_complete_fields', label: 'Fields' } | |
| ]; | |
| cols.forEach(function (c) { | |
| var th = createEl('th', { className: 'sortable' }); | |
| th.textContent = c.label; | |
| if (c.key === 'session_count' || c.key === 'profile_complete_fields') { | |
| th.style.textAlign = 'right'; | |
| } | |
| if (sortKey === c.key) { | |
| var ind = createEl('span', { className: 'sort-ind', text: sortDir === 'asc' ? '↑' : '↓' }); | |
| th.appendChild(ind); | |
| } | |
| th.onclick = function () { | |
| if (STATE.profilesSort.key === c.key) { | |
| STATE.profilesSort.dir = STATE.profilesSort.dir === 'asc' ? 'desc' : 'asc'; | |
| } else { | |
| STATE.profilesSort.key = c.key; | |
| STATE.profilesSort.dir = (c.key === 'last_seen' || c.key === 'first_seen' || c.key === 'session_count' || c.key === 'profile_complete_fields') ? 'desc' : 'asc'; | |
| } | |
| renderProfiles(); | |
| }; | |
| headRow.appendChild(th); | |
| }); | |
| thead.appendChild(headRow); | |
| table.appendChild(thead); | |
| var tbody = createEl('tbody'); | |
| sorted.forEach(function (p) { | |
| var tr = createEl('tr'); | |
| var nameCell = createEl('td', { className: 'name-cell', text: p.name_display || '—' }); | |
| if (p.name_display) nameCell.title = p.name_display; | |
| tr.appendChild(nameCell); | |
| var slugCell = createEl('td', { className: 'slug-cell', text: p.slug || '—' }); | |
| if (p.slug) slugCell.title = p.slug; | |
| tr.appendChild(slugCell); | |
| var firstCell = createEl('td', { className: 'time-cell', text: fmtIST(p.first_seen) }); | |
| if (!p.first_seen) firstCell.classList.add('muted'); | |
| tr.appendChild(firstCell); | |
| var lastCell = createEl('td', { className: 'time-cell', text: fmtIST(p.last_seen) }); | |
| if (!p.last_seen) lastCell.classList.add('muted'); | |
| tr.appendChild(lastCell); | |
| var sessCell = createEl('td', { className: 'num-cell', text: p.session_count != null ? String(p.session_count) : '—' }); | |
| tr.appendChild(sessCell); | |
| var fieldsCell = createEl('td', { className: 'num-cell' }); | |
| var fc = countFields(p.profile_complete_fields); | |
| var bar = createEl('span', { className: 'field-bar' }); | |
| var track = createEl('span', { className: 'track' }); | |
| var fill = createEl('span', { className: 'fill' }); | |
| var pct = fieldDenom > 0 ? Math.min(100, (fc / fieldDenom) * 100) : 0; | |
| fill.style.width = pct.toFixed(0) + '%'; | |
| track.appendChild(fill); | |
| var cnt = createEl('span', { className: 'field-count', text: fc + ' / ' + fieldDenom }); | |
| bar.appendChild(track); | |
| bar.appendChild(cnt); | |
| fieldsCell.appendChild(bar); | |
| tr.appendChild(fieldsCell); | |
| tbody.appendChild(tr); | |
| }); | |
| table.appendChild(tbody); | |
| var profWrap = createEl('div', { className: 'table-wrap' }); | |
| profWrap.appendChild(table); | |
| body.appendChild(profWrap); | |
| } | |
| // profile_complete_fields may be an integer or a list of field names | |
| function countFields(v) { | |
| if (v == null) return 0; | |
| if (typeof v === 'number') return v; | |
| if (Array.isArray(v)) return v.length; | |
| if (typeof v === 'object') return Object.keys(v).length; | |
| return 0; | |
| } | |
| function renderProfilesPending() { | |
| var body = $('profiles-body'); | |
| clearChildren(body); | |
| body.appendChild(buildPendingMessage('/api/admin/profiles')); | |
| } | |
| function loadProfiles(forceReload) { | |
| if (STATE.profilesLoaded && !forceReload) { | |
| renderProfiles(); | |
| return Promise.resolve(); | |
| } | |
| return fetchProfiles().then(function () { | |
| STATE.profilesLoaded = true; | |
| renderProfiles(); | |
| }).catch(function (err) { | |
| if (err && err.notDeployed) { | |
| renderProfilesPending(); | |
| } else { | |
| handleFetchErr(err); | |
| } | |
| }); | |
| } | |
| // ----- Performance tab ----- | |
| function fetchPerformance() { | |
| return apiGetTolerant('/api/admin/performance').then(function (data) { | |
| STATE.performance = data; | |
| STATE.performanceLoadedAt = Date.now(); | |
| }); | |
| } | |
| function loadPerformance(forceReload) { | |
| if (STATE.performanceLoaded && !forceReload) { | |
| renderPerformance(); | |
| return Promise.resolve(); | |
| } | |
| return fetchPerformance().then(function () { | |
| STATE.performanceLoaded = true; | |
| renderPerformance(); | |
| }).catch(function (err) { | |
| if (err && err.notDeployed) { | |
| renderPerformancePending(); | |
| } else { | |
| handleFetchErr(err); | |
| } | |
| }); | |
| } | |
| function renderPerformancePending() { | |
| var body = $('performance-body'); | |
| clearChildren(body); | |
| body.appendChild(buildPendingMessage('/api/admin/performance')); | |
| } | |
| function renderPerformance() { | |
| var body = $('performance-body'); | |
| clearChildren(body); | |
| if (!STATE.performance) { | |
| body.appendChild(createEl('div', { className: 'empty-state', text: 'Loading…' })); | |
| return; | |
| } | |
| var p = STATE.performance; | |
| // (a) Latest gold eval. The backend (/api/admin/performance → | |
| // _read_eval_summary) emits the `eval` block with fields | |
| // ran_at / n_questions / factual_accuracy / citation_accuracy / | |
| // refusal_precision / by_brain / by_type. The question count is | |
| // whatever the last run covered (gold set = eval/gold_qa.json); | |
| // we derive the heading from n_questions instead of hard-coding a | |
| // stale "96-Q" — the gold set is no longer 96 questions. | |
| var goldSection = createEl('div', { className: 'perf-sub' }); | |
| var gold = p.eval || p.gold_eval || null; | |
| var goldN = gold ? (gold.n_questions != null ? gold.n_questions | |
| : (gold.total_questions != null ? gold.total_questions : gold.n)) : null; | |
| goldSection.appendChild(createEl('h3', { | |
| text: 'Latest gold eval' + (goldN != null ? ' (' + goldN + '-Q)' : '') | |
| })); | |
| if (!gold || isEmptyObj(gold)) { | |
| goldSection.appendChild(createEl('div', { className: 'empty-state', text: 'No gold eval run yet.' })); | |
| } else { | |
| var caption = createEl('div', { className: 'perf-caption' }); | |
| var runAt = gold.ran_at || gold.run_at || gold.timestamp || gold.created_at; | |
| if (runAt) caption.textContent = 'Run at ' + fmtIST(runAt); | |
| if (caption.textContent) goldSection.appendChild(caption); | |
| var tiles = createEl('div', { className: 'metric-grid' }); | |
| tiles.appendChild(metricTile('Factual', fmtPctMaybe(pickPct(gold, ['factual_accuracy', 'factual_pct', 'factual', 'factual_correct_pct'])))); | |
| tiles.appendChild(metricTile('Citation', fmtPctMaybe(pickPct(gold, ['citation_accuracy', 'citation_pct', 'citation', 'citation_correct_pct'])))); | |
| tiles.appendChild(metricTile('Refusal', fmtPctMaybe(pickPct(gold, ['refusal_precision', 'refusal_pct', 'refusal', 'refusal_rate'])))); | |
| if (goldN != null) { | |
| tiles.appendChild(metricTile('Questions', String(goldN))); | |
| } | |
| goldSection.appendChild(tiles); | |
| // by_brain table | |
| var byBrain = gold.by_brain; | |
| if (byBrain && !isEmptyObj(byBrain)) { | |
| goldSection.appendChild(createEl('h3', { text: 'By brain' })); | |
| goldSection.appendChild(renderBreakdownTable(byBrain, ['Brain', 'Factual', 'Citation', 'Refusal', 'N'])); | |
| } | |
| // by_type table | |
| var byType = gold.by_type; | |
| if (byType && !isEmptyObj(byType)) { | |
| goldSection.appendChild(createEl('h3', { text: 'By type' })); | |
| goldSection.appendChild(renderBreakdownTable(byType, ['Type', 'Factual', 'Citation', 'Refusal', 'N'])); | |
| } | |
| } | |
| body.appendChild(goldSection); | |
| // (b) Latest persona audit. The backend (/api/admin/performance → | |
| // _read_audit_summary) emits the `audit` block parsed from the most | |
| // recent 80-audit/full_* run: run_id / personas_requested / | |
| // personas_completed / elapsed_seconds / turns_total / errors / | |
| // refusals / p50_ms / p95_ms / p99_ms / brain_routing. There is no | |
| // run_at field — the run is identified by run_id. | |
| var personaSection = createEl('div', { className: 'perf-sub' }); | |
| personaSection.appendChild(createEl('h3', { text: 'Latest persona audit' })); | |
| var persona = p.audit || p.persona_audit || p.persona || null; | |
| if (!persona || isEmptyObj(persona)) { | |
| personaSection.appendChild(createEl('div', { className: 'empty-state', text: 'No persona audit run yet.' })); | |
| } else { | |
| var pCaption = createEl('div', { className: 'perf-caption' }); | |
| if (persona.run_id) { | |
| pCaption.textContent = 'Run ' + persona.run_id | |
| + (persona.elapsed_seconds != null ? ' · ' + persona.elapsed_seconds + 's' : ''); | |
| } | |
| if (pCaption.textContent) personaSection.appendChild(pCaption); | |
| var pTiles = createEl('div', { className: 'metric-grid' }); | |
| if (persona.personas_completed != null) { | |
| pTiles.appendChild(metricTile('Personas completed', | |
| String(persona.personas_completed) | |
| + (persona.personas_requested != null ? ' / ' + persona.personas_requested : ''))); | |
| } | |
| if (persona.turns_total != null) { | |
| pTiles.appendChild(metricTile('Turns', String(persona.turns_total))); | |
| } | |
| pTiles.appendChild(metricTile('p50 latency', fmtLatency(persona.p50_ms != null ? persona.p50_ms : persona.p50))); | |
| pTiles.appendChild(metricTile('p95 latency', fmtLatency(persona.p95_ms != null ? persona.p95_ms : persona.p95))); | |
| pTiles.appendChild(metricTile('p99 latency', fmtLatency(persona.p99_ms != null ? persona.p99_ms : persona.p99))); | |
| if (persona.refusals != null) { | |
| pTiles.appendChild(metricTile('Refusals', String(persona.refusals))); | |
| } | |
| personaSection.appendChild(pTiles); | |
| // brain_routing pie/list | |
| var routing = persona.brain_routing; | |
| if (routing && !isEmptyObj(routing)) { | |
| personaSection.appendChild(createEl('h3', { text: 'Brain routing' })); | |
| personaSection.appendChild(renderBarList(routing)); | |
| } | |
| } | |
| body.appendChild(personaSection); | |
| // (c) Last 24 h LLM usage — per-role | |
| var usageSection = createEl('div', { className: 'perf-sub' }); | |
| usageSection.appendChild(createEl('h3', { text: 'Last 24 h LLM usage' })); | |
| var usage = p.llm_usage_24h || p.usage_24h || p.llm_usage || null; | |
| if (!usage || isEmptyObj(usage)) { | |
| usageSection.appendChild(createEl('div', { className: 'empty-state', text: 'No LLM usage recorded in last 24 h.' })); | |
| } else { | |
| var table = createEl('table'); | |
| var thead = createEl('thead'); | |
| var thr = createEl('tr'); | |
| ['Role', 'Count', 'Success rate', 'Avg latency'].forEach(function (h, i) { | |
| var th = createEl('th', { text: h }); | |
| if (i > 0) th.style.textAlign = 'right'; | |
| thr.appendChild(th); | |
| }); | |
| thead.appendChild(thr); | |
| table.appendChild(thead); | |
| var tbody = createEl('tbody'); | |
| // Accept either { role: {...} } map or [...] array | |
| var entries = []; | |
| if (Array.isArray(usage)) { | |
| entries = usage.map(function (u) { return { role: u.role, data: u }; }); | |
| } else { | |
| for (var k in usage) { | |
| if (Object.prototype.hasOwnProperty.call(usage, k)) { | |
| entries.push({ role: k, data: usage[k] }); | |
| } | |
| } | |
| } | |
| if (!entries.length) { | |
| var tr = createEl('tr'); | |
| var td = createEl('td', { text: 'No data.', attrs: { colspan: '4' } }); | |
| td.classList.add('muted'); | |
| tr.appendChild(td); | |
| tbody.appendChild(tr); | |
| } else { | |
| entries.forEach(function (entry) { | |
| var u = entry.data || {}; | |
| var tr = createEl('tr'); | |
| var roleCell = createEl('td'); | |
| var rbadge = createEl('span', { className: 'role-badge', text: ROLE_LABELS[entry.role] || String(entry.role).toUpperCase() }); | |
| rbadge.style.background = ROLE_COLORS[entry.role] || '#444'; | |
| roleCell.appendChild(rbadge); | |
| tr.appendChild(roleCell); | |
| var countV = u.count != null ? u.count : (u.calls != null ? u.calls : (u.total_calls_24h != null ? u.total_calls_24h : null)); | |
| var countCell = createEl('td', { text: countV != null ? String(countV) : '—' }); | |
| countCell.style.textAlign = 'right'; | |
| countCell.classList.add('mono'); | |
| tr.appendChild(countCell); | |
| var srV = u.success_rate != null ? u.success_rate : u.success; | |
| var srCell = createEl('td', { text: fmtPctMaybe(srV) }); | |
| srCell.style.textAlign = 'right'; | |
| tr.appendChild(srCell); | |
| var latV = u.avg_latency_ms != null ? u.avg_latency_ms : (u.avg_ms != null ? u.avg_ms : u.avg_latency); | |
| var latCell = createEl('td', { text: fmtLatency(latV) }); | |
| latCell.style.textAlign = 'right'; | |
| tr.appendChild(latCell); | |
| tbody.appendChild(tr); | |
| }); | |
| } | |
| table.appendChild(tbody); | |
| var usageWrap = createEl('div', { className: 'table-wrap' }); | |
| usageWrap.appendChild(table); | |
| usageSection.appendChild(usageWrap); | |
| } | |
| body.appendChild(usageSection); | |
| } | |
| function metricTile(label, value, sub) { | |
| var t = createEl('div', { className: 'metric-tile' }); | |
| t.appendChild(createEl('div', { className: 'label', text: label })); | |
| t.appendChild(createEl('div', { className: 'value', text: value != null ? value : '—' })); | |
| if (sub) t.appendChild(createEl('div', { className: 'sub', text: sub })); | |
| return t; | |
| } | |
| function pickPct(obj, keys) { | |
| for (var i = 0; i < keys.length; i++) { | |
| if (obj[keys[i]] != null) return obj[keys[i]]; | |
| } | |
| return null; | |
| } | |
| // Format a value that might be a fraction (0-1) or already a percentage (0-100). | |
| // If > 1 assume percent; if <= 1 assume fraction. | |
| function fmtPctMaybe(v) { | |
| if (v == null || isNaN(v)) return '—'; | |
| var n = Number(v); | |
| if (n <= 1) return (n * 100).toFixed(1) + '%'; | |
| return n.toFixed(1) + '%'; | |
| } | |
| function isEmptyObj(o) { | |
| if (o == null) return true; | |
| if (Array.isArray(o)) return o.length === 0; | |
| if (typeof o !== 'object') return false; | |
| for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) return false; | |
| return true; | |
| } | |
| // Render a breakdown table from a map { key: { factual_pct, citation_pct, refusal_pct, n } } | |
| function renderBreakdownTable(map, headers) { | |
| var table = createEl('table'); | |
| var thead = createEl('thead'); | |
| var thr = createEl('tr'); | |
| headers.forEach(function (h, i) { | |
| var th = createEl('th', { text: h }); | |
| if (i > 0) th.style.textAlign = 'right'; | |
| thr.appendChild(th); | |
| }); | |
| thead.appendChild(thr); | |
| table.appendChild(thead); | |
| var tbody = createEl('tbody'); | |
| var keys = Object.keys(map); | |
| if (!keys.length) { | |
| var tr = createEl('tr'); | |
| var td = createEl('td', { text: 'No data.', attrs: { colspan: String(headers.length) } }); | |
| td.classList.add('muted'); | |
| tr.appendChild(td); | |
| tbody.appendChild(tr); | |
| } else { | |
| keys.forEach(function (k) { | |
| var v = map[k] || {}; | |
| var tr = createEl('tr'); | |
| var keyCell = createEl('td', { text: k }); | |
| keyCell.classList.add('mono'); | |
| tr.appendChild(keyCell); | |
| var fCell = createEl('td', { text: fmtPctMaybe(pickPct(v, ['factual_pct', 'factual'])) }); | |
| fCell.style.textAlign = 'right'; | |
| tr.appendChild(fCell); | |
| var cCell = createEl('td', { text: fmtPctMaybe(pickPct(v, ['citation_pct', 'citation'])) }); | |
| cCell.style.textAlign = 'right'; | |
| tr.appendChild(cCell); | |
| var rCell = createEl('td', { text: fmtPctMaybe(pickPct(v, ['refusal_pct', 'refusal'])) }); | |
| rCell.style.textAlign = 'right'; | |
| tr.appendChild(rCell); | |
| var nCell = createEl('td', { text: v.n != null ? String(v.n) : (v.count != null ? String(v.count) : '—') }); | |
| nCell.style.textAlign = 'right'; | |
| nCell.classList.add('mono'); | |
| tr.appendChild(nCell); | |
| tbody.appendChild(tr); | |
| }); | |
| } | |
| table.appendChild(tbody); | |
| var bWrap = createEl('div', { className: 'table-wrap' }); | |
| bWrap.appendChild(table); | |
| return bWrap; | |
| } | |
| // Render a horizontal-bar list for a count/share map. | |
| function renderBarList(map) { | |
| // Accept either { key: count } or { key: { count, share } } or array | |
| var entries = []; | |
| if (Array.isArray(map)) { | |
| map.forEach(function (item) { | |
| entries.push({ label: item.label || item.brain || item.key || '—', value: item.count != null ? item.count : (item.value != null ? item.value : 0) }); | |
| }); | |
| } else { | |
| for (var k in map) { | |
| if (Object.prototype.hasOwnProperty.call(map, k)) { | |
| var v = map[k]; | |
| if (typeof v === 'number') entries.push({ label: k, value: v }); | |
| else if (v && typeof v === 'object') entries.push({ label: k, value: v.count != null ? v.count : (v.value != null ? v.value : 0) }); | |
| } | |
| } | |
| } | |
| var total = entries.reduce(function (acc, e) { return acc + (Number(e.value) || 0); }, 0); | |
| var wrap = createEl('div', { className: 'bar-list' }); | |
| if (!entries.length || total === 0) { | |
| var empty = createEl('div', { className: 'empty-state', text: 'No routing data.' }); | |
| wrap.appendChild(empty); | |
| return wrap; | |
| } | |
| entries.sort(function (a, b) { return b.value - a.value; }); | |
| entries.forEach(function (e) { | |
| var row = createEl('div', { className: 'bar-row' }); | |
| var lbl = createEl('div', { className: 'bar-label mono', text: e.label }); | |
| var track = createEl('div', { className: 'bar-track' }); | |
| var fill = createEl('div', { className: 'bar-fill' }); | |
| var pct = total > 0 ? (e.value / total) * 100 : 0; | |
| fill.style.width = pct.toFixed(1) + '%'; | |
| if (ROLE_COLORS[e.label]) fill.style.background = ROLE_COLORS[e.label]; | |
| track.appendChild(fill); | |
| var val = createEl('div', { className: 'bar-value', text: e.value + ' (' + pct.toFixed(1) + '%)' }); | |
| row.appendChild(lbl); | |
| row.appendChild(track); | |
| row.appendChild(val); | |
| wrap.appendChild(row); | |
| }); | |
| return wrap; | |
| } | |
| // ----- A5 — Persona Drift panel ----- | |
| function fetchDrift() { | |
| return apiGetTolerant('/api/admin/persona-drift').then(function (data) { | |
| STATE.drift = data; | |
| STATE.driftLoadedAt = Date.now(); | |
| }); | |
| } | |
| // Image #14 fix — render a slot-name list as wrapping chips so a | |
| // field like "location_tier" can never split mid-word. Pure | |
| // presentation: same data (the slot arrays), just chips instead of | |
| // a comma-joined string in a break-anywhere cell. | |
| function slotListCell(slots, isMissing) { | |
| var td = createEl('td'); | |
| var list = (slots || []); | |
| if (!list.length) { | |
| td.appendChild(createEl('span', { className: 'slot-list-empty', text: '—' })); | |
| return td; | |
| } | |
| var wrap = createEl('div', { className: 'slot-list' }); | |
| list.forEach(function (s) { | |
| wrap.appendChild(createEl('span', { | |
| className: isMissing ? 'slot-chip miss' : 'slot-chip', | |
| text: String(s) | |
| })); | |
| }); | |
| td.appendChild(wrap); | |
| return td; | |
| } | |
| function renderDrift() { | |
| var body = $('drift-body'); | |
| var summary = $('drift-summary'); | |
| clearChildren(body); | |
| if (summary) summary.textContent = ''; | |
| if (!STATE.drift) { | |
| body.appendChild(createEl('div', { className: 'empty-state', text: 'Loading…' })); | |
| return; | |
| } | |
| if (STATE.drift.__notDeployed) { | |
| body.appendChild(buildPendingMessage('/api/admin/persona-drift')); | |
| return; | |
| } | |
| var personas = STATE.drift.personas || []; | |
| if (summary) { | |
| summary.textContent = personas.length + ' persona' + (personas.length === 1 ? '' : 's') | |
| + ' · ' + ((STATE.drift.slots || []).length || 7) + ' slots'; | |
| } | |
| if (!personas.length) { | |
| body.appendChild(createEl('div', { className: 'empty-state', | |
| text: 'No personas captured yet.' })); | |
| return; | |
| } | |
| var table = createEl('table'); | |
| var thead = createEl('thead'); | |
| var hr = createEl('tr'); | |
| ['Persona', 'Last seen', 'Captured', 'Missing', 'Completeness'].forEach(function (h, i) { | |
| var th = createEl('th', { text: h }); | |
| if (i >= 4) th.style.textAlign = 'right'; | |
| hr.appendChild(th); | |
| }); | |
| thead.appendChild(hr); | |
| table.appendChild(thead); | |
| var tbody = createEl('tbody'); | |
| personas.forEach(function (r) { | |
| var tr = createEl('tr'); | |
| if (r.completeness_pct < 50) tr.classList.add('drift-row-bad'); | |
| var nameCell = createEl('td'); | |
| var nameSpan = createEl('span', { text: r.name_display || '—' }); | |
| nameCell.appendChild(nameSpan); | |
| var idSpan = createEl('span', { className: 'small muted', | |
| text: ' ' + (r.persona_id || '') }); | |
| idSpan.style.fontFamily = 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, monospace'; | |
| idSpan.style.marginLeft = '8px'; | |
| nameCell.appendChild(idSpan); | |
| tr.appendChild(nameCell); | |
| tr.appendChild(createEl('td', { className: 'time-cell', text: fmtIST(r.last_seen) })); | |
| tr.appendChild(slotListCell(r.captured_slots, false)); | |
| tr.appendChild(slotListCell(r.missing_slots, true)); | |
| var pctCell = createEl('td'); | |
| pctCell.style.textAlign = 'right'; | |
| var pctSpan = createEl('span', { className: 'drift-pct' }); | |
| pctSpan.textContent = (r.completeness_pct != null ? r.completeness_pct.toFixed(0) : '—') + '%'; | |
| if (r.completeness_pct < 50) pctSpan.classList.add('bad'); | |
| else if (r.completeness_pct < 80) pctSpan.classList.add('warn'); | |
| else pctSpan.classList.add('ok'); | |
| pctCell.appendChild(pctSpan); | |
| tr.appendChild(pctCell); | |
| tbody.appendChild(tr); | |
| }); | |
| table.appendChild(tbody); | |
| var driftWrap = createEl('div', { className: 'table-wrap' }); | |
| driftWrap.appendChild(table); | |
| body.appendChild(driftWrap); | |
| } | |
| function loadDrift(force) { | |
| if (STATE.driftLoaded && !force) { | |
| renderDrift(); | |
| return Promise.resolve(); | |
| } | |
| return fetchDrift().then(function () { | |
| STATE.driftLoaded = true; | |
| renderDrift(); | |
| }).catch(function (err) { | |
| if (err && err.notDeployed) { | |
| STATE.drift = { __notDeployed: true }; | |
| renderDrift(); | |
| } else { | |
| handleFetchErr(err); | |
| } | |
| }); | |
| } | |
| // ----- A5 — Recommendation History panel ----- | |
| function fetchRecHistory() { | |
| return apiGetTolerant('/api/admin/recommendation-history').then(function (data) { | |
| STATE.recHistory = data; | |
| STATE.recHistoryLoadedAt = Date.now(); | |
| }); | |
| } | |
| function renderRecHistory() { | |
| var body = $('rec-body'); | |
| var summary = $('rec-summary'); | |
| clearChildren(body); | |
| if (summary) summary.textContent = ''; | |
| if (!STATE.recHistory) { | |
| body.appendChild(createEl('div', { className: 'empty-state', text: 'Loading…' })); | |
| return; | |
| } | |
| if (STATE.recHistory.__notDeployed) { | |
| body.appendChild(buildPendingMessage('/api/admin/recommendation-history')); | |
| return; | |
| } | |
| var events = STATE.recHistory.events || []; | |
| if (summary) { | |
| summary.textContent = events.length + ' event' + (events.length === 1 ? '' : 's'); | |
| } | |
| if (!events.length) { | |
| body.appendChild(createEl('div', { className: 'empty-state', | |
| text: 'No policy events logged yet.' })); | |
| return; | |
| } | |
| var table = createEl('table'); | |
| var thead = createEl('thead'); | |
| var hr = createEl('tr'); | |
| ['When', 'Persona', 'Policy', 'Insurer', 'Outcome', 'Session', 'Turn'].forEach(function (h, i) { | |
| var th = createEl('th', { text: h }); | |
| if (i === 4 || i === 6) th.style.textAlign = 'center'; | |
| hr.appendChild(th); | |
| }); | |
| thead.appendChild(hr); | |
| table.appendChild(thead); | |
| var tbody = createEl('tbody'); | |
| events.forEach(function (e) { | |
| var tr = createEl('tr'); | |
| tr.appendChild(createEl('td', { className: 'time-cell', text: fmtIST(e.event_at) })); | |
| var pCell = createEl('td'); | |
| pCell.appendChild(createEl('span', { text: e.name_display || '—' })); | |
| var idSpan = createEl('span', { className: 'small muted', | |
| text: ' ' + (e.persona_id || '') }); | |
| idSpan.style.fontFamily = 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, monospace'; | |
| idSpan.style.marginLeft = '8px'; | |
| pCell.appendChild(idSpan); | |
| tr.appendChild(pCell); | |
| tr.appendChild(createEl('td', { className: 'mono small', text: e.policy_slug || '—' })); | |
| tr.appendChild(createEl('td', { text: e.insurer || '—' })); | |
| var oCell = createEl('td'); | |
| oCell.style.textAlign = 'center'; | |
| var outcome = (e.outcome || e.event_type || 'shown').toLowerCase(); | |
| var pill = createEl('span', { className: 'outcome-pill ' + outcome, text: outcome.toUpperCase() }); | |
| oCell.appendChild(pill); | |
| tr.appendChild(oCell); | |
| var sCell = createEl('td', { className: 'mono small', | |
| text: e.session_id ? String(e.session_id).slice(0, 10) + '…' : '—' }); | |
| if (e.session_id) sCell.title = e.session_id; | |
| tr.appendChild(sCell); | |
| // Conversation turn — not stamped on the event today; show '—' | |
| // until the orchestrator records turn_idx into the policy event. | |
| var tCell = createEl('td', { text: e.conversation_turn != null ? String(e.conversation_turn) : '—' }); | |
| tCell.style.textAlign = 'center'; | |
| tCell.classList.add('muted'); | |
| tr.appendChild(tCell); | |
| tbody.appendChild(tr); | |
| }); | |
| table.appendChild(tbody); | |
| var recWrap = createEl('div', { className: 'table-wrap' }); | |
| recWrap.appendChild(table); | |
| body.appendChild(recWrap); | |
| } | |
| function loadRecHistory(force) { | |
| if (STATE.recHistoryLoaded && !force) { | |
| renderRecHistory(); | |
| return Promise.resolve(); | |
| } | |
| return fetchRecHistory().then(function () { | |
| STATE.recHistoryLoaded = true; | |
| renderRecHistory(); | |
| }).catch(function (err) { | |
| if (err && err.notDeployed) { | |
| STATE.recHistory = { __notDeployed: true }; | |
| renderRecHistory(); | |
| } else { | |
| handleFetchErr(err); | |
| } | |
| }); | |
| } | |
| // ----- A5 — Stale-data footer (every table) ----- | |
| // 90s threshold: probe age > 90s ⇒ "stale" (yellow). Threshold matches | |
| // the audit's "freshness" definition. Updated live via setInterval. | |
| var STALE_THRESHOLD_MS = 90 * 1000; | |
| function fmtAgo(ms) { | |
| if (ms == null || isNaN(ms) || ms < 0) return '—'; | |
| var s = Math.floor(ms / 1000); | |
| if (s < 60) return s + 's ago'; | |
| var m = Math.floor(s / 60); | |
| if (m < 60) return m + 'm ' + (s % 60) + 's ago'; | |
| var h = Math.floor(m / 60); | |
| return h + 'h ' + (m % 60) + 'm ago'; | |
| } | |
| function setFooter(elId, fetchedAtMs, opts) { | |
| var el = $(elId); | |
| if (!el) return; | |
| clearChildren(el); | |
| if (!fetchedAtMs) return; | |
| var ageMs = Date.now() - fetchedAtMs; | |
| var label = createEl('span', { text: 'Last updated: ' + fmtAgo(ageMs) }); | |
| el.appendChild(label); | |
| var badge = createEl('span'); | |
| var stale = ageMs > STALE_THRESHOLD_MS; | |
| badge.className = 'stale-badge ' + (stale ? '' : 'fresh'); | |
| badge.textContent = stale ? 'STALE' : 'FRESH'; | |
| el.appendChild(badge); | |
| if (opts && opts.extraText) { | |
| var ex = createEl('span', { text: ' · ' + opts.extraText }); | |
| ex.classList.add('muted'); | |
| el.appendChild(ex); | |
| } | |
| } | |
| // Per-tab footers ticked every second. Cheap — just DOM text updates. | |
| function tickFooters() { | |
| setFooter('profiles-footer', STATE.profilesLoadedAt); | |
| setFooter('drift-footer', STATE.driftLoadedAt); | |
| setFooter('rec-footer', STATE.recHistoryLoadedAt); | |
| setFooter('performance-footer', STATE.performanceLoadedAt); | |
| // LLM health uses probe timestamp from the snapshot — falls back to | |
| // the local fetch time when the payload doesn't carry snapshot_ts. | |
| var probeAt = null; | |
| if (STATE.llmHealth && STATE.llmHealth.snapshot_ts) { | |
| var t = Date.parse(STATE.llmHealth.snapshot_ts); | |
| if (!isNaN(t)) probeAt = t; | |
| } | |
| if (!probeAt) probeAt = STATE.llmHealthLoadedAt; | |
| setFooter('llm-health-footer', probeAt); | |
| // KI-208 — FRESH/STALE header badge removed; the consolidated header | |
| // row's "Last refresh / Next in" timing conveys freshness instead. | |
| } | |
| setInterval(tickFooters, 1000); | |
| // ----- Tab switching ----- | |
| function switchTab(tab) { | |
| STATE.activeTab = tab; | |
| var tabs = ['profiles', 'performance', 'chain']; | |
| tabs.forEach(function (t) { | |
| var btn = $('tabbtn-' + t); | |
| var pane = $('tab-' + t); | |
| if (btn) { | |
| if (t === tab) { btn.classList.add('active'); btn.setAttribute('aria-selected', 'true'); } | |
| else { btn.classList.remove('active'); btn.setAttribute('aria-selected', 'false'); } | |
| } | |
| if (pane) { | |
| if (t === tab) pane.classList.add('active'); | |
| else pane.classList.remove('active'); | |
| } | |
| }); | |
| renderUpdatedLabel(); | |
| // Lazy-load this tab's data | |
| if (tab === 'profiles') { | |
| loadProfiles(false); | |
| // A5 — load drift + rec history alongside the visitors table. | |
| // Cheap; both endpoints walk the same profiles dir. | |
| loadDrift(false); | |
| loadRecHistory(false); | |
| stopLlmHealthPolling(); | |
| } else if (tab === 'performance') { | |
| loadPerformance(false); | |
| stopLlmHealthPolling(); | |
| } else if (tab === 'chain') { | |
| if (!STATE.chainLoaded) { | |
| refreshChain().catch(handleFetchErr); | |
| } else { | |
| // Tab already loaded — refresh just the KI-086 panel on re-entry | |
| // so the operator immediately sees fresh credits / sin-bin state. | |
| // KI-296 (2026-05-27) — also re-fetch /api/admin/health for the | |
| // top-left timer (renderUpdatedLabel reads STATE.health.updated_at). | |
| Promise.all([fetchHealth(), fetchLlmHealth()]) | |
| .then(function () { renderLlmHealth(); renderUpdatedLabel(); }) | |
| .catch(function () { /* swallow */ }); | |
| } | |
| startLlmHealthPolling(); | |
| } | |
| } | |
| // ----- Gate ----- | |
| function showGate(errMsg) { | |
| $('gate').classList.remove('hidden'); | |
| $('app').classList.add('hidden'); | |
| var e = $('gate-error'); | |
| if (errMsg) { | |
| e.textContent = errMsg; | |
| e.classList.remove('hidden'); | |
| } else { | |
| e.textContent = ''; | |
| e.classList.add('hidden'); | |
| } | |
| var pw = $('gate-password'); | |
| if (pw) { pw.value = ''; pw.focus(); } | |
| } | |
| function showApp() { | |
| $('gate').classList.add('hidden'); | |
| $('app').classList.remove('hidden'); | |
| } | |
| // Validates the password by hitting the cheapest known-existing admin | |
| // endpoint (/api/admin/health), then opens the default tab (profiles). | |
| function attemptUnlock(password, opts) { | |
| opts = opts || {}; | |
| STATE.password = password; | |
| return apiGet('/api/admin/health').then(function (data) { | |
| STATE.health = data; | |
| showApp(); | |
| if (opts.remember) { | |
| try { localStorage.setItem(STORAGE_KEY, password); } catch (_) {} | |
| } | |
| toast('Unlocked', 'success'); | |
| // Load initial tab (profiles) | |
| switchTab('profiles'); | |
| }).catch(function (err) { | |
| STATE.password = ''; | |
| if (err && err.code === 404) { | |
| try { localStorage.removeItem(STORAGE_KEY); } catch (_) {} | |
| showGate('Not authorized (404 = wrong password OR your IP not on allowlist)'); | |
| } else { | |
| showGate('Error: ' + (err && err.message ? err.message : 'unknown')); | |
| } | |
| }); | |
| } | |
| // ----- Wiring ----- | |
| function wire() { | |
| $('gate-unlock').onclick = function () { | |
| var pw = $('gate-password').value || ''; | |
| if (!pw) { | |
| showGate('Enter a password.'); | |
| return; | |
| } | |
| var remember = $('gate-remember').checked; | |
| attemptUnlock(pw, { remember: remember }); | |
| }; | |
| $('gate-password').addEventListener('keydown', function (e) { | |
| if (e.key === 'Enter') $('gate-unlock').click(); | |
| }); | |
| $('lock-btn').onclick = function () { | |
| STATE.password = ''; | |
| STATE.health = null; | |
| STATE.chains = null; | |
| STATE.usage = null; | |
| STATE.llmHealth = null; | |
| STATE.llmHealthLoadedAt = null; | |
| STATE.profiles = null; | |
| STATE.profilesLoaded = false; | |
| STATE.profilesLoadedAt = null; | |
| STATE.performance = null; | |
| STATE.performanceLoaded = false; | |
| STATE.performanceLoadedAt = null; | |
| // A5 — clear drift + rec history state on lock | |
| STATE.drift = null; | |
| STATE.driftLoaded = false; | |
| STATE.driftLoadedAt = null; | |
| STATE.recHistory = null; | |
| STATE.recHistoryLoaded = false; | |
| STATE.recHistoryLoadedAt = null; | |
| STATE.chainLoaded = false; | |
| stopLlmHealthPolling(); | |
| try { localStorage.removeItem(STORAGE_KEY); } catch (_) {} | |
| showGate(); | |
| toast('Locked', 'info'); | |
| }; | |
| // Tab buttons | |
| ['profiles', 'performance', 'chain'].forEach(function (t) { | |
| var btn = $('tabbtn-' + t); | |
| if (btn) btn.onclick = function () { switchTab(t); }; | |
| }); | |
| // Profiles refresh | |
| $('btn-refresh-profiles').onclick = function () { | |
| var btn = this; | |
| btn.disabled = true; | |
| btn.textContent = 'Refreshing…'; | |
| loadProfiles(true).then(function () { toast('Profiles refreshed', 'success'); }) | |
| .catch(handleFetchErr) | |
| .then(function () { btn.disabled = false; btn.textContent = 'Refresh'; }); | |
| }; | |
| // A5 — Persona Drift refresh (stop bubbling so <summary> doesn't | |
| // collapse the panel when the button is clicked). | |
| var driftBtn = $('btn-refresh-drift'); | |
| if (driftBtn) { | |
| driftBtn.onclick = function (e) { | |
| e.stopPropagation(); | |
| e.preventDefault(); | |
| var btn = this; | |
| btn.disabled = true; | |
| btn.textContent = 'Refreshing…'; | |
| loadDrift(true).then(function () { toast('Persona drift refreshed', 'success'); }) | |
| .catch(handleFetchErr) | |
| .then(function () { btn.disabled = false; btn.textContent = 'Refresh'; }); | |
| }; | |
| } | |
| // A5 — Recommendation history refresh | |
| var recBtn = $('btn-refresh-rec'); | |
| if (recBtn) { | |
| recBtn.onclick = function (e) { | |
| e.stopPropagation(); | |
| e.preventDefault(); | |
| var btn = this; | |
| btn.disabled = true; | |
| btn.textContent = 'Refreshing…'; | |
| loadRecHistory(true).then(function () { toast('Recommendation history refreshed', 'success'); }) | |
| .catch(handleFetchErr) | |
| .then(function () { btn.disabled = false; btn.textContent = 'Refresh'; }); | |
| }; | |
| } | |
| // Performance refresh | |
| $('btn-refresh-performance').onclick = function () { | |
| var btn = this; | |
| btn.disabled = true; | |
| btn.textContent = 'Refreshing…'; | |
| loadPerformance(true).then(function () { toast('Performance refreshed', 'success'); }) | |
| .catch(handleFetchErr) | |
| .then(function () { btn.disabled = false; btn.textContent = 'Refresh'; }); | |
| }; | |
| // KI-164: #btn-refresh and #btn-probe were removed alongside the | |
| // "Health snapshot" card. The LLM Chain tab now only has the | |
| // simplified 2-table view, refreshed via #btn-refresh-llm-health | |
| // and a 30s auto-poll. The legacy buttons no longer exist; their | |
| // handlers are gone. | |
| // KI-086 — manual refresh for the LLM Health & Credits card. | |
| // KI-184 (2026-05-15) — Refresh now forces a fresh probe BEFORE | |
| // fetching state. Previously this only re-fetched the cached probe | |
| // state from /api/admin/llm-health, which is refreshed by a 5-min | |
| // background loop. User clicked Refresh expecting current state but | |
| // got cached state. Now: POST /api/admin/probe → then re-fetch. | |
| // | |
| // KI-296 (2026-05-27) — also re-fetch /api/admin/health so STATE.health | |
| // (the source for the top-left "Last refresh / Next in" timer at | |
| // L1623-1652) reflects the just-completed probe. Without this, the | |
| // POST + the LlmHealth GET refreshed the table + the bottom-right | |
| // FRESH badge but the top-left timer kept ticking from the | |
| // login-time snapshot — making the user (correctly) suspect that | |
| // probing wasn't happening at all. | |
| var llmHealthBtn = $('btn-refresh-llm-health'); | |
| if (llmHealthBtn) { | |
| llmHealthBtn.onclick = function () { | |
| var btn = this; | |
| btn.disabled = true; | |
| btn.textContent = 'Refreshing…'; | |
| apiPost('/api/admin/probe', null) | |
| .catch(function () { /* tolerate probe failure; still re-fetch cached state */ }) | |
| .then(function () { return Promise.all([fetchHealth(), fetchLlmHealth()]); }) | |
| .then(function () { | |
| renderLlmHealth(); | |
| renderUpdatedLabel(); | |
| toast('LLM health refreshed', 'success'); | |
| }) | |
| .catch(handleFetchErr) | |
| .then(function () { btn.disabled = false; btn.textContent = 'Refresh now'; }); | |
| }; | |
| } | |
| } | |
| // ----- Boot ----- | |
| function boot() { | |
| wire(); | |
| var saved = ''; | |
| try { saved = localStorage.getItem(STORAGE_KEY) || ''; } catch (_) {} | |
| if (saved) { | |
| attemptUnlock(saved, { remember: true }); | |
| } else { | |
| showGate(); | |
| } | |
| } | |
| if (document.readyState === 'loading') { | |
| document.addEventListener('DOMContentLoaded', boot); | |
| } else { | |
| boot(); | |
| } | |
| })(); | |
| </script> | |
| </body> | |
| </html> | |