InsuranceBot / frontend /public /admin /llm-control.html
rohitsar567's picture
fix: 4 hardening fixes — sticky-retry, canned-msg copy, admin refresh wiring, recall privacy
2acdc9e
Raw
History Blame Contribute Delete
136 kB
<!DOCTYPE html>
<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 !important; }
.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)) !important; }
/* 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>