The dashboard was eight cards restating what the other seven pages own, so it was the page you passed through rather than the one you started from. It is now the landing page: no nav entry of its own, reached by the logo, with two views over the one /api/snapshot payload so switching costs no request. Board is one tile per destination, identical anatomy every time: what it is, one number, one qualifier, a link. Under them, a full-width Activity band plots routed decisions against billed requests on one axis, so the gap between them -- local dispatch, cache hits, requests that never reached a provider -- is readable, which it is in neither series alone. Its range (24h/7d/30d) is separate from the tile sparklines, because it is the chart you come to the page to read. Beside it, the eight busiest models ranked by CALLS, not cost: cost coverage is partial, and an OpenRouter row with two priced calls out of hundreds otherwise outranks the model doing the work. Live loads the row itself -- category, model, provider, required context, tier, classify time, exploratory flag, cost -- so the usual question does not need the Decisions page, and groups rows by minute so a burst reads as one. The dot is the classification source: green for a real classification, amber for a degraded one, which still routes but never moves proficiency, and until now was visible only in a /metrics warning. The rail beside it answers what Board cannot: throughput against the last hour, classifier p50 and p95 (the latency floor every routed request pays before an upstream token), degraded share, and the top five models by share of the live window keyed on model AND provider, since the same model on two providers is two different routing outcomes. Spend is not restated here; it belongs to Quota. Warnings move behind a navbar bell. They are recomputed from live state on every poll, so the only honest dismissal is "hide until the condition changes": a dismissal is keyed on the warning's TEXT, so when the numbers move the key stops matching and the warning returns on its own. Nothing has to expire it. Per browser, in localStorage, wrapped in try/catch -- a reading state, not a fact about the router. Two tests changed shape rather than being deleted. The verdict-bar tests pinned a card this page no longer has, but the lesson they recorded is the expensive part and is preserved verbatim in the replacement's docstring: `unverifiable` is not a finding, it is the default for prose and for any turn ending in a tool call, and any bar scaled against it renders every meaningful verdict at the 2px minimum. Exclude it from the scale, never from the denominator. The Live rail states the same fact as one ratio, which needs no scale. The nav test's rule is inverted for index.html rather than skipped: the landing page must have no nav entry AND must claim no active one, so neither can drift back in. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VRQXz5SYZYVWscxS1QqF6U
1288 lines
67 KiB
HTML
1288 lines
67 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en" data-bs-theme="dark">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Home — LLM Router Admin</title>
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns@3.0.0/dist/chartjs-adapter-date-fns.bundle.min.js"></script>
|
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/core@1.4.0/dist/css/tabler.min.css">
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Quicksand:wght@600;700&display=swap">
|
|
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20viewBox%3D%220%200%20256%20256%22%3E%3Ccircle%20cx%3D%22146%22%20cy%3D%2284%22%20r%3D%2245.6%22%20fill%3D%22%23fff%22/%3E%3Cpath%20d%3D%22M124%20110%20Q90%20140%2090%20176%20Q90%20212%20122%20212%20Q156%20212%20172%20186%22%20fill%3D%22none%22%20stroke%3D%22%23fff%22%20stroke-width%3D%2239.2%22%20stroke-linecap%3D%22round%22/%3E%3Ccircle%20cx%3D%22146%22%20cy%3D%2284%22%20r%3D%2242%22%20fill%3D%22%23f59e0b%22/%3E%3Cpath%20d%3D%22M124%20110%20Q90%20140%2090%20176%20Q90%20212%20122%20212%20Q156%20212%20172%20186%22%20fill%3D%22none%22%20stroke%3D%22%23f59e0b%22%20stroke-width%3D%2232%22%20stroke-linecap%3D%22round%22/%3E%3Ccircle%20cx%3D%22128%22%20cy%3D%2278%22%20r%3D%229%22%20fill%3D%22%23fff%22/%3E%3Ccircle%20cx%3D%22164%22%20cy%3D%2278%22%20r%3D%229%22%20fill%3D%22%23fff%22/%3E%3Ccircle%20cx%3D%22128%22%20cy%3D%2278%22%20r%3D%226%22%20fill%3D%22%231e293b%22/%3E%3Ccircle%20cx%3D%22164%22%20cy%3D%2278%22%20r%3D%226%22%20fill%3D%22%231e293b%22/%3E%3Ccircle%20cx%3D%22131%22%20cy%3D%2274%22%20r%3D%222.2%22%20fill%3D%22%23fff%22/%3E%3Ccircle%20cx%3D%22167%22%20cy%3D%2274%22%20r%3D%222.2%22%20fill%3D%22%23fff%22/%3E%3Cpath%20d%3D%22M134%20100%20Q146%20112%20158%20100%22%20fill%3D%22none%22%20stroke%3D%22%23fff%22%20stroke-width%3D%227%22%20stroke-linecap%3D%22round%22/%3E%3Cpath%20d%3D%22M134%20100%20Q146%20112%20158%20100%22%20fill%3D%22none%22%20stroke%3D%22%231e293b%22%20stroke-width%3D%223.5%22%20stroke-linecap%3D%22round%22/%3E%3Cline%20x1%3D%22150.47%22%20y1%3D%2247.59%22%20x2%3D%22156.01%22%20y2%3D%2227.13%22%20stroke%3D%22%23fff%22%20stroke-width%3D%2210.73%22%20stroke-linecap%3D%22round%22/%3E%3Cline%20x1%3D%22150.47%22%20y1%3D%2247.59%22%20x2%3D%22155.08%22%20y2%3D%2230.54%22%20stroke%3D%22%231e293b%22%20stroke-width%3D%226.26%22%20stroke-linecap%3D%22round%22/%3E%3Ccircle%20cx%3D%2295%22%20cy%3D%22226%22%20r%3D%2214%22%20fill%3D%22%23fff%22/%3E%3Ccircle%20cx%3D%22150%22%20cy%3D%22226%22%20r%3D%2214%22%20fill%3D%22%23fff%22/%3E%3Ccircle%20cx%3D%2295%22%20cy%3D%22226%22%20r%3D%229%22%20fill%3D%22%231e293b%22/%3E%3Ccircle%20cx%3D%22150%22%20cy%3D%22226%22%20r%3D%229%22%20fill%3D%22%231e293b%22/%3E%3C/svg%3E">
|
|
<script src="https://cdn.jsdelivr.net/npm/@tabler/core@1.4.0/dist/js/tabler.min.js"></script>
|
|
<link rel="stylesheet" href="navbar.css">
|
|
<script src="navbar.js"></script>
|
|
<style>
|
|
/* ── Liquid glass: soft gradient wash behind everything so the blur has
|
|
something to catch, then translucent+blurred navbar/cards on top. Kept
|
|
subtle — this is a working dashboard, not a marketing page. ── */
|
|
body{
|
|
background:
|
|
radial-gradient(900px circle at 6% -8%, rgba(245,158,11,.10), transparent 55%),
|
|
radial-gradient(800px circle at 94% -6%, rgba(59,130,246,.10), transparent 50%),
|
|
radial-gradient(760px circle at 50% 108%, rgba(139,92,246,.07), transparent 55%),
|
|
#111827;
|
|
background-attachment: fixed;
|
|
}
|
|
header.navbar{
|
|
background: rgba(31,41,55,.6)!important;
|
|
backdrop-filter: blur(16px) saturate(160%);
|
|
-webkit-backdrop-filter: blur(16px) saturate(160%);
|
|
border-bottom: 1px solid rgba(255,255,255,.06);
|
|
/* backdrop-filter creates a stacking context; without an explicit
|
|
position+z-index this navbar (and its dropdown) paints BELOW the later
|
|
.page element's own stacking context (the quota chip has backdrop-filter
|
|
too) regardless of the dropdown's own z-index — DOM order decides
|
|
between two auto-stacked contexts, not z-index alone. Pin it above. */
|
|
position: relative;
|
|
z-index: 1030;
|
|
}
|
|
.card{
|
|
background: rgba(31,41,55,.55);
|
|
backdrop-filter: blur(18px) saturate(140%);
|
|
-webkit-backdrop-filter: blur(18px) saturate(140%);
|
|
border: 1px solid rgba(255,255,255,.07);
|
|
box-shadow: 0 10px 30px -14px rgba(0,0,0,.55), inset 0 1px 0 rgba(255,255,255,.04);
|
|
}
|
|
.card-header{border-bottom-color: rgba(255,255,255,.06)}
|
|
/* ── The "gap only at wide windows" bug, root-caused: this Chrome/Linux
|
|
build reports a phantom left margin on <html> exactly equal to the
|
|
scrollbar's width whenever <html> itself is the scrolling element —
|
|
confirmed by toggling overflow-y on document.documentElement live
|
|
(scrollbar present -> 15px "margin-left"; overflow-y:hidden -> 0). Width
|
|
only correlated because taller/shorter content crossing the scroll
|
|
threshold is what silently added or removed the scrollbar. Moving the
|
|
scroll context down to <body> (html never scrolls, body does) sidesteps
|
|
whatever miscomputes it, with identical scroll UX. ── */
|
|
html{overflow:hidden;height:100%;margin:0!important;padding:0!important}
|
|
body{overflow-y:auto;height:100%;margin:0!important;padding:0!important}
|
|
/* ── Buttons: glass pills instead of flat saturated fills. The stock
|
|
Bootstrap solid green/red read harsh against frosted cards — same fix
|
|
as everything else tonight, translucency + a colored border instead of
|
|
a solid block. ── */
|
|
.btn-success{background:rgba(34,197,94,.16)!important;border-color:rgba(74,222,128,.45)!important;color:#4ade80!important}
|
|
.btn-success:hover{background:rgba(34,197,94,.28)!important;border-color:rgba(74,222,128,.7)!important;color:#86efac!important}
|
|
.btn-danger{background:rgba(239,68,68,.16)!important;border-color:rgba(248,113,113,.45)!important;color:#f87171!important}
|
|
.btn-danger:hover{background:rgba(239,68,68,.28)!important;border-color:rgba(248,113,113,.7)!important;color:#fca5a5!important}
|
|
.btn{backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px)}
|
|
/* ── Badges: same glass treatment. Also the actual fix for Category
|
|
Breakdown's counts reading as blank — bg-secondary's default text color
|
|
was too close to its own background to read at a glance. ── */
|
|
.badge.bg-success{background:rgba(34,197,94,.20)!important;color:#4ade80!important}
|
|
.badge.bg-warning{background:rgba(245,158,11,.20)!important;color:#fbbf24!important}
|
|
.badge.bg-danger{background:rgba(239,68,68,.20)!important;color:#f87171!important}
|
|
.badge.bg-info{background:rgba(59,130,246,.20)!important;color:#93c5fd!important}
|
|
.badge.bg-secondary{background:rgba(148,163,184,.20)!important;color:#e2e8f0!important}
|
|
.badge.bg-purple{background:rgba(168,85,247,.20)!important;color:#d8b4fe!important}
|
|
/* ── Progress bars (Per-Model Usage, Verdict Mix): translucent track + a
|
|
soft glow on the fill instead of a flat solid block — same language as
|
|
the quota chip's fill. ── */
|
|
.progress{background:rgba(255,255,255,.08)!important;border-radius:999px;overflow:hidden}
|
|
.progress-bar{box-shadow:0 0 6px 0 currentColor;filter:saturate(1.25)}
|
|
/* ── Quota modal: same glass card language as everything else. ── */
|
|
.modal-content{
|
|
background: rgba(31,41,55,.75);
|
|
backdrop-filter: blur(24px) saturate(150%);
|
|
-webkit-backdrop-filter: blur(24px) saturate(150%);
|
|
border: 1px solid rgba(255,255,255,.09);
|
|
box-shadow: 0 20px 50px -20px rgba(0,0,0,.6);
|
|
}
|
|
.modal-backdrop{background:#000}
|
|
.modal-backdrop.show{opacity:.6}
|
|
/* ── Warnings bell: navbar icon replacing the old full card. Amber glass
|
|
dot carries the count; dropdown menu gets the same glass card look. ── */
|
|
.warn-bell{position:relative;display:flex;align-items:center;justify-content:center;width:32px;height:32px;border-radius:999px;border:1px solid rgba(245,158,11,.3);background:rgba(245,158,11,.14);color:#fbbf24;cursor:pointer}
|
|
.warn-bell:hover{background:rgba(245,158,11,.22)}
|
|
.warn-count-dot{position:absolute;top:-4px;right:-4px;min-width:15px;height:15px;padding:0 3px;border-radius:999px;background:#ef4444;color:#fff;font-size:.62rem;line-height:15px;text-align:center;font-weight:700;box-shadow:0 0 0 2px #1f2937}
|
|
#warnings-menu{background:rgba(31,41,55,.92);backdrop-filter:blur(20px) saturate(150%);-webkit-backdrop-filter:blur(20px) saturate(150%);border:1px solid rgba(255,255,255,.09)}
|
|
/* ── Navbar edge padding: pinned to an explicit value instead of trusting
|
|
Bootstrap's --bs-gutter-x default, so the brand's left edge is exactly
|
|
20px from the viewport edge regardless of container/breakpoint math. ── */
|
|
header.navbar>.container-fluid{padding-left:20px!important;padding-right:20px!important}
|
|
/* ── Rounder hero type: brand wordmark + page titles get the friendlier
|
|
geometric font; body copy stays on Tabler's default for readability. ── */
|
|
.navbar-brand, .page-title{font-family:'Quicksand',var(--tblr-font-sans-serif,ui-sans-serif,system-ui,sans-serif);font-weight:700;letter-spacing:.01em}
|
|
/* ── Header brand link: browser default `a{text-decoration:underline}` was
|
|
never overridden on this specific anchor (it has no .nav-link/.navbar-brand
|
|
class of its own, just inline flex styling) — showed up as a stray
|
|
underline under the brand wordmark in real browsers. ── */
|
|
.navbar-brand a{text-decoration:none!important;color:inherit}
|
|
/* ── Brand: Tabler's own rule is [data-bs-theme=dark] .navbar-brand-autodark
|
|
.navbar-brand-image{filter:brightness(0) invert(1)} — attribute selector +
|
|
2 classes outranks a plain 2-class override, so `filter:none` alone loses
|
|
the cascade regardless of source order (confirmed live: logo still a flat
|
|
white silhouette after that "fix"). !important is what actually beats it.
|
|
That filter was stripping the mascot's orange color, and the 2rem
|
|
.navbar-brand-image cap shrank his face to sub-pixel dots.
|
|
Restore color + force an explicit size (64px, below). ── */
|
|
.navbar-brand-autodark .navbar-brand-image{filter:none!important;height:64px;width:64px}
|
|
/* ── 64px mascot, same bar height. His face is only ~40% of his height (the
|
|
rest is tail and wheels), so at 45px the eyes were ~4px and the whole point
|
|
of having a mascot was lost. The 19px comes out of padding, not out of the
|
|
navbar: the brand h1 carries 8px top and bottom, and the navbar itself 4px.
|
|
Zero the first and halve the second and the bar lands at 69px — 1px shorter
|
|
than the 45px version it replaces. Measure before changing either number. ── */
|
|
.navbar-brand{padding-top:0!important;padding-bottom:0!important}
|
|
header.navbar{padding-top:2px!important;padding-bottom:2px!important}
|
|
/* ── Speed lines: he is a wheeled rover, so give him a trail. Drawn as their
|
|
own inline SVG rather than baked into the logo, so assets/*.svg stays the
|
|
plain mascot and the lines can drop out on narrow screens (d-none d-md-flex).
|
|
The gradient fades AWAY from him (transparent at the far left, amber where
|
|
he is), which is what reads as speed rather than as three floating dashes;
|
|
it is userSpaceOnUse because an objectBoundingBox gradient on a horizontal
|
|
line has a zero-height bbox and is undefined. ── */
|
|
.navbar-brand a{display:inline-flex;align-items:center;gap:.15rem}
|
|
/* -20px: the logo's own viewBox carries ~18px of empty space to the left of
|
|
his tail at 64px, so the pull has to cover that before it buys any real
|
|
closeness. Measured gap from the middle line's tip to his back: ~4.5px. */
|
|
.brand-speedlines{align-items:center;line-height:0;margin-right:-20px}
|
|
.brand-speedlines svg{filter:none!important}
|
|
/* Motion only on hover — a dashboard that twitches at rest is a dashboard you
|
|
stop looking at. */
|
|
@keyframes brand-dash{
|
|
0%{transform:translateX(-5px);opacity:.2}
|
|
55%{opacity:1}
|
|
100%{transform:translateX(4px);opacity:0}
|
|
}
|
|
.navbar-brand a:hover .brand-speedlines line{animation:brand-dash .75s ease-in infinite}
|
|
.navbar-brand a:hover .brand-speedlines line:nth-child(2){animation-delay:.12s}
|
|
.navbar-brand a:hover .brand-speedlines line:nth-child(3){animation-delay:.24s}
|
|
@media (prefers-reduced-motion: reduce){
|
|
.navbar-brand a:hover .brand-speedlines line{animation:none}
|
|
}
|
|
/* ── Chart container (minimal override) ── */
|
|
.chart-container{position:relative;width:100%}
|
|
.chart-container canvas{width:100%!important;height:100%!important;display:block}
|
|
/* ── Verdict Mix: pure HTML horizontal bars ── */
|
|
.verdict-row{display:flex;align-items:center;gap:8px;font-size:0.8rem;margin-bottom:6px}
|
|
.verdict-label{flex:0 0 88px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--tblr-secondary)}
|
|
/* Category Breakdown reuses the verdict-row layout but task_category names
|
|
("tool_use_agentic", "coding_refactor") run longer than verdict words
|
|
("failed", "succeeded") — verdict-label's 88px was truncating them to
|
|
"tool_use_ag…". Wider label, same everything else. */
|
|
.cat-label{flex:0 0 128px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--tblr-secondary)}
|
|
.verdict-progress{flex:1 1 auto;height:10px}
|
|
.verdict-nums{flex:0 0 auto;font-variant-numeric:tabular-nums;white-space:nowrap;color:var(--tblr-secondary)}
|
|
.verdict-share{color:var(--tblr-secondary);opacity:0.7}
|
|
/* ── History mini-charts: Bootstrap grid + fixed-height wraps ── */
|
|
.history-chart{position:relative;width:100%;height:150px;min-height:120px}
|
|
.history-chart canvas{width:100%!important;height:100%!important;display:block}
|
|
.history-row .col{min-width:180px}
|
|
|
|
/* ── App-specific panel layout (thin layer on top of Tabler) ── */
|
|
.panel-scroll{max-height:260px;overflow-y:auto;overscroll-behavior:contain}
|
|
/* Per-model usage label/bar/value rows. Grid instead of a single stacked
|
|
column — now that Quota Meter no longer shares this row, one bar per
|
|
full-width line left ~60% of it empty. auto-fit collapses to 1 column
|
|
itself on narrow viewports, so no separate breakpoint is needed. */
|
|
.bars-scroll{display:grid;grid-template-columns:repeat(auto-fit,minmax(340px,1fr));gap:2px 24px;max-height:340px;overflow-y:auto;overscroll-behavior:contain;align-content:start}
|
|
.bar-row{display:flex;align-items:center;gap:8px;font-size:0.75rem;padding:3px 6px;border-radius:6px;transition:background .15s ease}
|
|
.bar-row:hover{background:rgba(255,255,255,.06)}
|
|
.bar-label{flex-shrink:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}
|
|
.bar-progress{flex:1 1 90px;max-width:160px;min-width:60px}
|
|
.bar-val{flex-shrink:0;color:var(--tblr-secondary);white-space:nowrap;font-size:0.7rem}
|
|
/* Per-Model Usage sort toggle */
|
|
.bars-sort{display:flex;gap:6px}
|
|
.bars-sort button{font-size:.68rem;padding:2px 10px;border-radius:999px;border:1px solid rgba(255,255,255,.12);background:transparent;color:var(--tblr-secondary);cursor:pointer;transition:background .15s ease,color .15s ease,border-color .15s ease}
|
|
.bars-sort button:hover{border-color:rgba(255,255,255,.25);color:#e2e8f0}
|
|
.bars-sort button.active{background:rgba(59,130,246,.20);border-color:rgba(59,130,246,.45);color:#93c5fd}
|
|
/* Category list rows: colored dot + name + count */
|
|
.cat-item{display:flex;align-items:center;gap:8px;font-size:0.82rem}
|
|
.cat-dot{width:10px;height:10px;border-radius:50%;flex:0 0 auto}
|
|
.cat-name{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
.cat-val{color:var(--tblr-secondary)}
|
|
/* Tier colors on the decisions table */
|
|
.tier-1{color:var(--tblr-success)}
|
|
.tier-2{color:var(--tblr-warning)}
|
|
.tier-3{color:var(--tblr-danger)}
|
|
.tier-?{color:var(--tblr-secondary)}
|
|
/* Footer parts are laid out, not joined by a glyph. See
|
|
plans/admin-design-standards.md, "No middot separators". */
|
|
.footer-bits{display:inline-flex;flex-wrap:wrap;gap:.25rem 1.4rem}
|
|
/* Independent stats in a row: spaced, not joined by a glyph. Each stands
|
|
alone so the eye can tell which number answers which question. See
|
|
plans/admin-design-standards.md, "No middot separators". */
|
|
.stat-set{display:inline-flex;flex-wrap:wrap;gap:.15rem .85rem;align-items:baseline}
|
|
.stat-set b{font-weight:600;font-variant-numeric:tabular-nums}
|
|
/* A ranked row opens its own detail; make that discoverable without adding
|
|
an affordance that competes with the bar itself. */
|
|
.bar-clickable{cursor:pointer;border-radius:4px}
|
|
.bar-clickable:hover{background:rgba(255,255,255,.04)}
|
|
.bar-clickable:focus-visible{outline:2px solid rgba(59,130,246,.55);outline-offset:1px}
|
|
.detail-facts{display:grid;grid-template-columns:auto 1fr;gap:.3rem .9rem;
|
|
font-size:.82rem;align-items:baseline;margin:0}
|
|
.detail-facts dt{color:var(--tblr-secondary)}
|
|
.detail-facts dd{margin:0;text-align:right;font-weight:600;font-variant-numeric:tabular-nums}
|
|
|
|
/* ── Home ─────────────────────────────────────────────────────────────────── */
|
|
.btn-seg{display:inline-flex;border:1px solid rgba(255,255,255,.10);border-radius:6px;overflow:hidden}
|
|
.btn-seg button{background:none;border:0;color:var(--tblr-secondary);cursor:pointer;
|
|
padding:.2rem .7rem;font-size:.73rem;font-weight:600;line-height:1.6}
|
|
.btn-seg button+button{border-left:1px solid rgba(255,255,255,.08)}
|
|
.btn-seg button:hover{background:rgba(255,255,255,.05);color:var(--tblr-body-color)}
|
|
.btn-seg button.active{background:rgba(59,130,246,.18);color:#93c5fd}
|
|
|
|
/* One tile per destination, identical anatomy every time. */
|
|
.home-grid{display:grid;grid-template-columns:repeat(4, minmax(0,1fr));gap:14px}
|
|
@media (max-width:980px){.home-grid{grid-template-columns:repeat(2, minmax(0,1fr))}}
|
|
@media (max-width:640px){.home-grid{grid-template-columns:1fr}}
|
|
.home-tile{display:flex;flex-direction:column;gap:.12rem;padding:.85rem .95rem;
|
|
border-radius:8px;text-decoration:none!important;color:inherit;
|
|
background:rgba(31,41,55,.55);border:1px solid rgba(255,255,255,.07);
|
|
backdrop-filter:blur(18px) saturate(140%);
|
|
box-shadow:0 10px 30px -14px rgba(0,0,0,.55), inset 0 1px 0 rgba(255,255,255,.04)}
|
|
.home-tile:hover{border-color:rgba(255,255,255,.18);background:rgba(31,41,55,.72)}
|
|
.tile-name{font-size:.62rem;text-transform:uppercase;letter-spacing:.07em;color:var(--tblr-secondary)}
|
|
.tile-val{font-size:1.5rem;font-weight:700;line-height:1.15;font-variant-numeric:tabular-nums}
|
|
.tile-sub{font-size:.7rem;color:var(--tblr-secondary);line-height:1.35}
|
|
.tile-spark{height:22px;margin-top:.3rem;width:100%}
|
|
.ok-fg{color:#4ade80}.warn-fg{color:#f87171}
|
|
/* Full height of the row, not a fixed box: the busiest-models list next to it
|
|
is what sets the card's height, and a short chart just leaves dead space
|
|
under itself. min-height covers the stacked (narrow) layout. */
|
|
.activity-chart{position:relative;width:100%;height:100%;min-height:230px}
|
|
.activity-chart canvas{width:100%!important;height:100%!important;display:block}
|
|
.busy-row{display:grid;grid-template-columns:1fr auto;gap:.1rem .6rem;padding:.3rem 0;
|
|
border-bottom:1px solid rgba(255,255,255,.05)}
|
|
.busy-row:last-child{border-bottom:0}
|
|
.busy-name{font-size:.78rem;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
.busy-prov{font-size:.64rem;text-transform:uppercase;letter-spacing:.05em;color:var(--tblr-secondary)}
|
|
.busy-num{font-size:.78rem;font-variant-numeric:tabular-nums;text-align:right}
|
|
.busy-sub{font-size:.66rem;color:var(--tblr-secondary);text-align:right}
|
|
.busy-bar{grid-column:1/-1;height:3px;border-radius:2px;background:rgba(255,255,255,.06)}
|
|
.busy-bar>span{display:block;height:100%;border-radius:2px;background:#93c5fd}
|
|
.home-go{font-size:.7rem;font-weight:600;color:var(--tblr-secondary);text-decoration:none}
|
|
.home-go:hover{color:#fbbf24}
|
|
|
|
/* Live feed. Each row carries why the decision went where it did -- tier,
|
|
context, classify time -- so the common question does not need the
|
|
Decisions page. Grouped by minute, because a burst reads as a burst. */
|
|
.live-wrap{display:flex;flex-wrap:wrap;gap:1rem;align-items:flex-start}
|
|
.live-main{flex:1 1 620px;min-width:0}
|
|
.live-side{flex:0 1 320px;min-width:290px;display:flex;flex-direction:column;gap:1rem}
|
|
.live-feed{display:flex;flex-direction:column;max-height:560px;overflow-y:auto;overscroll-behavior:contain}
|
|
.feed-min{font-size:.62rem;color:#6b7688;text-transform:uppercase;letter-spacing:.08em;
|
|
padding:.45rem .1rem .18rem;border-bottom:1px solid rgba(255,255,255,.06)}
|
|
.feed-min:first-child{padding-top:.1rem}
|
|
.feed-row{display:grid;grid-template-columns:60px 9px 112px 1fr 66px 26px 54px 20px 62px;
|
|
gap:.5rem;align-items:center;font-size:.75rem;padding:.24rem .1rem;white-space:nowrap;
|
|
border-bottom:1px solid rgba(255,255,255,.04)}
|
|
.feed-row:hover{background:rgba(255,255,255,.03)}
|
|
.feed-src{width:7px;height:7px;border-radius:50%;justify-self:center}
|
|
.feed-cat{padding:.02rem .42rem;border-radius:999px;font-size:.66rem;font-weight:600;
|
|
justify-self:start;overflow:hidden;text-overflow:ellipsis;max-width:100%;white-space:nowrap}
|
|
.feed-model{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
.feed-prov{color:var(--tblr-secondary);font-size:.68rem}
|
|
.feed-ctx,.feed-ms{text-align:right;font-variant-numeric:tabular-nums}
|
|
.feed-tier{font-size:.6rem;font-weight:700;color:var(--tblr-secondary);
|
|
border:1px solid rgba(255,255,255,.12);border-radius:3px;padding:0 .22rem;justify-self:center}
|
|
.feed-e{font-size:.58rem;font-weight:700;color:#c4b5fd;background:#c4b5fd22;
|
|
border-radius:3px;padding:0 .2rem;justify-self:center}
|
|
.feed-cost{text-align:right}
|
|
/* Rail meters. */
|
|
.meter{height:5px;border-radius:3px;background:rgba(255,255,255,.07);margin-top:.25rem}
|
|
.meter>span{display:block;height:100%;border-radius:3px}
|
|
.rate-strip{display:inline-flex;align-items:flex-end;gap:1px;height:26px}
|
|
.rate-strip>span{width:6px;border-radius:1px;background:#93c5fd}
|
|
.share-row{display:grid;grid-template-columns:1fr auto;gap:.05rem .5rem;padding:.26rem 0}
|
|
.share-row .bar{grid-column:1/-1;height:4px;border-radius:2px;background:rgba(255,255,255,.06)}
|
|
.share-row .bar>span{display:block;height:100%;border-radius:2px}
|
|
.rail-h{font-family:'Quicksand',var(--tblr-font-sans-serif);font-weight:700;font-size:.82rem}
|
|
.mini{display:flex;justify-content:space-between;font-size:.78rem;padding:.15rem 0}
|
|
|
|
/* Warnings popup. */
|
|
.warn-head{display:flex;align-items:baseline;gap:.5rem;padding:.5rem .6rem;
|
|
border-bottom:1px solid rgba(255,255,255,.07)}
|
|
.warn-title{font-family:'Quicksand',var(--tblr-font-sans-serif);font-weight:700;font-size:.85rem}
|
|
.warn-row{display:grid;grid-template-columns:8px 1fr auto;gap:.55rem;padding:.55rem .6rem;
|
|
border-bottom:1px solid rgba(255,255,255,.05)}
|
|
.warn-dot{width:7px;height:7px;border-radius:999px;margin-top:.34rem}
|
|
.warn-dot.high{background:#f87171}.warn-dot.medium{background:#fbbf24}
|
|
.warn-txt{font-size:.76rem;line-height:1.45}
|
|
.warn-link{font-size:.7rem;font-weight:600}
|
|
.warn-btn{background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.09);
|
|
color:var(--tblr-secondary);border-radius:5px;font-size:.63rem;font-weight:600;
|
|
padding:.12rem .42rem;cursor:pointer;white-space:nowrap;align-self:flex-start}
|
|
.warn-btn:hover{color:var(--tblr-body-color);background:rgba(255,255,255,.11)}
|
|
.warn-empty{padding:.9rem .6rem;font-size:.78rem;color:var(--tblr-secondary)}
|
|
.warn-foot{padding:.5rem .6rem;border-top:1px solid rgba(255,255,255,.07);
|
|
font-size:.67rem;line-height:1.5;color:var(--tblr-secondary)}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<!-- ═══ Navbar ═══ -->
|
|
<header class="navbar navbar-expand-md navbar-dark" data-bs-theme="dark">
|
|
<div class="container-fluid">
|
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbar-menu" aria-controls="navbar-menu" aria-expanded="false" aria-label="Toggle navigation"><span class="navbar-toggler-icon"></span></button>
|
|
<h1 class="navbar-brand navbar-brand-autodark d-none-navbar-horizontal pe-0 pe-md-3">
|
|
<a href="/admin/" style="display:inline-flex;align-items:center;gap:8px">
|
|
<span class="brand-speedlines d-none d-md-flex" aria-hidden="true">
|
|
<svg viewBox="0 0 40 64" width="40" height="64" xmlns="http://www.w3.org/2000/svg">
|
|
<defs>
|
|
<linearGradient id="speedfade" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="40" y2="0">
|
|
<stop offset="0" stop-color="#f59e0b" stop-opacity="0"/>
|
|
<stop offset="1" stop-color="#f59e0b" stop-opacity=".85"/>
|
|
</linearGradient>
|
|
</defs>
|
|
<g stroke="url(#speedfade)" stroke-linecap="round" fill="none">
|
|
<line x1="14" y1="20" x2="38" y2="20" stroke-width="5"/>
|
|
<line x1="2" y1="33" x2="36" y2="33" stroke-width="6"/>
|
|
<line x1="18" y1="46" x2="34" y2="46" stroke-width="4.5"/>
|
|
</g>
|
|
</svg>
|
|
</span>
|
|
<img class="navbar-brand-image" src="6krrt-logo.webp" width="64" height="64" style="width:64px;height:64px" alt="6krrt logo">
|
|
</a>
|
|
</h1>
|
|
<div class="navbar-nav flex-row order-md-last">
|
|
<div class="d-flex align-items-center gap-3">
|
|
<div class="dropdown" id="warnings-dropdown-wrap" hidden>
|
|
<button class="warn-bell" type="button" data-bs-toggle="dropdown" aria-expanded="false" title="Warnings">
|
|
<span data-icon="alert"></span>
|
|
<span class="warn-count-dot" id="warn-count">0</span>
|
|
</button>
|
|
<div class="dropdown-menu dropdown-menu-end p-2" id="warnings-menu" style="min-width:360px;margin-top:14px!important"></div>
|
|
</div>
|
|
<span id="nav-widgets" class="d-flex align-items-center"></span>
|
|
</div>
|
|
</div>
|
|
<div class="collapse navbar-collapse" id="navbar-menu">
|
|
<div class="d-flex flex-column flex-md-row flex-fill align-items-stretch align-items-md-center">
|
|
<ul class="navbar-nav">
|
|
<li class="nav-item"><a class="nav-link" href="/admin/models">Models</a></li>
|
|
<li class="nav-item"><a class="nav-link" href="/admin/profiles">Profiles</a></li>
|
|
<li class="nav-item"><a class="nav-link" href="/admin/proficiency">Proficiency</a></li>
|
|
<li class="nav-item"><a class="nav-link" href="/admin/decisions">Decisions</a></li>
|
|
<li class="nav-item"><a class="nav-link" href="/admin/quota">Quota</a></li>
|
|
<li class="nav-item"><a class="nav-link" href="/admin/controls">Controls</a></li>
|
|
<li class="nav-item"><a class="nav-link" href="/admin/providers">Providers</a></li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<!-- ═══ Page shell ═══ -->
|
|
<div class="page">
|
|
<div class="page-wrapper">
|
|
<div class="page-header d-print-none">
|
|
<div class="container-xl">
|
|
<div class="row g-2 align-items-center">
|
|
<div class="col">
|
|
<h2 class="page-title">Home</h2>
|
|
<div class="text-muted mt-1" id="home-subtitle">Everything at a glance, with the detail one click away</div>
|
|
</div>
|
|
<div class="col-auto">
|
|
<div class="btn-seg" id="home-views">
|
|
<button type="button" data-view="board" class="active">Board</button>
|
|
<button type="button" data-view="live">Live</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="page-body">
|
|
<div class="container-xl">
|
|
|
|
<!-- BOARD: one tile per destination, identical anatomy every time.
|
|
A tile says what it is, one number, one qualifier, and links to
|
|
the page that owns the detail. -->
|
|
<div id="view-board">
|
|
<div class="home-grid" id="board-tiles">
|
|
<div class="text-muted small">Loading…</div>
|
|
</div>
|
|
|
|
<!-- One full-width band under the tiles. The tiles answer "how much";
|
|
this answers "when, and to whom" -- the two questions a landing
|
|
page can settle without sending you to another page. -->
|
|
<div class="card mt-3">
|
|
<div class="card-header" style="padding:.55rem .8rem">
|
|
<h3 class="card-title mb-0" style="font-size:.9rem">Activity</h3>
|
|
<span class="card-subtitle text-muted ms-2" style="font-size:.72rem">routing volume and where it landed</span>
|
|
<div class="btn-seg ms-auto" id="activity-range">
|
|
<button type="button" data-range="24h">24h</button>
|
|
<button type="button" data-range="7d" class="active">7d</button>
|
|
<button type="button" data-range="30d">30d</button>
|
|
</div>
|
|
</div>
|
|
<div class="card-body" style="padding:.8rem">
|
|
<div class="row g-3">
|
|
<div class="col-lg-8 d-flex">
|
|
<div class="activity-chart"><canvas id="activity-canvas"></canvas></div>
|
|
</div>
|
|
<div class="col-lg-4">
|
|
<div class="d-flex align-items-baseline mb-1">
|
|
<span class="rail-h">Busiest models</span>
|
|
<a href="/admin/models" class="ms-auto home-go">Models →</a>
|
|
</div>
|
|
<div id="busy-models"><div class="text-muted small">Loading…</div></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- LIVE: the router working, as the page's subject. -->
|
|
<div id="view-live" hidden>
|
|
<div class="live-wrap">
|
|
<div class="live-main">
|
|
<div class="card">
|
|
<div class="card-header" style="padding:.55rem .8rem">
|
|
<h3 class="card-title mb-0" style="font-size:.9rem">Live routing</h3>
|
|
<span class="card-subtitle text-muted ms-2" id="live-count">last 40 decisions</span>
|
|
<a href="/admin/decisions" class="ms-auto home-go">all decisions →</a>
|
|
</div>
|
|
<div class="card-body" style="padding:.35rem .8rem .6rem">
|
|
<div class="live-feed" id="live-feed">
|
|
<div class="text-muted small">Waiting for decisions…</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="live-side" id="live-rail">
|
|
<div class="text-muted small">Loading…</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div><!-- /container-xl -->
|
|
</div><!-- /page-body -->
|
|
<footer class="footer footer-transparent d-print-none">
|
|
<div class="container-xl">
|
|
<span class="footer-bits"><span>© 2026 adLee</span><span>6krrt, local LLM model router</span><span>admin</span></span>
|
|
</div>
|
|
</footer>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Toast (Bootstrap native) -->
|
|
<!-- Per-model detail. Same shape as proficiency.html's #cell-modal. -->
|
|
<div class="modal modal-blur fade" id="model-modal" tabindex="-1" role="dialog" aria-hidden="true">
|
|
<div class="modal-dialog modal-dialog-centered" role="document">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title" id="model-modal-title">—</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
</div>
|
|
<div class="modal-body" id="model-modal-body"></div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-outline-secondary btn-sm" data-bs-dismiss="modal">Close</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="toast" class="toast align-items-center text-bg-info border-0 position-fixed bottom-0 end-0 p-3" role="alert" aria-live="assertive" aria-atomic="true"></div>
|
|
|
|
<script>
|
|
/* ═══════════════════════════════════════════════════
|
|
admin/frontend/index.html — single-file dashboard
|
|
API base: relative (works under /admin/)
|
|
═══════════════════════════════════════════════════ */
|
|
|
|
const API = ''; // relative to /admin/
|
|
const SSE_URL = '/events/decisions'; // root-level endpoint
|
|
const REFRESH_MS = 30000;
|
|
let decisionCount = 0;
|
|
|
|
/* ═─ Inline icon helper (no tabler-icons webfont) ═─ */
|
|
function icon(name, size) {
|
|
const s = size || 18;
|
|
const svgs = {
|
|
energy: `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg>`,
|
|
cloud: `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.5 19c0-2.2-1.8-4-4-4h-1c-2.2 0-4 1.8-4 4H6c-3.3 0-6-2.7-6-6 0-3.1 2.4-5.7 5.4-6 0.4-3.1 3.1-5.5 6.3-5.5 3.1 0 5.8 2.3 6.3 5.4C21.6 7.3 24 9.9 24 13c0 3.3-2.7 6-6 6h-0.5z"/></svg>`,
|
|
list: `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>`,
|
|
'bar-chart':`<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="20" x2="12" y2="10"/><line x1="18" y1="20" x2="18" y2="4"/><line x1="6" y1="20" x2="6" y2="16"/></svg>`,
|
|
'checkup': `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>`,
|
|
category: `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>`,
|
|
alert: `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>`,
|
|
history: `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><polyline points="23 20 23 14 17 14"/><path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/></svg>`,
|
|
default: `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="1"/></svg>`,
|
|
};
|
|
return svgs[name] || svgs.default;
|
|
}
|
|
|
|
/* Fill [data-icon] placeholders in static card headers with real SVG icons. */
|
|
function renderStaticIcons() {
|
|
document.querySelectorAll('[data-icon]').forEach(el => {
|
|
el.innerHTML = icon(el.dataset.icon);
|
|
});
|
|
}
|
|
|
|
/* ── Toast (Bootstrap native via tabler wrapper) ── */
|
|
let _toastEl = null;
|
|
function toast(msg, type = 'info') {
|
|
const bgClass = { success: 'text-bg-success', error: 'text-bg-danger', info: 'text-bg-info' }[type] || 'text-bg-info';
|
|
if (!_toastEl) {
|
|
_toastEl = document.getElementById('toast');
|
|
_toastEl.innerHTML = `<div class="d-flex">
|
|
<div class="toast-body"></div>
|
|
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
|
|
</div>`;
|
|
_toastEl._bt = new tabler.Toast(_toastEl, { delay: 4000 });
|
|
}
|
|
_toastEl.querySelector('.toast-body').textContent = msg;
|
|
_toastEl.className = `toast align-items-center ${bgClass} border-0 position-fixed bottom-0 end-0 p-3`;
|
|
_toastEl._bt.show();
|
|
}
|
|
|
|
/* ── Fetch Wrapper ── */
|
|
async function apiFetch(url, opts = {}) {
|
|
try {
|
|
const resp = await fetch(url, opts);
|
|
if (!resp.ok) throw new Error(`${resp.status} ${resp.statusText}`);
|
|
return await resp.json();
|
|
} catch (e) {
|
|
console.warn('API call failed:', url, e);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/* ═══════════════════════════════════════
|
|
DATA FETCHING
|
|
═══════════════════════════════════════ */
|
|
|
|
/* ══════════════════════════════════════════════════════════════════════════
|
|
HOME
|
|
Two views over one /api/snapshot payload, so switching costs no request.
|
|
Board answers "what is the state of things" and covers every destination in
|
|
the nav; Live answers "what is it doing right now".
|
|
══════════════════════════════════════════════════════════════════════════ */
|
|
|
|
let _snap = null;
|
|
let _homeView = 'board';
|
|
|
|
function num(n) { return Number(n || 0).toLocaleString(); }
|
|
function usd(v) { return v == null ? '—' : '$' + Number(v).toFixed(2); }
|
|
|
|
/* A sparkline from a history series, drawn inline rather than with Chart.js:
|
|
eight of these on one page is eight canvases and eight animation loops for
|
|
a shape that never needs a tooltip. */
|
|
function spark(points, color) {
|
|
if (!points || points.length < 2) return '';
|
|
const ys = points.map(p => p[1]);
|
|
const lo = Math.min(...ys), hi = Math.max(...ys), span = (hi - lo) || 1;
|
|
const d = points.map((p, i) =>
|
|
`${(i / (points.length - 1) * 100).toFixed(1)},${(20 - (p[1] - lo) / span * 18).toFixed(1)}`
|
|
).join(' ');
|
|
return `<svg class="tile-spark" viewBox="0 0 100 22" preserveAspectRatio="none" aria-hidden="true">
|
|
<polyline fill="none" stroke="${color}" stroke-width="1.5" points="${d}"/></svg>`;
|
|
}
|
|
|
|
function tile(href, name, value, sub, sparkSvg) {
|
|
return `<a class="home-tile" href="${href}">
|
|
<span class="tile-name">${name}</span>
|
|
<span class="tile-val">${value}</span>
|
|
<span class="tile-sub">${sub}</span>
|
|
${sparkSvg || ''}
|
|
</a>`;
|
|
}
|
|
|
|
function renderBoard() {
|
|
const el = document.getElementById('board-tiles');
|
|
if (!el || !_snap) return;
|
|
const q = _snap.quota || {};
|
|
const spend = q.spend || {};
|
|
const alarm = q.alarm || {};
|
|
const vm = _snap.verdict_mix || {};
|
|
const pm = (_snap.per_model || []).filter(m => m.provider !== 'ollama-local');
|
|
const pinch = _snap.pinch || {};
|
|
const prof = _snap.top_proficiency || [];
|
|
const accounts = (q.accounts || []).length;
|
|
const hist = _homeHistory || {};
|
|
|
|
const good = (vm.ok || 0) + (vm.succeeded || 0);
|
|
const bad = (vm.failed || 0) + (vm.malformed || 0);
|
|
const decisions = Object.values(vm).reduce((a, b) => a + b, 0);
|
|
const topModel = pm.slice().sort((a, b) => (b.calls || 0) - (a.calls || 0))[0];
|
|
const paceNote = alarm.kind === 'plan_pace'
|
|
? `<span class="warn-fg">${escapeHtml(alarm.headline || '')}</span>`
|
|
: 'no plan pressure';
|
|
|
|
el.innerHTML = [
|
|
tile('/admin/quota', 'Quota', usd(spend.total_usd),
|
|
`spent this period<br>${paceNote}`,
|
|
spark(hist.cost_per_bucket, '#fbbf24')),
|
|
tile('/admin/decisions', 'Decisions', num(decisions),
|
|
`verified in 7 days<br><span class="ok-fg">${num(good)} ok</span>, ${num(bad)} failed`,
|
|
spark(hist.decisions_per_bucket, '#93c5fd')),
|
|
tile('/admin/models', 'Models', num(pm.length),
|
|
topModel
|
|
? `with traffic in 30 days<br>${escapeHtml(topModel.model_id)} leads`
|
|
: 'with traffic in 30 days',
|
|
spark(hist.requests_per_bucket, '#4ade80')),
|
|
tile('/admin/proficiency', 'Proficiency', num(prof.length),
|
|
'scored model-category pairs<br>drives category ranking'),
|
|
tile('/admin/providers', 'Providers', num(accounts),
|
|
'accounts configured<br>' + (q.accounts || [])
|
|
.map(a => escapeHtml((a.shape || '').replace('_', ' '))).join(', ')),
|
|
tile('/admin/profiles', 'Profiles', '—',
|
|
'routing profiles<br><span id="board-profile">serving auto</span>'),
|
|
tile('/admin/quota', 'Pinch', ((pinch.share_pruned || 0) * 100).toFixed(0) + '%',
|
|
`of prompts trimmed<br>${usd(pinch.dollars_saved_usd_30d)} saved in 30 days`),
|
|
tile('/admin/controls', 'Controls', '<span id="board-knobs">—</span>',
|
|
'runtime toggles<br><span id="board-knobs-sub">gaming mode, breaker, pinch</span>'),
|
|
].join('');
|
|
|
|
// How many runtime knobs there are, and how many are off their persisted
|
|
// value -- "3 changed" is the fact worth surfacing, because a runtime
|
|
// override silently reverts on the next restart.
|
|
fetch(API + 'api/runtime').then(r => r.ok ? r.json() : null).then(d => {
|
|
if (!d) return;
|
|
const keys = Object.keys(d);
|
|
const changed = keys.filter(k => d[k] && d[k].runtime !== d[k].persisted);
|
|
const v = document.getElementById('board-knobs');
|
|
const sub = document.getElementById('board-knobs-sub');
|
|
if (v) v.textContent = keys.length;
|
|
if (sub) {
|
|
sub.innerHTML = changed.length
|
|
? `<span class="warn-fg">${changed.length} overridden</span>, reverts on restart`
|
|
: 'all at their persisted values';
|
|
}
|
|
}).catch(() => {});
|
|
|
|
renderBusyModels();
|
|
|
|
// The profile count and active name come from the switcher's own endpoint,
|
|
// which the navbar already fetched; ask again rather than duplicate state.
|
|
fetch(API + 'api/active-profile').then(r => r.ok ? r.json() : null).then(d => {
|
|
if (!d) return;
|
|
const t = el.querySelectorAll('.home-tile')[5];
|
|
if (!t) return;
|
|
t.querySelector('.tile-val').textContent = (d.available || []).length;
|
|
const p = document.getElementById('board-profile');
|
|
if (p) p.innerHTML = `<strong>${escapeHtml(d.active)}</strong> is serving auto`;
|
|
}).catch(() => {});
|
|
}
|
|
|
|
/* ── Activity band ─────────────────────────────────────────────────────────
|
|
*
|
|
* Two count series on one axis, so the gap between them is readable: every
|
|
* routed decision against every request the provider actually billed for. A
|
|
* decision without a billed request is a local dispatch, a cache hit, or a
|
|
* request that never reached a provider -- worth seeing, and invisible in
|
|
* either series alone.
|
|
*
|
|
* Its own range, independent of the tile sparklines: this is the chart you
|
|
* come to the page to read, and 24h vs 30d is the question it answers.
|
|
*/
|
|
let _activityChart = null;
|
|
let _activityRange = '7d';
|
|
let _activityHistory = null;
|
|
|
|
function renderActivity() {
|
|
const cv = document.getElementById('activity-canvas');
|
|
if (!cv || !_activityHistory || typeof Chart === 'undefined') return;
|
|
const h = _activityHistory;
|
|
const pts = (series) => (series || []).map(([t, v]) => ({ x: t * 1000, y: v }));
|
|
|
|
const data = {
|
|
datasets: [
|
|
{ label: 'decisions', data: pts(h.decisions_per_bucket),
|
|
borderColor: '#93c5fd', backgroundColor: 'rgba(147,197,253,.12)',
|
|
borderWidth: 1.6, pointRadius: 0, pointHoverRadius: 3, tension: .25, fill: true },
|
|
{ label: 'billed requests', data: pts(h.requests_per_bucket),
|
|
borderColor: '#fbbf24', borderWidth: 1.6, borderDash: [5, 3],
|
|
pointRadius: 0, pointHoverRadius: 3, tension: .25, fill: false },
|
|
],
|
|
};
|
|
|
|
if (_activityChart) {
|
|
_activityChart.data = data;
|
|
_activityChart.update('none');
|
|
return;
|
|
}
|
|
_activityChart = new Chart(cv.getContext('2d'), {
|
|
type: 'line',
|
|
data,
|
|
options: {
|
|
responsive: true, maintainAspectRatio: false,
|
|
interaction: { mode: 'index', intersect: false },
|
|
plugins: {
|
|
legend: { display: true, position: 'bottom',
|
|
labels: { boxWidth: 10, boxHeight: 2, font: { size: 10 },
|
|
color: 'rgba(255,255,255,.6)' } },
|
|
tooltip: { callbacks: {
|
|
title: (items) => new Date(items[0].parsed.x).toLocaleString(),
|
|
label: (i) => `${i.dataset.label}: ${Number(i.parsed.y).toLocaleString()}`,
|
|
} },
|
|
},
|
|
scales: {
|
|
x: { type: 'time', grid: { color: 'rgba(255,255,255,.04)' },
|
|
ticks: { color: 'rgba(255,255,255,.45)', font: { size: 10 }, maxRotation: 0 } },
|
|
y: { beginAtZero: true, grid: { color: 'rgba(255,255,255,.04)' },
|
|
ticks: { color: 'rgba(255,255,255,.45)', font: { size: 10 }, precision: 0 } },
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
let _liveHistory = null;
|
|
|
|
// The rail's throughput strip. Its own range (1h/5min) because "is it busy
|
|
// right now" is a different question from the Board band's 7-day shape.
|
|
async function loadLiveHistory() {
|
|
const d = await apiFetch(`${API}api/history?range=1h`);
|
|
if (d) { _liveHistory = d; if (_homeView === 'live') renderLive(); }
|
|
}
|
|
|
|
async function loadActivity() {
|
|
const d = await apiFetch(`${API}api/history?range=${_activityRange}`);
|
|
if (d) { _activityHistory = d; renderActivity(); }
|
|
}
|
|
|
|
function bindActivityRange() {
|
|
const seg = document.getElementById('activity-range');
|
|
if (!seg) return;
|
|
seg.addEventListener('click', (ev) => {
|
|
const b = ev.target.closest('button[data-range]');
|
|
if (!b) return;
|
|
_activityRange = b.dataset.range;
|
|
seg.querySelectorAll('button').forEach(x => x.classList.toggle('active', x === b));
|
|
loadActivity();
|
|
});
|
|
}
|
|
|
|
/* Ranked by calls, not cost. Cost coverage is partial -- an OpenRouter row can
|
|
carry hundreds of calls and no price at all -- so ranking on it puts a
|
|
two-call model with one measured cent above a model doing all the work. */
|
|
function renderBusyModels() {
|
|
const el = document.getElementById('busy-models');
|
|
if (!el || !_snap) return;
|
|
const rows = (_snap.per_model || [])
|
|
.filter(m => m.provider !== 'ollama-local')
|
|
.slice().sort((a, b) => (b.calls || 0) - (a.calls || 0))
|
|
.slice(0, 8);
|
|
if (!rows.length) { el.innerHTML = '<div class="text-muted small">No traffic yet</div>'; return; }
|
|
const top = rows[0].calls || 1;
|
|
el.innerHTML = rows.map(m => {
|
|
const perCall = m.sum_cost_usd != null && m.cost_rows
|
|
? '$' + (m.sum_cost_usd / m.cost_rows).toFixed(4) + ' each'
|
|
: 'no price measured';
|
|
return `<div class="busy-row">
|
|
<div><span class="busy-name">${escapeHtml(m.model_id)}</span>
|
|
<div class="busy-prov">${escapeHtml(m.provider || '')}</div></div>
|
|
<div><div class="busy-num">${Number(m.calls || 0).toLocaleString()}</div>
|
|
<div class="busy-sub">${perCall}</div></div>
|
|
<div class="busy-bar"><span style="width:${((m.calls || 0) / top * 100).toFixed(1)}%"></span></div>
|
|
</div>`;
|
|
}).join('');
|
|
}
|
|
|
|
function pct(n, d) { return d ? (n / d * 100) : 0; }
|
|
|
|
function quantile(sorted, q) {
|
|
if (!sorted.length) return null;
|
|
const i = Math.min(sorted.length - 1, Math.floor(q * sorted.length));
|
|
return sorted[i];
|
|
}
|
|
|
|
/* The rail reads the same window the feed shows, so a number here is always
|
|
explainable by a row you can see. Nothing is restated from Board. */
|
|
function renderLive() {
|
|
const rail = document.getElementById('live-rail');
|
|
if (!rail || !_snap) return;
|
|
const rows = _snap.recent_decisions || [];
|
|
const vm = _snap.verdict_mix || {};
|
|
|
|
// Throughput over the window the snapshot returned, not a guess.
|
|
let perMin = null;
|
|
if (rows.length > 1) {
|
|
const ts = rows.map(r => Date.parse(r.observed_at)).filter(n => !isNaN(n));
|
|
const span = (Math.max(...ts) - Math.min(...ts)) / 60000;
|
|
if (span > 0) perMin = rows.length / span;
|
|
}
|
|
// A quiet router reads as "0.0 per minute" on a per-minute figure, which
|
|
// looks broken rather than quiet. Switch units instead of losing the number.
|
|
const unit = (v) => `<span class="text-muted" style="font-size:.7rem;font-weight:400"> ${v}</span>`;
|
|
const rate = perMin == null ? '—'
|
|
: perMin >= 1 ? perMin.toFixed(1) + unit('per minute')
|
|
: perMin * 60 >= 1 ? (perMin * 60).toFixed(1) + unit('per hour')
|
|
: (perMin * 1440).toFixed(1) + unit('per day');
|
|
|
|
const strip = (_liveHistory && _liveHistory.decisions_per_bucket) || [];
|
|
const stripTop = Math.max(1, ...strip.map(b => b[1]));
|
|
const stripHtml = strip.length
|
|
? `<div class="rate-strip">${strip.map(b =>
|
|
`<span style="height:${Math.max(2, Math.round(b[1] / stripTop * 26))}px"
|
|
title="${new Date(b[0] * 1000).toLocaleTimeString()}: ${b[1]}"></span>`).join('')}</div>`
|
|
: '<div class="text-muted small">no history yet</div>';
|
|
|
|
// Classifier time is the router's own latency floor, and the only
|
|
// per-decision timing the decision row actually carries.
|
|
const msAll = rows.map(r => r.classifier_ms).filter(v => v != null).sort((a, b) => a - b);
|
|
const p50 = quantile(msAll, 0.5), p95 = quantile(msAll, 0.95);
|
|
const fmtMs = (v) => v == null ? '—' : (v >= 1000 ? (v / 1000).toFixed(1) + 's' : v + 'ms');
|
|
|
|
const degraded = rows.filter(r => DEGRADED_SOURCES.has(r.classification_source)).length;
|
|
|
|
const good = (vm.ok || 0) + (vm.succeeded || 0);
|
|
const bad = (vm.failed || 0) + (vm.malformed || 0) + (vm.truncated || 0);
|
|
const failRate = pct(bad, good + bad);
|
|
|
|
// Top 5 by share of the live window, keyed on model AND provider: the same
|
|
// model served by two providers is two different routing outcomes.
|
|
const byModel = new Map();
|
|
rows.forEach(r => {
|
|
if (!r.selected_model) return;
|
|
const k = r.selected_model + '\u0000' + (r.selected_provider || '');
|
|
byModel.set(k, (byModel.get(k) || 0) + 1);
|
|
});
|
|
const top = [...byModel.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5);
|
|
const topMax = top.length ? top[0][1] : 1;
|
|
const topHtml = top.length
|
|
? top.map(([k, n]) => {
|
|
const [m, pv] = k.split('\u0000');
|
|
const share = pct(n, rows.length);
|
|
return `<div class="share-row">
|
|
<div><span style="font-size:.76rem;font-weight:600">${escapeHtml(m)}</span>
|
|
<div class="text-muted" style="font-size:.64rem;text-transform:uppercase;letter-spacing:.05em">${escapeHtml(pv)}</div></div>
|
|
<div class="num" style="font-size:.78rem;text-align:right">${share.toFixed(0)}%
|
|
<div class="text-muted" style="font-size:.64rem">${n}</div></div>
|
|
<div class="bar"><span style="width:${pct(n, topMax).toFixed(1)}%;background:${categoryColor(m)}"></span></div>
|
|
</div>`;
|
|
}).join('')
|
|
: '<div class="text-muted small">No decisions yet</div>';
|
|
|
|
rail.innerHTML = `
|
|
<div class="card"><div class="card-body p-3">
|
|
<div class="d-flex align-items-baseline mb-1">
|
|
<span class="rail-h">Throughput</span>
|
|
<a href="/admin/decisions" class="ms-auto home-go">Decisions →</a>
|
|
</div>
|
|
<div style="font-size:1.4rem;font-weight:700;line-height:1" class="num">${rate}</div>
|
|
<div class="mt-2">${stripHtml}</div>
|
|
${strip.length ? '<div class="text-muted" style="font-size:.68rem;margin-top:.25rem">last hour, 5 minute buckets</div>' : ''}
|
|
</div></div>
|
|
|
|
<div class="card"><div class="card-body p-3">
|
|
<div class="rail-h mb-1">Classify time</div>
|
|
<div class="mini"><span class="text-muted">p50</span><span class="num">${fmtMs(p50)}</span></div>
|
|
<div class="meter"><span style="width:${Math.min(100, (p50 || 0) / 60).toFixed(0)}%;background:#4ade80"></span></div>
|
|
<div class="mini mt-2"><span class="text-muted">p95</span><span class="num">${fmtMs(p95)}</span></div>
|
|
<div class="meter"><span style="width:${Math.min(100, (p95 || 0) / 60).toFixed(0)}%;background:#fbbf24"></span></div>
|
|
<div class="text-muted" style="font-size:.68rem;margin-top:.45rem">every routed request pays this before an upstream token</div>
|
|
</div></div>
|
|
|
|
<div class="card"><div class="card-body p-3">
|
|
<div class="rail-h mb-1">Signal quality</div>
|
|
<div class="mini"><span class="text-muted">degraded classifications</span>
|
|
<span class="num ${degraded ? 'warn-fg' : 'ok-fg'}">${degraded} of ${rows.length}</span></div>
|
|
<div class="mini"><span class="text-muted">failing, last 7 days</span>
|
|
<span class="num ${failRate > 10 ? 'warn-fg' : 'ok-fg'}">${failRate.toFixed(1)}%</span></div>
|
|
<div class="text-muted" style="font-size:.68rem;margin-top:.35rem">a degraded row still routes, but never moves proficiency</div>
|
|
</div></div>
|
|
|
|
<div class="card"><div class="card-body p-3">
|
|
<div class="d-flex align-items-baseline mb-1">
|
|
<span class="rail-h">Top 5 models</span>
|
|
<a href="/admin/models" class="ms-auto home-go">Models →</a>
|
|
</div>
|
|
${topHtml}
|
|
<div class="text-muted" style="font-size:.68rem;margin-top:.35rem">share of the last ${rows.length} decisions</div>
|
|
</div></div>`;
|
|
}
|
|
|
|
/* The feed. Seeded from the snapshot and then appended to by SSE, so it is
|
|
populated on arrival rather than empty until the first decision lands. */
|
|
/* Tint a category chip with the shared per-category colour, so a category
|
|
reads the same here as it does anywhere else in the portal. */
|
|
function catChipStyle(cat) {
|
|
if (!cat) return '';
|
|
const c = categoryColor(cat);
|
|
return `color:${c};background:${c}22`;
|
|
}
|
|
|
|
/* Which classification the row was routed on. A degraded source still routes,
|
|
but it is excluded from proficiency attribution, so it is worth seeing at a
|
|
glance rather than only in the metrics warning. */
|
|
const DEGRADED_SOURCES = new Set(['fallback', 'session_stale', 'session_history']);
|
|
|
|
function sourceDot(src) {
|
|
const bad = DEGRADED_SOURCES.has(src);
|
|
const c = bad ? '#fbbf24' : '#4ade80';
|
|
const t = bad ? `classified from ${src} (degraded)` : `classified by ${src || 'classifier'}`;
|
|
return `<span class="feed-src" style="background:${c}" title="${escapeHtml(t)}"></span>`;
|
|
}
|
|
|
|
function fmtCtx(n) {
|
|
if (n == null) return '';
|
|
if (n >= 1000) return Math.round(n / 1000) + 'k';
|
|
return String(n);
|
|
}
|
|
|
|
function feedRow(d) {
|
|
const when = d.observed_at ? formatTs(d.observed_at) : '';
|
|
const model = d.selected_model || '—';
|
|
const prov = d.selected_provider || '';
|
|
const cost = d.est_cost_usd != null ? '$' + Number(d.est_cost_usd).toFixed(4) : '—';
|
|
const ms = d.classifier_ms != null
|
|
? (d.classifier_ms >= 1000 ? (d.classifier_ms / 1000).toFixed(1) + 's' : d.classifier_ms + 'ms')
|
|
: '';
|
|
return `<div class="feed-row">
|
|
<span class="text-muted num">${escapeHtml(when)}</span>
|
|
${sourceDot(d.classification_source)}
|
|
<span class="feed-cat" style="${catChipStyle(d.task_category)}">${escapeHtml(d.task_category || 'unclassified')}</span>
|
|
<span class="feed-model" title="${escapeHtml(model)}">${escapeHtml(model)}
|
|
<span class="feed-prov">${escapeHtml(prov)}</span></span>
|
|
<span class="text-muted feed-ctx" title="required context">${fmtCtx(d.required_context_tokens)}</span>
|
|
<span class="feed-tier" title="task tier">${d.task_tier != null ? 'T' + d.task_tier : ''}</span>
|
|
<span class="text-muted feed-ms" title="classifier time">${ms}</span>
|
|
<span>${d.exploration ? '<span class="feed-e" title="exploratory pick">E</span>' : ''}</span>
|
|
<span class="text-muted num feed-cost">${cost}</span>
|
|
</div>`;
|
|
}
|
|
|
|
/* Held as an array rather than appended to the DOM: minute headers depend on
|
|
the row before, so a single re-render is simpler than patching them in. */
|
|
let _feedRows = [];
|
|
|
|
function paintFeed() {
|
|
const el = document.getElementById('live-feed');
|
|
if (!el) return;
|
|
if (!_feedRows.length) {
|
|
el.innerHTML = '<div class="text-muted small">No decisions yet</div>';
|
|
} else {
|
|
let lastMin = null;
|
|
el.innerHTML = _feedRows.map(d => {
|
|
const stamp = d.observed_at ? formatTs(d.observed_at) : '';
|
|
// Drop the seconds, keep everything after them: "06:32:06 PM" -> "06:32 PM".
|
|
// Slicing to 5 chars instead would merge 01:45 AM into 01:45 PM.
|
|
const minute = stamp.replace(/^(\d{1,2}:\d{2}):\d{2}/, '$1');
|
|
const head = minute && minute !== lastMin
|
|
? `<div class="feed-min">${escapeHtml(minute)}</div>` : '';
|
|
lastMin = minute || lastMin;
|
|
return head + feedRow(d);
|
|
}).join('');
|
|
}
|
|
const c = document.getElementById('live-count');
|
|
if (c) c.textContent = `last ${_feedRows.length} decisions`;
|
|
}
|
|
|
|
function renderFeed(decisions) {
|
|
_feedRows = (decisions || []).slice(0, 40);
|
|
paintFeed();
|
|
}
|
|
|
|
function pushFeed(d) {
|
|
_feedRows.unshift(d);
|
|
if (_feedRows.length > 40) _feedRows.length = 40;
|
|
if (_homeView === 'live') paintFeed();
|
|
}
|
|
|
|
function setHomeView(view) {
|
|
_homeView = view === 'live' ? 'live' : 'board';
|
|
const board = document.getElementById('view-board');
|
|
const live = document.getElementById('view-live');
|
|
if (board) board.hidden = _homeView !== 'board';
|
|
if (live) live.hidden = _homeView !== 'live';
|
|
try { localStorage.setItem('6krrt.homeView', _homeView); } catch (_) { /* private window */ }
|
|
if (_homeView === 'board') renderBoard(); else { renderLive(); renderFeed(_snap && _snap.recent_decisions); }
|
|
}
|
|
|
|
/* Which view you left the page on is a reading preference, not a fact about
|
|
the router, so it lives per browser rather than in the config. */
|
|
function bindHomeViews() {
|
|
const seg = document.getElementById('home-views');
|
|
if (!seg) return;
|
|
let saved = 'board';
|
|
try { saved = localStorage.getItem('6krrt.homeView') || 'board'; } catch (_) { /* private window */ }
|
|
seg.addEventListener('click', (ev) => {
|
|
const b = ev.target.closest('button[data-view]');
|
|
if (!b) return;
|
|
seg.querySelectorAll('button').forEach(x => x.classList.toggle('active', x === b));
|
|
setHomeView(b.dataset.view);
|
|
});
|
|
const initial = seg.querySelector(`button[data-view="${saved}"]`) || seg.firstElementChild;
|
|
seg.querySelectorAll('button').forEach(x => x.classList.toggle('active', x === initial));
|
|
setHomeView(initial ? initial.dataset.view : 'board');
|
|
}
|
|
|
|
/* ── Alerts ────────────────────────────────────────────────────────────────
|
|
*
|
|
* Warnings live behind the bell, not on the page. A derived warning does not
|
|
* earn permanent real estate, and the old panel pushed real content below the
|
|
* fold to say the same three lines every load.
|
|
*
|
|
* DISMISS CANNOT MEAN DELETE. These are recomputed from live state on every
|
|
* poll, so the only honest dismissal is "hide this until the condition
|
|
* changes". A dismissal is keyed on the warning's TEXT: when the underlying
|
|
* numbers move the text moves with them, the key stops matching, and the
|
|
* warning comes back on its own. Nothing has to expire it.
|
|
*
|
|
* Per browser, in localStorage, which is the right scope for "I have seen
|
|
* this" -- it is a reading state, not a fact about the router.
|
|
*/
|
|
const DISMISS_KEY = '6krrt.dismissedWarnings';
|
|
|
|
function loadDismissed() {
|
|
try {
|
|
const set = new Set(JSON.parse(localStorage.getItem(DISMISS_KEY) || '[]'));
|
|
// One-time carry-over from the previous, index-keyed implementation.
|
|
// Changing the storage key without this would silently resurface every
|
|
// warning the operator had already dismissed.
|
|
const old = localStorage.getItem('dismissedWarnings');
|
|
if (old) {
|
|
JSON.parse(old).forEach(w => set.add(w));
|
|
localStorage.removeItem('dismissedWarnings');
|
|
localStorage.setItem(DISMISS_KEY, JSON.stringify([...set]));
|
|
}
|
|
return set;
|
|
} catch (_) { return new Set(); } // private window, or corrupt value
|
|
}
|
|
function saveDismissed(set) {
|
|
try { localStorage.setItem(DISMISS_KEY, JSON.stringify([...set])); } catch (_) { /* ignore */ }
|
|
}
|
|
|
|
/* Which page fixes this warning. Matched on the phrases the warnings actually
|
|
use; an unrecognised warning simply gets no link rather than a wrong one. */
|
|
function warningTarget(text) {
|
|
if (/plan pace|kWh plan|balance|overage/i.test(text)) return ['Quota', '/admin/quota'];
|
|
if (/proficiency/i.test(text)) return ['Proficiency', '/admin/proficiency'];
|
|
if (/reference-workload|energy|catalog|model/i.test(text)) return ['Models', '/admin/models'];
|
|
if (/classifier|degraded/i.test(text)) return ['Controls', '/admin/controls'];
|
|
return [null, null];
|
|
}
|
|
|
|
function warningSeverity(text) {
|
|
if (/\d+(\.\d+)?x (sustainable |plan )?pace|0 eligible|cannot|refus/i.test(text)) return 'high';
|
|
return 'medium';
|
|
}
|
|
|
|
let _allWarnings = [];
|
|
|
|
function renderWarnings(warnings) {
|
|
_allWarnings = warnings || [];
|
|
const dismissed = loadDismissed();
|
|
|
|
// Anything dismissed whose text no longer appears has been resolved; drop
|
|
// the key so the store cannot grow forever with stale entries.
|
|
const live = new Set(_allWarnings);
|
|
let pruned = false;
|
|
for (const k of [...dismissed]) {
|
|
if (!live.has(k)) { dismissed.delete(k); pruned = true; }
|
|
}
|
|
if (pruned) saveDismissed(dismissed);
|
|
|
|
const open = _allWarnings.filter(w => !dismissed.has(w));
|
|
const bell = document.getElementById('warnings-dropdown-wrap');
|
|
const count = document.getElementById('warn-count');
|
|
const menu = document.getElementById('warnings-menu');
|
|
if (!menu) return;
|
|
|
|
// The bell is always present now: it is the only route to warnings, and to
|
|
// the dismissed ones. Only the count dot comes and goes.
|
|
if (bell) bell.hidden = false;
|
|
if (count) {
|
|
count.textContent = open.length;
|
|
count.hidden = open.length === 0;
|
|
}
|
|
|
|
const body = open.length
|
|
? open.map(w => {
|
|
const [name, href] = warningTarget(w);
|
|
const sev = warningSeverity(w);
|
|
return `<div class="warn-row">
|
|
<span class="warn-dot ${sev}"></span>
|
|
<div class="warn-txt">${escapeHtml(w)}
|
|
${href ? `<div><a class="warn-link" href="${href}">open ${name} →</a></div>` : ''}
|
|
</div>
|
|
<button type="button" class="warn-btn" data-dismiss="${escapeHtml(w)}">dismiss</button>
|
|
</div>`;
|
|
}).join('')
|
|
: `<div class="warn-empty">Nothing needs attention.${
|
|
dismissed.size ? ` ${dismissed.size} dismissed.` : ''}</div>`;
|
|
|
|
menu.innerHTML = `
|
|
<div class="warn-head">
|
|
<span class="warn-title">Warnings</span>
|
|
<span class="text-muted" style="font-size:.7rem">${open.length} open</span>
|
|
${open.length ? '<button type="button" class="warn-btn ms-auto" data-dismiss-all>dismiss all</button>' : ''}
|
|
${dismissed.size ? '<button type="button" class="warn-btn ms-auto" data-restore>show dismissed</button>' : ''}
|
|
</div>
|
|
${body}
|
|
<div class="warn-foot">
|
|
Warnings are derived from live state, so dismissing hides one until the
|
|
condition changes rather than clearing it. A dismissed warning returns
|
|
on its own if it recurs.
|
|
</div>`;
|
|
}
|
|
|
|
function bindWarnings() {
|
|
const menu = document.getElementById('warnings-menu');
|
|
if (!menu) return;
|
|
menu.addEventListener('click', (ev) => {
|
|
// Keep the dropdown open: acting on one warning should not close the list
|
|
// you are working through.
|
|
const one = ev.target.closest('[data-dismiss]');
|
|
const all = ev.target.closest('[data-dismiss-all]');
|
|
const restore = ev.target.closest('[data-restore]');
|
|
if (!one && !all && !restore) return;
|
|
ev.stopPropagation();
|
|
|
|
const set = loadDismissed();
|
|
if (one) set.add(one.getAttribute('data-dismiss'));
|
|
if (all) _allWarnings.forEach(w => set.add(w));
|
|
if (restore) set.clear();
|
|
saveDismissed(set);
|
|
renderWarnings(_allWarnings);
|
|
});
|
|
}
|
|
|
|
async function loadSnapshot() {
|
|
const data = await apiFetch(`${API}api/snapshot`);
|
|
if (!data) return;
|
|
_snap = data;
|
|
renderWarnings((data.coverage && data.coverage.warnings) || []);
|
|
if (_homeView === 'board') renderBoard();
|
|
else { renderLive(); renderFeed(data.recent_decisions); }
|
|
navbarUpdated(data.generated_at ? new Date(data.generated_at).toLocaleString() : null);
|
|
}
|
|
|
|
let _homeHistory = null;
|
|
|
|
// Fetched only to give the Board tiles their sparklines. The five History
|
|
// charts this used to draw are gone: the quota page's usage graph covers that
|
|
// ground per provider, which is the version worth keeping.
|
|
async function loadHistory() {
|
|
const data = await apiFetch(`${API}api/history?range=7d`);
|
|
if (data) { _homeHistory = data; if (_homeView === 'board') renderBoard(); }
|
|
}
|
|
|
|
/* ═══════════════════════════════════════
|
|
SSE LIVE STREAM
|
|
═══════════════════════════════════════ */
|
|
let sseConn = null;
|
|
|
|
function connectSSE() {
|
|
try {
|
|
if (sseConn) { sseConn.close(); }
|
|
sseConn = new EventSource(SSE_URL);
|
|
|
|
navbarSse('connecting');
|
|
|
|
sseConn.onopen = () => {
|
|
navbarSse('live');
|
|
};
|
|
|
|
sseConn.onmessage = (ev) => {
|
|
try {
|
|
const data = JSON.parse(ev.data);
|
|
pushFeed(data);
|
|
} catch (_) { /* heartbeat, ignore */ }
|
|
};
|
|
|
|
sseConn.onerror = () => {
|
|
navbarSse('offline', 'reconnecting');
|
|
};
|
|
} catch (e) {
|
|
navbarSse('offline');
|
|
}
|
|
}
|
|
|
|
|
|
/* ═══════════════════════════════════════
|
|
RENDER FUNCTIONS
|
|
═══════════════════════════════════════ */
|
|
|
|
|
|
function formatTs(ts) {
|
|
if (!ts) return '—';
|
|
try {
|
|
const d = new Date(ts);
|
|
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
|
} catch { return String(ts); }
|
|
}
|
|
|
|
|
|
|
|
// Shared per-category color map so Verdict Mix bars and Category dots agree.
|
|
const CATEGORY_COLORS = {
|
|
unverifiable: '#5a6a7f', // muted — the dominant background outcome
|
|
failed: '#e74c3c', reject: '#e74c3c', rejected: '#e74c3c',
|
|
malformed: '#f39c12',
|
|
ok: '#2ecc71', succeed: '#2ecc71', verified: '#2ecc71', pass: '#2ecc71',
|
|
truncated: '#f1c40f',
|
|
ambiguous: '#f1c40f', unknown: '#5a6a7f',
|
|
};
|
|
|
|
function categoryColor(label) {
|
|
const l = String(label).toLowerCase();
|
|
// Special-case unverifiable FIRST (muted), before any prefix match.
|
|
if (l.includes('unverif')) return CATEGORY_COLORS.unverifiable;
|
|
for (const [k, c] of Object.entries(CATEGORY_COLORS)) {
|
|
if (l.includes(k)) return c;
|
|
}
|
|
return `hsl(${Math.abs(hashCode(l)) % 360}, 60%, 60%)`;
|
|
}
|
|
|
|
|
|
|
|
// Warnings now live behind a navbar bell instead of their own card. Dismissal
|
|
// is a per-viewer convenience (localStorage), not a server-side ack — a
|
|
// dismissed warning stays hidden on this browser until the server stops
|
|
// reporting it and later reports it again (same text re-added).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/* ═══════════════════════════════════════
|
|
CHART HELPERS
|
|
═══════════════════════════════════════ */
|
|
|
|
|
|
/* ═══════════════════════════════════════
|
|
HISTORY RANGE SELECTOR
|
|
═══════════════════════════════════════ */
|
|
|
|
|
|
/* ═══════════════════════════════════════
|
|
UTILITIES
|
|
═══════════════════════════════════════ */
|
|
|
|
function escapeHtml(s) {
|
|
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
}
|
|
|
|
function hashCode(str) {
|
|
let hash = 0;
|
|
for (let i = 0; i < str.length; i++) {
|
|
hash = ((hash << 5) - hash) + str.charCodeAt(i);
|
|
hash |= 0;
|
|
}
|
|
return hash;
|
|
}
|
|
|
|
/* ═══════════════════════════════════════
|
|
INIT
|
|
═══════════════════════════════════════ */
|
|
|
|
function init() {
|
|
renderStaticIcons();
|
|
bindHomeViews();
|
|
bindWarnings();
|
|
bindActivityRange();
|
|
loadSnapshot();
|
|
loadHistory();
|
|
loadActivity();
|
|
loadLiveHistory();
|
|
connectSSE();
|
|
// Auto-refresh
|
|
setInterval(() => {
|
|
loadSnapshot();
|
|
loadHistory();
|
|
loadActivity();
|
|
loadLiveHistory();
|
|
}, REFRESH_MS);
|
|
}
|
|
|
|
init();
|
|
</script>
|
|
</body>
|
|
</html>
|