rohitsar567 Claude Opus 4.7 (1M context) commited on
Commit
c53d167
·
1 Parent(s): 2eae364

feat(admin): KI-052 — 3-tab admin panel (Profiles, Performance, LLM Chain)

Browse files

Restructures the iframe-embedded admin panel into three tabs, each
lazy-loaded on first activation. The existing LLM Chain functionality
is preserved verbatim — original markup wrapped in <section id="tab-chain">,
all the original behaviour (chain reorder + save/reload, health probe,
recent usage) intact.

Tab 1 — Profile + Visitor Log:
• Fetches GET /api/admin/profiles (KI-048).
• Sortable table — Name · Slug · First seen · Last seen · Sessions
· Fields (with completeness mini-bar). Default sort: last_seen
desc. Click column headers to toggle.
• Tolerant of {profiles:[...]} wrapper OR bare array.
• Empty state: "No named profiles yet — users haven't told the
bot their name."

Tab 2 — Performance:
• Fetches GET /api/admin/performance (KI-048).
• Three sub-cards, each collapses gracefully when its slice is null:
- Gold eval: factual / citation / refusal / n_questions metric
tiles + by_brain + by_type breakdown tables.
- Persona audit: personas_completed / p50 / p95 / p99 / refusals
tiles + brain_routing bar list.
- 24h LLM usage: per-role table (count, success_rate, avg latency).
• Percentage formatters accept 0-1 fraction OR 0-100 percent.
• Field aliases for robustness across small API-shape variations.

Tab 3 — LLM Chain (unchanged):
• All original markup + behaviour preserved verbatim.
• renderAll → renderChain (state-flagged so we don't re-fetch on
return to this tab).

Cross-cutting:
• ARIA tablist / tab / tabpanel for accessibility.
• Auth preserved — every fetch carries X-Admin-Password.
• apiGetTolerant() distinguishes "endpoint not deployed yet" (shows
Pending deployment placeholder) from "auth failure" (locks gate).
• Lock button clears all three tab caches.
• IST timestamps via Intl.DateTimeFormat('en-IN').
• Cache-busting meta tags preserved.

File grew 1109 → 1910 lines. `node --check` clean. Auth flow + sort
logic + lazy loading + graceful 404 handling all wired.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files changed (1) hide show
  1. frontend/public/admin/llm-control.html +853 -52
frontend/public/admin/llm-control.html CHANGED
@@ -8,7 +8,7 @@
8
  <meta http-equiv="Expires" content="0">
9
  <meta name="robots" content="noindex,nofollow">
10
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
11
- <title>LLM Control Panel</title>
12
  <style>
13
  :root {
14
  --bg: #0b0d12;
@@ -20,6 +20,7 @@
20
  --yellow: #d29922;
21
  --red: #f85149;
22
  --blue: #58a6ff;
 
23
  }
24
  * { box-sizing: border-box; }
25
  body {
@@ -46,6 +47,12 @@
46
  font-size: 16px;
47
  font-weight: 600;
48
  }
 
 
 
 
 
 
49
  .container {
50
  max-width: 1100px;
51
  margin: 0 auto;
@@ -102,6 +109,42 @@
102
  .muted { color: var(--muted); }
103
  .small { font-size: 12px; }
104
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
  /* Counter pills */
106
  .counters { display: flex; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
107
  .counter {
@@ -138,7 +181,7 @@
138
  .status.down { background: rgba(248, 81, 73, 0.15); color: var(--red); border-color: rgba(248, 81, 73, 0.35); }
139
  .status.unknown { background: rgba(139, 149, 164, 0.15); color: var(--muted); border-color: rgba(139, 149, 164, 0.35); }
140
 
141
- /* Health table */
142
  table { width: 100%; border-collapse: collapse; font-size: 13px; }
143
  thead th {
144
  text-align: left;
@@ -157,6 +200,15 @@
157
  vertical-align: middle;
158
  }
159
  tbody tr:last-child td { border-bottom: none; }
 
 
 
 
 
 
 
 
 
160
 
161
  /* Chains */
162
  .chains-grid {
@@ -319,12 +371,125 @@
319
  font-size: 12px;
320
  color: var(--muted);
321
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
322
  </style>
323
  </head>
324
  <body>
325
  <!-- Gate (password) -->
326
  <div id="gate" class="card gate-card">
327
- <h1>LLM Control Panel</h1>
328
  <p class="muted small">Enter the admin password to unlock.</p>
329
  <div style="margin-top: 16px;">
330
  <input id="gate-password" type="password" placeholder="Admin password" autocomplete="current-password">
@@ -342,39 +507,80 @@
342
  <!-- Main app -->
343
  <div id="app" class="container hidden">
344
  <div class="row-between" style="margin-bottom: 16px;">
345
- <h1 style="margin: 0;">LLM Control Panel</h1>
346
  <div class="row">
347
  <span id="last-updated" class="last-updated"></span>
348
  <button id="lock-btn">Lock</button>
349
  </div>
350
  </div>
351
 
352
- <!-- Health -->
353
- <div class="card">
354
- <div class="row-between" style="margin-bottom: 12px;">
355
- <h2>Health snapshot</h2>
356
- <div class="actions">
357
- <button id="btn-refresh">Refresh (cached)</button>
358
- <button id="btn-probe" class="primary">Force fresh probe (slow)</button>
 
 
 
 
 
 
 
 
 
 
 
 
359
  </div>
360
  </div>
361
- <div id="counters" class="counters"></div>
362
- <table>
363
- <thead>
364
- <tr>
365
- <th>Model</th>
366
- <th>Status</th>
367
- <th>Latency</th>
368
- <th>Last error</th>
369
- <th>Last success</th>
370
- </tr>
371
- </thead>
372
- <tbody id="health-tbody"></tbody>
373
- </table>
374
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
375
 
376
- <!-- Chains -->
377
- <div class="chains-grid" id="chains-grid"></div>
 
378
  </div>
379
 
380
  <!-- Toast -->
@@ -387,10 +593,21 @@
387
  // ----- State -----
388
  var STATE = {
389
  password: '',
390
- health: null, // { models: [ { model, status, latency_ms, last_error, last_success } ], counters: { healthy, degraded, down } }
 
391
  chains: null, // { brain: [...], fast_brain: [...], judge: [...] }
392
- usage: null, // { brain: {...}, fast_brain: {...}, judge: {...} } from /api/admin/usage
393
- lastUpdatedAt: null // ms-since-epoch of the most recent successful snapshot fetch
 
 
 
 
 
 
 
 
 
 
394
  };
395
  var ROLES = ['brain', 'fast_brain', 'judge'];
396
  var ROLE_COLORS = {
@@ -446,6 +663,18 @@
446
  }
447
  }
448
 
 
 
 
 
 
 
 
 
 
 
 
 
449
  function normalizeStatus(s) {
450
  if (!s) return 'unknown';
451
  var v = String(s).toLowerCase();
@@ -522,7 +751,28 @@
522
  });
523
  }
524
 
525
- // ----- Renderers -----
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
526
  function renderCounters() {
527
  var wrap = $('counters');
528
  clearChildren(wrap);
@@ -653,8 +903,6 @@
653
  return 'unknown';
654
  }
655
 
656
- // Index by_model array from /api/admin/usage by model id, for O(1) lookup
657
- // when rendering one chain row.
658
  function usageStatsForRole(role) {
659
  var u = STATE.usage && STATE.usage[role];
660
  if (!u || !Array.isArray(u.by_model)) return { byModel: {}, total: 0, payload: u || null };
@@ -804,7 +1052,7 @@
804
 
805
  var revertBtn = createEl('button', { text: 'Reload' });
806
  revertBtn.onclick = function () {
807
- refreshAll().catch(handleFetchErr);
808
  };
809
 
810
  actions.appendChild(saveBtn);
@@ -917,9 +1165,6 @@
917
  return h + 'h ' + (m % 60) + 'm';
918
  }
919
 
920
- // Source-of-truth for "when the probe data was last current" is the
921
- // backend's updated_at (ISO string from /api/admin/health). Falls back
922
- // to local fetch time if the field is missing.
923
  function probeTimestampMs() {
924
  if (STATE.health && STATE.health.updated_at) {
925
  var t = Date.parse(STATE.health.updated_at);
@@ -931,23 +1176,25 @@
931
  function renderUpdatedLabel() {
932
  var el = $('last-updated');
933
  if (!el) return;
 
 
 
 
 
 
934
  var probeAt = probeTimestampMs();
935
- if (!probeAt) return;
936
  var now = Date.now();
937
  var since = formatMinSec(now - probeAt);
938
- // Next auto-probe = last probe + cadence. If that's already in the
939
- // past (probe loop running slow or paused), clamp to "due now".
940
  var nextAt = probeAt + PROBE_INTERVAL_MS;
941
  var untilNext = nextAt > now ? formatMinSec(nextAt - now) : 'due now';
942
  var wallClock = new Date(probeAt).toLocaleTimeString();
943
  el.textContent = 'Updated ' + wallClock + ' · ' + since + ' ago · next auto-probe in ' + untilNext;
944
  }
945
 
946
- // Tick the relative portion every second so the user sees how stale
947
- // the snapshot is in real time without having to click Refresh.
948
  setInterval(renderUpdatedLabel, 1000);
949
 
950
- // ----- Fetchers -----
951
  function fetchHealth() {
952
  return apiGet('/api/admin/health').then(function (data) {
953
  STATE.health = data;
@@ -956,7 +1203,6 @@
956
 
957
  function fetchChains() {
958
  return apiGet('/api/admin/chain').then(function (data) {
959
- // Accept either { brain, fast_brain, judge } or { chains: { ... } }
960
  var chains = data && data.chains ? data.chains : data;
961
  var clean = {};
962
  ROLES.forEach(function (r) {
@@ -968,9 +1214,6 @@
968
  }
969
 
970
  function fetchUsage() {
971
- // Backward-compatible: usage endpoint may return empty stats when the
972
- // JSONL log doesn't exist yet. Treat any failure as "no usage data"
973
- // so the UI still renders the chain editor cleanly.
974
  return apiGet('/api/admin/usage').then(function (data) {
975
  STATE.usage = (data && typeof data === 'object') ? data : null;
976
  }).catch(function () {
@@ -978,18 +1221,18 @@
978
  });
979
  }
980
 
981
- function refreshAll() {
982
  return Promise.all([fetchHealth(), fetchChains(), fetchUsage()]).then(function () {
983
  renderCounters();
984
  renderHealthTable();
985
  renderChains();
986
  setLastUpdated();
 
987
  });
988
  }
989
 
990
  function handleFetchErr(err) {
991
  if (err && err.code === 404) {
992
- // Token may have rotated; lock and clear
993
  try { localStorage.removeItem(STORAGE_KEY); } catch (_) {}
994
  STATE.password = '';
995
  showGate('Not authorized (404 = wrong password OR your IP not on allowlist)');
@@ -999,6 +1242,526 @@
999
  }
1000
  }
1001
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1002
  // ----- Gate -----
1003
  function showGate(errMsg) {
1004
  $('gate').classList.remove('hidden');
@@ -1020,15 +1783,20 @@
1020
  $('app').classList.remove('hidden');
1021
  }
1022
 
 
 
1023
  function attemptUnlock(password, opts) {
1024
  opts = opts || {};
1025
  STATE.password = password;
1026
- return refreshAll().then(function () {
 
1027
  showApp();
1028
  if (opts.remember) {
1029
  try { localStorage.setItem(STORAGE_KEY, password); } catch (_) {}
1030
  }
1031
  toast('Unlocked', 'success');
 
 
1032
  }).catch(function (err) {
1033
  STATE.password = '';
1034
  if (err && err.code === 404) {
@@ -1059,16 +1827,49 @@
1059
  STATE.password = '';
1060
  STATE.health = null;
1061
  STATE.chains = null;
 
 
 
 
 
 
1062
  try { localStorage.removeItem(STORAGE_KEY); } catch (_) {}
1063
  showGate();
1064
  toast('Locked', 'info');
1065
  };
1066
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1067
  $('btn-refresh').onclick = function () {
1068
  var btn = this;
1069
  btn.disabled = true;
1070
  btn.textContent = 'Refreshing…';
1071
- refreshAll()
1072
  .then(function () { toast('Refreshed from cache', 'success'); })
1073
  .catch(handleFetchErr)
1074
  .then(function () { btn.disabled = false; btn.textContent = 'Refresh (cached)'; });
@@ -1079,7 +1880,7 @@
1079
  btn.disabled = true;
1080
  btn.textContent = 'Probing…';
1081
  apiPost('/api/admin/probe', null)
1082
- .then(function () { return refreshAll(); })
1083
  .then(function () { toast('Fresh probe complete', 'success'); })
1084
  .catch(handleFetchErr)
1085
  .then(function () { btn.disabled = false; btn.textContent = 'Force fresh probe (slow)'; });
 
8
  <meta http-equiv="Expires" content="0">
9
  <meta name="robots" content="noindex,nofollow">
10
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
11
+ <title>Admin Control Panel</title>
12
  <style>
13
  :root {
14
  --bg: #0b0d12;
 
20
  --yellow: #d29922;
21
  --red: #f85149;
22
  --blue: #58a6ff;
23
+ --primary: #58a6ff;
24
  }
25
  * { box-sizing: border-box; }
26
  body {
 
47
  font-size: 16px;
48
  font-weight: 600;
49
  }
50
+ h3 {
51
+ margin: 0 0 12px 0;
52
+ font-size: 14px;
53
+ font-weight: 600;
54
+ color: var(--text);
55
+ }
56
  .container {
57
  max-width: 1100px;
58
  margin: 0 auto;
 
109
  .muted { color: var(--muted); }
110
  .small { font-size: 12px; }
111
 
112
+ /* Tab nav */
113
+ .tabnav {
114
+ display: flex;
115
+ gap: 4px;
116
+ margin-bottom: 20px;
117
+ border-bottom: 1px solid var(--border);
118
+ flex-wrap: wrap;
119
+ }
120
+ .tabnav button {
121
+ background: transparent;
122
+ border: 1px solid transparent;
123
+ border-bottom: none;
124
+ border-radius: 8px 8px 0 0;
125
+ padding: 10px 16px;
126
+ color: var(--muted);
127
+ font-weight: 500;
128
+ font-size: 13px;
129
+ margin-bottom: -1px;
130
+ cursor: pointer;
131
+ transition: color 0.12s ease, border-color 0.12s ease, background 0.12s ease;
132
+ }
133
+ .tabnav button:hover {
134
+ color: var(--text);
135
+ background: rgba(88, 166, 255, 0.05);
136
+ }
137
+ .tabnav button.active {
138
+ color: var(--text);
139
+ border-color: var(--primary);
140
+ border-bottom-color: var(--bg);
141
+ background: var(--card);
142
+ outline: 1px solid var(--primary);
143
+ outline-offset: -1px;
144
+ }
145
+ .tabpane { display: none; }
146
+ .tabpane.active { display: block; }
147
+
148
  /* Counter pills */
149
  .counters { display: flex; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
150
  .counter {
 
181
  .status.down { background: rgba(248, 81, 73, 0.15); color: var(--red); border-color: rgba(248, 81, 73, 0.35); }
182
  .status.unknown { background: rgba(139, 149, 164, 0.15); color: var(--muted); border-color: rgba(139, 149, 164, 0.35); }
183
 
184
+ /* Tables */
185
  table { width: 100%; border-collapse: collapse; font-size: 13px; }
186
  thead th {
187
  text-align: left;
 
200
  vertical-align: middle;
201
  }
202
  tbody tr:last-child td { border-bottom: none; }
203
+ tbody tr:hover td { background: rgba(88, 166, 255, 0.04); }
204
+
205
+ /* Sortable column header */
206
+ thead th.sortable {
207
+ cursor: pointer;
208
+ user-select: none;
209
+ }
210
+ thead th.sortable:hover { color: var(--text); }
211
+ thead th.sortable .sort-ind { margin-left: 4px; color: var(--blue); }
212
 
213
  /* Chains */
214
  .chains-grid {
 
371
  font-size: 12px;
372
  color: var(--muted);
373
  }
374
+
375
+ /* Performance tab */
376
+ .metric-grid {
377
+ display: grid;
378
+ grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
379
+ gap: 12px;
380
+ margin-bottom: 16px;
381
+ }
382
+ .metric-tile {
383
+ padding: 12px 14px;
384
+ background: #0b0d12;
385
+ border: 1px solid var(--border);
386
+ border-radius: 8px;
387
+ }
388
+ .metric-tile .label {
389
+ font-size: 10px;
390
+ text-transform: uppercase;
391
+ letter-spacing: 0.08em;
392
+ color: var(--muted);
393
+ margin-bottom: 6px;
394
+ }
395
+ .metric-tile .value {
396
+ font-size: 20px;
397
+ font-weight: 600;
398
+ font-variant-numeric: tabular-nums;
399
+ }
400
+ .metric-tile .sub {
401
+ font-size: 11px;
402
+ color: var(--muted);
403
+ margin-top: 2px;
404
+ }
405
+ .perf-sub {
406
+ margin-top: 16px;
407
+ padding-top: 16px;
408
+ border-top: 1px solid var(--border);
409
+ }
410
+ .perf-sub:first-of-type { margin-top: 0; padding-top: 0; border-top: none; }
411
+ .empty-state {
412
+ padding: 24px;
413
+ text-align: center;
414
+ color: var(--muted);
415
+ font-size: 13px;
416
+ background: #0b0d12;
417
+ border: 1px dashed var(--border);
418
+ border-radius: 8px;
419
+ }
420
+ .empty-state strong { color: var(--text); font-weight: 600; }
421
+ .perf-caption {
422
+ color: var(--muted);
423
+ font-size: 12px;
424
+ margin-bottom: 12px;
425
+ }
426
+
427
+ /* Pie / bar surrogate */
428
+ .bar-list { display: flex; flex-direction: column; gap: 6px; }
429
+ .bar-row {
430
+ display: grid;
431
+ grid-template-columns: 120px 1fr 80px;
432
+ align-items: center;
433
+ gap: 10px;
434
+ font-size: 12px;
435
+ }
436
+ .bar-row .bar-label { color: var(--text); }
437
+ .bar-row .bar-track {
438
+ height: 8px;
439
+ background: #0b0d12;
440
+ border: 1px solid var(--border);
441
+ border-radius: 4px;
442
+ overflow: hidden;
443
+ }
444
+ .bar-row .bar-fill {
445
+ height: 100%;
446
+ background: var(--blue);
447
+ }
448
+ .bar-row .bar-value {
449
+ text-align: right;
450
+ color: var(--muted);
451
+ font-variant-numeric: tabular-nums;
452
+ }
453
+
454
+ /* Profiles table */
455
+ .profile-table td.name-cell { font-weight: 500; }
456
+ .profile-table td.slug-cell {
457
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, monospace;
458
+ font-size: 12px;
459
+ color: var(--muted);
460
+ }
461
+ .profile-table td.num-cell { text-align: right; font-variant-numeric: tabular-nums; }
462
+ .profile-table td.time-cell { white-space: nowrap; font-size: 12px; }
463
+
464
+ /* Field completeness bar */
465
+ .field-bar {
466
+ display: inline-flex;
467
+ align-items: center;
468
+ gap: 6px;
469
+ }
470
+ .field-bar .track {
471
+ width: 60px;
472
+ height: 6px;
473
+ background: #0b0d12;
474
+ border: 1px solid var(--border);
475
+ border-radius: 3px;
476
+ overflow: hidden;
477
+ }
478
+ .field-bar .fill {
479
+ height: 100%;
480
+ background: var(--green);
481
+ }
482
+ .field-bar .field-count {
483
+ font-size: 11px;
484
+ color: var(--muted);
485
+ font-variant-numeric: tabular-nums;
486
+ }
487
  </style>
488
  </head>
489
  <body>
490
  <!-- Gate (password) -->
491
  <div id="gate" class="card gate-card">
492
+ <h1>Admin Control Panel</h1>
493
  <p class="muted small">Enter the admin password to unlock.</p>
494
  <div style="margin-top: 16px;">
495
  <input id="gate-password" type="password" placeholder="Admin password" autocomplete="current-password">
 
507
  <!-- Main app -->
508
  <div id="app" class="container hidden">
509
  <div class="row-between" style="margin-bottom: 16px;">
510
+ <h1 style="margin: 0;">Admin Control Panel</h1>
511
  <div class="row">
512
  <span id="last-updated" class="last-updated"></span>
513
  <button id="lock-btn">Lock</button>
514
  </div>
515
  </div>
516
 
517
+ <!-- Tab navigation -->
518
+ <nav class="tabnav" role="tablist">
519
+ <button id="tabbtn-profiles" class="active" role="tab" aria-selected="true" data-tab="profiles">Profile + Visitor Log</button>
520
+ <button id="tabbtn-performance" role="tab" aria-selected="false" data-tab="performance">Performance</button>
521
+ <button id="tabbtn-chain" role="tab" aria-selected="false" data-tab="chain">LLM Chain</button>
522
+ </nav>
523
+
524
+ <!-- Tab 1: Profile + Visitor Log -->
525
+ <section id="tab-profiles" class="tabpane active" role="tabpanel">
526
+ <div class="card">
527
+ <div class="row-between" style="margin-bottom: 12px;">
528
+ <h2>Named visitors</h2>
529
+ <div class="actions">
530
+ <span id="profiles-summary" class="muted small"></span>
531
+ <button id="btn-refresh-profiles">Refresh</button>
532
+ </div>
533
+ </div>
534
+ <div id="profiles-body">
535
+ <div class="empty-state">Loading…</div>
536
  </div>
537
  </div>
538
+ </section>
539
+
540
+ <!-- Tab 2: Performance -->
541
+ <section id="tab-performance" class="tabpane" role="tabpanel">
542
+ <div class="card">
543
+ <div class="row-between" style="margin-bottom: 12px;">
544
+ <h2>Performance snapshot</h2>
545
+ <div class="actions">
546
+ <button id="btn-refresh-performance">Refresh</button>
547
+ </div>
548
+ </div>
549
+ <div id="performance-body">
550
+ <div class="empty-state">Loading…</div>
551
+ </div>
552
+ </div>
553
+ </section>
554
+
555
+ <!-- Tab 3: LLM Chain (existing functionality, preserved) -->
556
+ <section id="tab-chain" class="tabpane" role="tabpanel">
557
+ <!-- Health -->
558
+ <div class="card">
559
+ <div class="row-between" style="margin-bottom: 12px;">
560
+ <h2>Health snapshot</h2>
561
+ <div class="actions">
562
+ <button id="btn-refresh">Refresh (cached)</button>
563
+ <button id="btn-probe" class="primary">Force fresh probe (slow)</button>
564
+ </div>
565
+ </div>
566
+ <div id="counters" class="counters"></div>
567
+ <table>
568
+ <thead>
569
+ <tr>
570
+ <th>Model</th>
571
+ <th>Status</th>
572
+ <th>Latency</th>
573
+ <th>Last error</th>
574
+ <th>Last success</th>
575
+ </tr>
576
+ </thead>
577
+ <tbody id="health-tbody"></tbody>
578
+ </table>
579
+ </div>
580
 
581
+ <!-- Chains -->
582
+ <div class="chains-grid" id="chains-grid"></div>
583
+ </section>
584
  </div>
585
 
586
  <!-- Toast -->
 
593
  // ----- State -----
594
  var STATE = {
595
  password: '',
596
+ // LLM Chain tab
597
+ health: null, // { models: [...], counters: {...} }
598
  chains: null, // { brain: [...], fast_brain: [...], judge: [...] }
599
+ usage: null, // { brain: {...}, fast_brain: {...}, judge: {...} }
600
+ lastUpdatedAt: null,
601
+ chainLoaded: false,
602
+ // Profiles tab
603
+ profiles: null,
604
+ profilesLoaded: false,
605
+ profilesSort: { key: 'last_seen', dir: 'desc' },
606
+ // Performance tab
607
+ performance: null,
608
+ performanceLoaded: false,
609
+ // Active tab
610
+ activeTab: 'profiles'
611
  };
612
  var ROLES = ['brain', 'fast_brain', 'judge'];
613
  var ROLE_COLORS = {
 
663
  }
664
  }
665
 
666
+ // IST timestamp formatting (en-IN locale)
667
+ function fmtIST(t) {
668
+ if (!t) return '—';
669
+ try {
670
+ var d = new Date(t);
671
+ if (isNaN(d.getTime())) return String(t);
672
+ return d.toLocaleString('en-IN');
673
+ } catch (e) {
674
+ return String(t);
675
+ }
676
+ }
677
+
678
  function normalizeStatus(s) {
679
  if (!s) return 'unknown';
680
  var v = String(s).toLowerCase();
 
751
  });
752
  }
753
 
754
+ // For new endpoints that may not be deployed yet — treat 404 as
755
+ // "pending deployment" rather than auth failure, since the user is
756
+ // already authenticated against /api/admin/health by this point.
757
+ function apiGetTolerant(path) {
758
+ return fetch(path, { method: 'GET', headers: apiHeaders(), cache: 'no-store' })
759
+ .then(function (res) {
760
+ if (res.status === 404) {
761
+ var err = new Error('Endpoint not found');
762
+ err.code = 404;
763
+ err.notDeployed = true;
764
+ throw err;
765
+ }
766
+ if (!res.ok) {
767
+ var e = new Error('Request failed (' + res.status + ')');
768
+ e.code = res.status;
769
+ throw e;
770
+ }
771
+ return res.json();
772
+ });
773
+ }
774
+
775
+ // ----- LLM Chain renderers (preserved from original) -----
776
  function renderCounters() {
777
  var wrap = $('counters');
778
  clearChildren(wrap);
 
903
  return 'unknown';
904
  }
905
 
 
 
906
  function usageStatsForRole(role) {
907
  var u = STATE.usage && STATE.usage[role];
908
  if (!u || !Array.isArray(u.by_model)) return { byModel: {}, total: 0, payload: u || null };
 
1052
 
1053
  var revertBtn = createEl('button', { text: 'Reload' });
1054
  revertBtn.onclick = function () {
1055
+ refreshChain().catch(handleFetchErr);
1056
  };
1057
 
1058
  actions.appendChild(saveBtn);
 
1165
  return h + 'h ' + (m % 60) + 'm';
1166
  }
1167
 
 
 
 
1168
  function probeTimestampMs() {
1169
  if (STATE.health && STATE.health.updated_at) {
1170
  var t = Date.parse(STATE.health.updated_at);
 
1176
  function renderUpdatedLabel() {
1177
  var el = $('last-updated');
1178
  if (!el) return;
1179
+ // Only show the LLM-Chain probe staleness when that tab is active —
1180
+ // it's irrelevant on Profiles / Performance.
1181
+ if (STATE.activeTab !== 'chain') {
1182
+ el.textContent = '';
1183
+ return;
1184
+ }
1185
  var probeAt = probeTimestampMs();
1186
+ if (!probeAt) { el.textContent = ''; return; }
1187
  var now = Date.now();
1188
  var since = formatMinSec(now - probeAt);
 
 
1189
  var nextAt = probeAt + PROBE_INTERVAL_MS;
1190
  var untilNext = nextAt > now ? formatMinSec(nextAt - now) : 'due now';
1191
  var wallClock = new Date(probeAt).toLocaleTimeString();
1192
  el.textContent = 'Updated ' + wallClock + ' · ' + since + ' ago · next auto-probe in ' + untilNext;
1193
  }
1194
 
 
 
1195
  setInterval(renderUpdatedLabel, 1000);
1196
 
1197
+ // ----- Fetchers (LLM Chain) -----
1198
  function fetchHealth() {
1199
  return apiGet('/api/admin/health').then(function (data) {
1200
  STATE.health = data;
 
1203
 
1204
  function fetchChains() {
1205
  return apiGet('/api/admin/chain').then(function (data) {
 
1206
  var chains = data && data.chains ? data.chains : data;
1207
  var clean = {};
1208
  ROLES.forEach(function (r) {
 
1214
  }
1215
 
1216
  function fetchUsage() {
 
 
 
1217
  return apiGet('/api/admin/usage').then(function (data) {
1218
  STATE.usage = (data && typeof data === 'object') ? data : null;
1219
  }).catch(function () {
 
1221
  });
1222
  }
1223
 
1224
+ function refreshChain() {
1225
  return Promise.all([fetchHealth(), fetchChains(), fetchUsage()]).then(function () {
1226
  renderCounters();
1227
  renderHealthTable();
1228
  renderChains();
1229
  setLastUpdated();
1230
+ STATE.chainLoaded = true;
1231
  });
1232
  }
1233
 
1234
  function handleFetchErr(err) {
1235
  if (err && err.code === 404) {
 
1236
  try { localStorage.removeItem(STORAGE_KEY); } catch (_) {}
1237
  STATE.password = '';
1238
  showGate('Not authorized (404 = wrong password OR your IP not on allowlist)');
 
1242
  }
1243
  }
1244
 
1245
+ // ----- Pending-deployment placeholder -----
1246
+ function buildPendingMessage(target) {
1247
+ var msg = createEl('div', { className: 'empty-state' });
1248
+ var strong = createEl('strong', { text: 'Pending deployment.' });
1249
+ msg.appendChild(strong);
1250
+ msg.appendChild(createEl('br'));
1251
+ var sub = createEl('span', { className: 'muted small', text: 'The ' + target + ' endpoint is not live yet. Try refreshing in a minute.' });
1252
+ msg.appendChild(sub);
1253
+ return msg;
1254
+ }
1255
+
1256
+ // ----- Profiles tab -----
1257
+ function fetchProfiles() {
1258
+ return apiGetTolerant('/api/admin/profiles').then(function (data) {
1259
+ STATE.profiles = data;
1260
+ });
1261
+ }
1262
+
1263
+ function renderProfiles() {
1264
+ var body = $('profiles-body');
1265
+ var summary = $('profiles-summary');
1266
+ clearChildren(body);
1267
+ if (summary) summary.textContent = '';
1268
+
1269
+ if (!STATE.profiles) {
1270
+ var empty = createEl('div', { className: 'empty-state', text: 'Loading…' });
1271
+ body.appendChild(empty);
1272
+ return;
1273
+ }
1274
+
1275
+ // Accept either { profiles: [...] } or [...] directly
1276
+ var profiles = Array.isArray(STATE.profiles)
1277
+ ? STATE.profiles
1278
+ : (Array.isArray(STATE.profiles.profiles) ? STATE.profiles.profiles : []);
1279
+
1280
+ if (!profiles.length) {
1281
+ var msg = createEl('div', { className: 'empty-state' });
1282
+ msg.appendChild(document.createTextNode("No named profiles yet — users haven't told the bot their name."));
1283
+ body.appendChild(msg);
1284
+ return;
1285
+ }
1286
+
1287
+ if (summary) {
1288
+ summary.textContent = profiles.length + ' named ' + (profiles.length === 1 ? 'profile' : 'profiles');
1289
+ }
1290
+
1291
+ // Sort
1292
+ var sortKey = STATE.profilesSort.key;
1293
+ var sortDir = STATE.profilesSort.dir;
1294
+ var sorted = profiles.slice().sort(function (a, b) {
1295
+ var va = a[sortKey];
1296
+ var vb = b[sortKey];
1297
+ // ISO date strings sort lexicographically as dates
1298
+ if (va == null && vb == null) return 0;
1299
+ if (va == null) return 1;
1300
+ if (vb == null) return -1;
1301
+ if (typeof va === 'number' && typeof vb === 'number') {
1302
+ return sortDir === 'asc' ? va - vb : vb - va;
1303
+ }
1304
+ var sa = String(va);
1305
+ var sb = String(vb);
1306
+ return sortDir === 'asc' ? sa.localeCompare(sb) : sb.localeCompare(sa);
1307
+ });
1308
+
1309
+ // Determine max field count for the completeness bar denominator
1310
+ var maxFields = 0;
1311
+ sorted.forEach(function (p) {
1312
+ var fc = countFields(p.profile_complete_fields);
1313
+ if (fc > maxFields) maxFields = fc;
1314
+ });
1315
+ // Fallback denominator: assume a reasonable cap so partial bars still look meaningful
1316
+ var fieldDenom = Math.max(maxFields, 6);
1317
+
1318
+ var table = createEl('table', { className: 'profile-table' });
1319
+ var thead = createEl('thead');
1320
+ var headRow = createEl('tr');
1321
+ var cols = [
1322
+ { key: 'name_display', label: 'Name' },
1323
+ { key: 'slug', label: 'Slug' },
1324
+ { key: 'first_seen', label: 'First seen' },
1325
+ { key: 'last_seen', label: 'Last seen' },
1326
+ { key: 'session_count', label: 'Sessions' },
1327
+ { key: 'profile_complete_fields', label: 'Fields' }
1328
+ ];
1329
+ cols.forEach(function (c) {
1330
+ var th = createEl('th', { className: 'sortable' });
1331
+ th.textContent = c.label;
1332
+ if (c.key === 'session_count' || c.key === 'profile_complete_fields') {
1333
+ th.style.textAlign = 'right';
1334
+ }
1335
+ if (sortKey === c.key) {
1336
+ var ind = createEl('span', { className: 'sort-ind', text: sortDir === 'asc' ? '↑' : '↓' });
1337
+ th.appendChild(ind);
1338
+ }
1339
+ th.onclick = function () {
1340
+ if (STATE.profilesSort.key === c.key) {
1341
+ STATE.profilesSort.dir = STATE.profilesSort.dir === 'asc' ? 'desc' : 'asc';
1342
+ } else {
1343
+ STATE.profilesSort.key = c.key;
1344
+ STATE.profilesSort.dir = (c.key === 'last_seen' || c.key === 'first_seen' || c.key === 'session_count' || c.key === 'profile_complete_fields') ? 'desc' : 'asc';
1345
+ }
1346
+ renderProfiles();
1347
+ };
1348
+ headRow.appendChild(th);
1349
+ });
1350
+ thead.appendChild(headRow);
1351
+ table.appendChild(thead);
1352
+
1353
+ var tbody = createEl('tbody');
1354
+ sorted.forEach(function (p) {
1355
+ var tr = createEl('tr');
1356
+
1357
+ var nameCell = createEl('td', { className: 'name-cell', text: p.name_display || '—' });
1358
+ tr.appendChild(nameCell);
1359
+
1360
+ var slugCell = createEl('td', { className: 'slug-cell', text: p.slug || '—' });
1361
+ tr.appendChild(slugCell);
1362
+
1363
+ var firstCell = createEl('td', { className: 'time-cell', text: fmtIST(p.first_seen) });
1364
+ if (!p.first_seen) firstCell.classList.add('muted');
1365
+ tr.appendChild(firstCell);
1366
+
1367
+ var lastCell = createEl('td', { className: 'time-cell', text: fmtIST(p.last_seen) });
1368
+ if (!p.last_seen) lastCell.classList.add('muted');
1369
+ tr.appendChild(lastCell);
1370
+
1371
+ var sessCell = createEl('td', { className: 'num-cell', text: p.session_count != null ? String(p.session_count) : '—' });
1372
+ tr.appendChild(sessCell);
1373
+
1374
+ var fieldsCell = createEl('td', { className: 'num-cell' });
1375
+ var fc = countFields(p.profile_complete_fields);
1376
+ var bar = createEl('span', { className: 'field-bar' });
1377
+ var track = createEl('span', { className: 'track' });
1378
+ var fill = createEl('span', { className: 'fill' });
1379
+ var pct = fieldDenom > 0 ? Math.min(100, (fc / fieldDenom) * 100) : 0;
1380
+ fill.style.width = pct.toFixed(0) + '%';
1381
+ track.appendChild(fill);
1382
+ var cnt = createEl('span', { className: 'field-count', text: fc + ' / ' + fieldDenom });
1383
+ bar.appendChild(track);
1384
+ bar.appendChild(cnt);
1385
+ fieldsCell.appendChild(bar);
1386
+ tr.appendChild(fieldsCell);
1387
+
1388
+ tbody.appendChild(tr);
1389
+ });
1390
+ table.appendChild(tbody);
1391
+ body.appendChild(table);
1392
+ }
1393
+
1394
+ // profile_complete_fields may be an integer or a list of field names
1395
+ function countFields(v) {
1396
+ if (v == null) return 0;
1397
+ if (typeof v === 'number') return v;
1398
+ if (Array.isArray(v)) return v.length;
1399
+ if (typeof v === 'object') return Object.keys(v).length;
1400
+ return 0;
1401
+ }
1402
+
1403
+ function renderProfilesPending() {
1404
+ var body = $('profiles-body');
1405
+ clearChildren(body);
1406
+ body.appendChild(buildPendingMessage('/api/admin/profiles'));
1407
+ }
1408
+
1409
+ function loadProfiles(forceReload) {
1410
+ if (STATE.profilesLoaded && !forceReload) {
1411
+ renderProfiles();
1412
+ return Promise.resolve();
1413
+ }
1414
+ return fetchProfiles().then(function () {
1415
+ STATE.profilesLoaded = true;
1416
+ renderProfiles();
1417
+ }).catch(function (err) {
1418
+ if (err && err.notDeployed) {
1419
+ renderProfilesPending();
1420
+ } else {
1421
+ handleFetchErr(err);
1422
+ }
1423
+ });
1424
+ }
1425
+
1426
+ // ----- Performance tab -----
1427
+ function fetchPerformance() {
1428
+ return apiGetTolerant('/api/admin/performance').then(function (data) {
1429
+ STATE.performance = data;
1430
+ });
1431
+ }
1432
+
1433
+ function loadPerformance(forceReload) {
1434
+ if (STATE.performanceLoaded && !forceReload) {
1435
+ renderPerformance();
1436
+ return Promise.resolve();
1437
+ }
1438
+ return fetchPerformance().then(function () {
1439
+ STATE.performanceLoaded = true;
1440
+ renderPerformance();
1441
+ }).catch(function (err) {
1442
+ if (err && err.notDeployed) {
1443
+ renderPerformancePending();
1444
+ } else {
1445
+ handleFetchErr(err);
1446
+ }
1447
+ });
1448
+ }
1449
+
1450
+ function renderPerformancePending() {
1451
+ var body = $('performance-body');
1452
+ clearChildren(body);
1453
+ body.appendChild(buildPendingMessage('/api/admin/performance'));
1454
+ }
1455
+
1456
+ function renderPerformance() {
1457
+ var body = $('performance-body');
1458
+ clearChildren(body);
1459
+ if (!STATE.performance) {
1460
+ body.appendChild(createEl('div', { className: 'empty-state', text: 'Loading…' }));
1461
+ return;
1462
+ }
1463
+ var p = STATE.performance;
1464
+
1465
+ // (a) Latest 96-Q gold eval
1466
+ var goldSection = createEl('div', { className: 'perf-sub' });
1467
+ goldSection.appendChild(createEl('h3', { text: 'Latest 96-Q gold eval' }));
1468
+ var gold = p.gold_eval || p.eval || null;
1469
+ if (!gold || isEmptyObj(gold)) {
1470
+ goldSection.appendChild(createEl('div', { className: 'empty-state', text: 'No gold eval run yet.' }));
1471
+ } else {
1472
+ var caption = createEl('div', { className: 'perf-caption' });
1473
+ var runAt = gold.run_at || gold.timestamp || gold.created_at;
1474
+ if (runAt) caption.textContent = 'Run at ' + fmtIST(runAt);
1475
+ if (caption.textContent) goldSection.appendChild(caption);
1476
+
1477
+ var tiles = createEl('div', { className: 'metric-grid' });
1478
+ tiles.appendChild(metricTile('Factual', fmtPctMaybe(pickPct(gold, ['factual_pct', 'factual', 'factual_correct_pct']))));
1479
+ tiles.appendChild(metricTile('Citation', fmtPctMaybe(pickPct(gold, ['citation_pct', 'citation', 'citation_correct_pct']))));
1480
+ tiles.appendChild(metricTile('Refusal', fmtPctMaybe(pickPct(gold, ['refusal_pct', 'refusal', 'refusal_rate']))));
1481
+ if (gold.total_questions != null) {
1482
+ tiles.appendChild(metricTile('Questions', String(gold.total_questions)));
1483
+ } else if (gold.n != null) {
1484
+ tiles.appendChild(metricTile('Questions', String(gold.n)));
1485
+ }
1486
+ goldSection.appendChild(tiles);
1487
+
1488
+ // by_brain table
1489
+ var byBrain = gold.by_brain;
1490
+ if (byBrain && !isEmptyObj(byBrain)) {
1491
+ goldSection.appendChild(createEl('h3', { text: 'By brain' }));
1492
+ goldSection.appendChild(renderBreakdownTable(byBrain, ['Brain', 'Factual', 'Citation', 'Refusal', 'N']));
1493
+ }
1494
+
1495
+ // by_type table
1496
+ var byType = gold.by_type;
1497
+ if (byType && !isEmptyObj(byType)) {
1498
+ goldSection.appendChild(createEl('h3', { text: 'By type' }));
1499
+ goldSection.appendChild(renderBreakdownTable(byType, ['Type', 'Factual', 'Citation', 'Refusal', 'N']));
1500
+ }
1501
+ }
1502
+ body.appendChild(goldSection);
1503
+
1504
+ // (b) Latest persona audit
1505
+ var personaSection = createEl('div', { className: 'perf-sub' });
1506
+ personaSection.appendChild(createEl('h3', { text: 'Latest persona audit' }));
1507
+ var persona = p.persona_audit || p.persona || null;
1508
+ if (!persona || isEmptyObj(persona)) {
1509
+ personaSection.appendChild(createEl('div', { className: 'empty-state', text: 'No persona audit run yet.' }));
1510
+ } else {
1511
+ var pCaption = createEl('div', { className: 'perf-caption' });
1512
+ var pRunAt = persona.run_at || persona.timestamp || persona.created_at;
1513
+ if (pRunAt) pCaption.textContent = 'Run at ' + fmtIST(pRunAt);
1514
+ if (pCaption.textContent) personaSection.appendChild(pCaption);
1515
+
1516
+ var pTiles = createEl('div', { className: 'metric-grid' });
1517
+ if (persona.personas_completed != null) {
1518
+ pTiles.appendChild(metricTile('Personas completed', String(persona.personas_completed)));
1519
+ }
1520
+ pTiles.appendChild(metricTile('p50 latency', fmtLatency(persona.p50_ms != null ? persona.p50_ms : persona.p50)));
1521
+ pTiles.appendChild(metricTile('p95 latency', fmtLatency(persona.p95_ms != null ? persona.p95_ms : persona.p95)));
1522
+ pTiles.appendChild(metricTile('p99 latency', fmtLatency(persona.p99_ms != null ? persona.p99_ms : persona.p99)));
1523
+ if (persona.refusals != null) {
1524
+ pTiles.appendChild(metricTile('Refusals', String(persona.refusals)));
1525
+ }
1526
+ personaSection.appendChild(pTiles);
1527
+
1528
+ // brain_routing pie/list
1529
+ var routing = persona.brain_routing;
1530
+ if (routing && !isEmptyObj(routing)) {
1531
+ personaSection.appendChild(createEl('h3', { text: 'Brain routing' }));
1532
+ personaSection.appendChild(renderBarList(routing));
1533
+ }
1534
+ }
1535
+ body.appendChild(personaSection);
1536
+
1537
+ // (c) Last 24 h LLM usage — per-role
1538
+ var usageSection = createEl('div', { className: 'perf-sub' });
1539
+ usageSection.appendChild(createEl('h3', { text: 'Last 24 h LLM usage' }));
1540
+ var usage = p.llm_usage_24h || p.usage_24h || p.llm_usage || null;
1541
+ if (!usage || isEmptyObj(usage)) {
1542
+ usageSection.appendChild(createEl('div', { className: 'empty-state', text: 'No LLM usage recorded in last 24 h.' }));
1543
+ } else {
1544
+ var table = createEl('table');
1545
+ var thead = createEl('thead');
1546
+ var thr = createEl('tr');
1547
+ ['Role', 'Count', 'Success rate', 'Avg latency'].forEach(function (h, i) {
1548
+ var th = createEl('th', { text: h });
1549
+ if (i > 0) th.style.textAlign = 'right';
1550
+ thr.appendChild(th);
1551
+ });
1552
+ thead.appendChild(thr);
1553
+ table.appendChild(thead);
1554
+
1555
+ var tbody = createEl('tbody');
1556
+ // Accept either { role: {...} } map or [...] array
1557
+ var entries = [];
1558
+ if (Array.isArray(usage)) {
1559
+ entries = usage.map(function (u) { return { role: u.role, data: u }; });
1560
+ } else {
1561
+ for (var k in usage) {
1562
+ if (Object.prototype.hasOwnProperty.call(usage, k)) {
1563
+ entries.push({ role: k, data: usage[k] });
1564
+ }
1565
+ }
1566
+ }
1567
+ if (!entries.length) {
1568
+ var tr = createEl('tr');
1569
+ var td = createEl('td', { text: 'No data.', attrs: { colspan: '4' } });
1570
+ td.classList.add('muted');
1571
+ tr.appendChild(td);
1572
+ tbody.appendChild(tr);
1573
+ } else {
1574
+ entries.forEach(function (entry) {
1575
+ var u = entry.data || {};
1576
+ var tr = createEl('tr');
1577
+ var roleCell = createEl('td');
1578
+ var rbadge = createEl('span', { className: 'role-badge', text: ROLE_LABELS[entry.role] || String(entry.role).toUpperCase() });
1579
+ rbadge.style.background = ROLE_COLORS[entry.role] || '#444';
1580
+ roleCell.appendChild(rbadge);
1581
+ tr.appendChild(roleCell);
1582
+
1583
+ var countV = u.count != null ? u.count : (u.calls != null ? u.calls : (u.total_calls_24h != null ? u.total_calls_24h : null));
1584
+ var countCell = createEl('td', { text: countV != null ? String(countV) : '—' });
1585
+ countCell.style.textAlign = 'right';
1586
+ countCell.classList.add('mono');
1587
+ tr.appendChild(countCell);
1588
+
1589
+ var srV = u.success_rate != null ? u.success_rate : u.success;
1590
+ var srCell = createEl('td', { text: fmtPctMaybe(srV) });
1591
+ srCell.style.textAlign = 'right';
1592
+ tr.appendChild(srCell);
1593
+
1594
+ var latV = u.avg_latency_ms != null ? u.avg_latency_ms : (u.avg_ms != null ? u.avg_ms : u.avg_latency);
1595
+ var latCell = createEl('td', { text: fmtLatency(latV) });
1596
+ latCell.style.textAlign = 'right';
1597
+ tr.appendChild(latCell);
1598
+
1599
+ tbody.appendChild(tr);
1600
+ });
1601
+ }
1602
+ table.appendChild(tbody);
1603
+ usageSection.appendChild(table);
1604
+ }
1605
+ body.appendChild(usageSection);
1606
+ }
1607
+
1608
+ function metricTile(label, value, sub) {
1609
+ var t = createEl('div', { className: 'metric-tile' });
1610
+ t.appendChild(createEl('div', { className: 'label', text: label }));
1611
+ t.appendChild(createEl('div', { className: 'value', text: value != null ? value : '—' }));
1612
+ if (sub) t.appendChild(createEl('div', { className: 'sub', text: sub }));
1613
+ return t;
1614
+ }
1615
+
1616
+ function pickPct(obj, keys) {
1617
+ for (var i = 0; i < keys.length; i++) {
1618
+ if (obj[keys[i]] != null) return obj[keys[i]];
1619
+ }
1620
+ return null;
1621
+ }
1622
+
1623
+ // Format a value that might be a fraction (0-1) or already a percentage (0-100).
1624
+ // If > 1 assume percent; if <= 1 assume fraction.
1625
+ function fmtPctMaybe(v) {
1626
+ if (v == null || isNaN(v)) return '—';
1627
+ var n = Number(v);
1628
+ if (n <= 1) return (n * 100).toFixed(1) + '%';
1629
+ return n.toFixed(1) + '%';
1630
+ }
1631
+
1632
+ function isEmptyObj(o) {
1633
+ if (o == null) return true;
1634
+ if (Array.isArray(o)) return o.length === 0;
1635
+ if (typeof o !== 'object') return false;
1636
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) return false;
1637
+ return true;
1638
+ }
1639
+
1640
+ // Render a breakdown table from a map { key: { factual_pct, citation_pct, refusal_pct, n } }
1641
+ function renderBreakdownTable(map, headers) {
1642
+ var table = createEl('table');
1643
+ var thead = createEl('thead');
1644
+ var thr = createEl('tr');
1645
+ headers.forEach(function (h, i) {
1646
+ var th = createEl('th', { text: h });
1647
+ if (i > 0) th.style.textAlign = 'right';
1648
+ thr.appendChild(th);
1649
+ });
1650
+ thead.appendChild(thr);
1651
+ table.appendChild(thead);
1652
+
1653
+ var tbody = createEl('tbody');
1654
+ var keys = Object.keys(map);
1655
+ if (!keys.length) {
1656
+ var tr = createEl('tr');
1657
+ var td = createEl('td', { text: 'No data.', attrs: { colspan: String(headers.length) } });
1658
+ td.classList.add('muted');
1659
+ tr.appendChild(td);
1660
+ tbody.appendChild(tr);
1661
+ } else {
1662
+ keys.forEach(function (k) {
1663
+ var v = map[k] || {};
1664
+ var tr = createEl('tr');
1665
+ var keyCell = createEl('td', { text: k });
1666
+ keyCell.classList.add('mono');
1667
+ tr.appendChild(keyCell);
1668
+
1669
+ var fCell = createEl('td', { text: fmtPctMaybe(pickPct(v, ['factual_pct', 'factual'])) });
1670
+ fCell.style.textAlign = 'right';
1671
+ tr.appendChild(fCell);
1672
+
1673
+ var cCell = createEl('td', { text: fmtPctMaybe(pickPct(v, ['citation_pct', 'citation'])) });
1674
+ cCell.style.textAlign = 'right';
1675
+ tr.appendChild(cCell);
1676
+
1677
+ var rCell = createEl('td', { text: fmtPctMaybe(pickPct(v, ['refusal_pct', 'refusal'])) });
1678
+ rCell.style.textAlign = 'right';
1679
+ tr.appendChild(rCell);
1680
+
1681
+ var nCell = createEl('td', { text: v.n != null ? String(v.n) : (v.count != null ? String(v.count) : '—') });
1682
+ nCell.style.textAlign = 'right';
1683
+ nCell.classList.add('mono');
1684
+ tr.appendChild(nCell);
1685
+
1686
+ tbody.appendChild(tr);
1687
+ });
1688
+ }
1689
+ table.appendChild(tbody);
1690
+ return table;
1691
+ }
1692
+
1693
+ // Render a horizontal-bar list for a count/share map.
1694
+ function renderBarList(map) {
1695
+ // Accept either { key: count } or { key: { count, share } } or array
1696
+ var entries = [];
1697
+ if (Array.isArray(map)) {
1698
+ map.forEach(function (item) {
1699
+ entries.push({ label: item.label || item.brain || item.key || '—', value: item.count != null ? item.count : (item.value != null ? item.value : 0) });
1700
+ });
1701
+ } else {
1702
+ for (var k in map) {
1703
+ if (Object.prototype.hasOwnProperty.call(map, k)) {
1704
+ var v = map[k];
1705
+ if (typeof v === 'number') entries.push({ label: k, value: v });
1706
+ else if (v && typeof v === 'object') entries.push({ label: k, value: v.count != null ? v.count : (v.value != null ? v.value : 0) });
1707
+ }
1708
+ }
1709
+ }
1710
+ var total = entries.reduce(function (acc, e) { return acc + (Number(e.value) || 0); }, 0);
1711
+ var wrap = createEl('div', { className: 'bar-list' });
1712
+ if (!entries.length || total === 0) {
1713
+ var empty = createEl('div', { className: 'empty-state', text: 'No routing data.' });
1714
+ wrap.appendChild(empty);
1715
+ return wrap;
1716
+ }
1717
+ entries.sort(function (a, b) { return b.value - a.value; });
1718
+ entries.forEach(function (e) {
1719
+ var row = createEl('div', { className: 'bar-row' });
1720
+ var lbl = createEl('div', { className: 'bar-label mono', text: e.label });
1721
+ var track = createEl('div', { className: 'bar-track' });
1722
+ var fill = createEl('div', { className: 'bar-fill' });
1723
+ var pct = total > 0 ? (e.value / total) * 100 : 0;
1724
+ fill.style.width = pct.toFixed(1) + '%';
1725
+ if (ROLE_COLORS[e.label]) fill.style.background = ROLE_COLORS[e.label];
1726
+ track.appendChild(fill);
1727
+ var val = createEl('div', { className: 'bar-value', text: e.value + ' (' + pct.toFixed(1) + '%)' });
1728
+ row.appendChild(lbl);
1729
+ row.appendChild(track);
1730
+ row.appendChild(val);
1731
+ wrap.appendChild(row);
1732
+ });
1733
+ return wrap;
1734
+ }
1735
+
1736
+ // ----- Tab switching -----
1737
+ function switchTab(tab) {
1738
+ STATE.activeTab = tab;
1739
+ var tabs = ['profiles', 'performance', 'chain'];
1740
+ tabs.forEach(function (t) {
1741
+ var btn = $('tabbtn-' + t);
1742
+ var pane = $('tab-' + t);
1743
+ if (btn) {
1744
+ if (t === tab) { btn.classList.add('active'); btn.setAttribute('aria-selected', 'true'); }
1745
+ else { btn.classList.remove('active'); btn.setAttribute('aria-selected', 'false'); }
1746
+ }
1747
+ if (pane) {
1748
+ if (t === tab) pane.classList.add('active');
1749
+ else pane.classList.remove('active');
1750
+ }
1751
+ });
1752
+ renderUpdatedLabel();
1753
+ // Lazy-load this tab's data
1754
+ if (tab === 'profiles') {
1755
+ loadProfiles(false);
1756
+ } else if (tab === 'performance') {
1757
+ loadPerformance(false);
1758
+ } else if (tab === 'chain') {
1759
+ if (!STATE.chainLoaded) {
1760
+ refreshChain().catch(handleFetchErr);
1761
+ }
1762
+ }
1763
+ }
1764
+
1765
  // ----- Gate -----
1766
  function showGate(errMsg) {
1767
  $('gate').classList.remove('hidden');
 
1783
  $('app').classList.remove('hidden');
1784
  }
1785
 
1786
+ // Validates the password by hitting the cheapest known-existing admin
1787
+ // endpoint (/api/admin/health), then opens the default tab (profiles).
1788
  function attemptUnlock(password, opts) {
1789
  opts = opts || {};
1790
  STATE.password = password;
1791
+ return apiGet('/api/admin/health').then(function (data) {
1792
+ STATE.health = data;
1793
  showApp();
1794
  if (opts.remember) {
1795
  try { localStorage.setItem(STORAGE_KEY, password); } catch (_) {}
1796
  }
1797
  toast('Unlocked', 'success');
1798
+ // Load initial tab (profiles)
1799
+ switchTab('profiles');
1800
  }).catch(function (err) {
1801
  STATE.password = '';
1802
  if (err && err.code === 404) {
 
1827
  STATE.password = '';
1828
  STATE.health = null;
1829
  STATE.chains = null;
1830
+ STATE.usage = null;
1831
+ STATE.profiles = null;
1832
+ STATE.profilesLoaded = false;
1833
+ STATE.performance = null;
1834
+ STATE.performanceLoaded = false;
1835
+ STATE.chainLoaded = false;
1836
  try { localStorage.removeItem(STORAGE_KEY); } catch (_) {}
1837
  showGate();
1838
  toast('Locked', 'info');
1839
  };
1840
 
1841
+ // Tab buttons
1842
+ ['profiles', 'performance', 'chain'].forEach(function (t) {
1843
+ var btn = $('tabbtn-' + t);
1844
+ if (btn) btn.onclick = function () { switchTab(t); };
1845
+ });
1846
+
1847
+ // Profiles refresh
1848
+ $('btn-refresh-profiles').onclick = function () {
1849
+ var btn = this;
1850
+ btn.disabled = true;
1851
+ btn.textContent = 'Refreshing…';
1852
+ loadProfiles(true).then(function () { toast('Profiles refreshed', 'success'); })
1853
+ .catch(handleFetchErr)
1854
+ .then(function () { btn.disabled = false; btn.textContent = 'Refresh'; });
1855
+ };
1856
+
1857
+ // Performance refresh
1858
+ $('btn-refresh-performance').onclick = function () {
1859
+ var btn = this;
1860
+ btn.disabled = true;
1861
+ btn.textContent = 'Refreshing…';
1862
+ loadPerformance(true).then(function () { toast('Performance refreshed', 'success'); })
1863
+ .catch(handleFetchErr)
1864
+ .then(function () { btn.disabled = false; btn.textContent = 'Refresh'; });
1865
+ };
1866
+
1867
+ // LLM Chain refresh + probe (preserved from original)
1868
  $('btn-refresh').onclick = function () {
1869
  var btn = this;
1870
  btn.disabled = true;
1871
  btn.textContent = 'Refreshing…';
1872
+ refreshChain()
1873
  .then(function () { toast('Refreshed from cache', 'success'); })
1874
  .catch(handleFetchErr)
1875
  .then(function () { btn.disabled = false; btn.textContent = 'Refresh (cached)'; });
 
1880
  btn.disabled = true;
1881
  btn.textContent = 'Probing…';
1882
  apiPost('/api/admin/probe', null)
1883
+ .then(function () { return refreshChain(); })
1884
  .then(function () { toast('Fresh probe complete', 'success'); })
1885
  .catch(handleFetchErr)
1886
  .then(function () { btn.disabled = false; btn.textContent = 'Force fresh probe (slow)'; });