1568 lines
80 KiB
HTML
1568 lines
80 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" id="nav-links"></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>
|
|
|
|
<!-- Loops: open watchdog alerts. -->
|
|
<div class="card mt-3" id="loops">
|
|
<div class="card-header" style="padding:.55rem .8rem">
|
|
<h3 class="card-title mb-0" style="font-size:.9rem">Loops</h3>
|
|
<span class="card-subtitle text-muted ms-2" style="font-size:.72rem" id="loops-count">0 open</span>
|
|
</div>
|
|
<div class="card-body" style="padding:.6rem .8rem">
|
|
<div id="loops-body"><div class="text-muted small">Loading…</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 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 || {};
|
|
|
|
// Decisions tile: real routed decisions plus client-reported outcomes.
|
|
// The `|| {...}` guard matters because a stale tab can still receive a
|
|
// snapshot payload that predates this field -- without it, a missing
|
|
// object throws and blanks the whole board.
|
|
const dout = _snap.decision_outcomes
|
|
|| {decisions: 0, client_reports: 0, client_ok: 0, client_failed: 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';
|
|
|
|
// Second qualifier line for the Decisions tile. Percent covered only when
|
|
// there are routed decisions to divide by; the failed count takes the warn
|
|
// colour above a 10% failure rate (same threshold as the Signal card).
|
|
let outcomeLine = 'no client reports in 7 days';
|
|
if (dout.client_reports > 0) {
|
|
const share = dout.decisions > 0
|
|
? ` (${(dout.client_reports / dout.decisions * 100).toFixed(1)}%)`
|
|
: '';
|
|
const failedFg = dout.client_failed / dout.client_reports > 0.10
|
|
? 'warn-fg'
|
|
: 'ok-fg';
|
|
outcomeLine = `${num(dout.client_reports)} client reports${share}: `
|
|
+ `<span class="ok-fg">${num(dout.client_ok)} ok</span>, `
|
|
+ `<span class="${failedFg}">${num(dout.client_failed)} failed</span>`;
|
|
}
|
|
|
|
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(dout.decisions),
|
|
`routed in 7 days<br>${outcomeLine}`,
|
|
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 SEVERITY plus its text
|
|
* with the digits normalized away: the underlying numbers move on nearly
|
|
* every poll ("3 eligible" -> "50 eligible"), and a key that tracked the
|
|
* raw count would resurface the warning each time the number moved. The
|
|
* severity stays in the key so normalizing cannot fuse different warnings
|
|
* -- "0 eligible" is high-severity and must stay distinct from "3 eligible"
|
|
* (medium) even though both normalize to "N eligible". Store entries written
|
|
* by older implementations are raw warning text; loadDismissed migrates them
|
|
* through the current pipeline once and rewrites the store only when the
|
|
* migration actually changed something.
|
|
*
|
|
* 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 raw = 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. Unmappable carry-over
|
|
// entries never match a live key and are pruned on the next render.
|
|
const old = localStorage.getItem('dismissedWarnings');
|
|
if (old) {
|
|
raw.push(...JSON.parse(old));
|
|
localStorage.removeItem('dismissedWarnings'); // strip the legacy key
|
|
}
|
|
const set = migrateDismissedKeys(raw);
|
|
// Persist only when migration changed the store: a clean read must not
|
|
// rewrite identical JSON on every load. The old-key check forces a save
|
|
// after the carry-over so the merged set survives this removal.
|
|
const same = set.size === raw.length && raw.every(k => set.has(k));
|
|
if (!same || old) saveDismissed(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];
|
|
}
|
|
|
|
/* WARN_SEVERITY:BEGIN */
|
|
function warningSeverity(text) {
|
|
if (/\d+(\.\d+)?x (sustainable |plan )?pace|0 eligible|cannot|refus/i.test(text)) return 'high';
|
|
return 'medium';
|
|
}
|
|
/* WARN_SEVERITY:END */
|
|
|
|
/* DISMISS_UNITS:BEGIN */
|
|
/* Digit normalization is what makes a dismissal survive a count move: any
|
|
run of digits (integer or decimal) becomes N, so "3 eligible" and "50
|
|
eligible" share one key while every other word still counts. */
|
|
function normalizeDigits(text) {
|
|
return String(text).replace(/\d+(?:\.\d+)?/g, 'N');
|
|
}
|
|
|
|
/* The storage key for one warning: severity + digit-normalized, escaped
|
|
text. dismissKey output is what gets stored AND what gets compared, so
|
|
both sides of every comparison run through this one pipeline. */
|
|
function dismissKey(text, severity) {
|
|
return `${severity}:${escapeHtml(normalizeDigits(text))}`;
|
|
}
|
|
|
|
/* Store entries written by older implementations are raw warning text.
|
|
Map them through the current key pipeline. Entries already in the new
|
|
format (they open with a known severity prefix) pass through untouched,
|
|
so re-migrating an already-migrated store cannot mangle it. */
|
|
function migrateDismissedKeys(rawKeys) {
|
|
return new Set((rawKeys || []).map(k => {
|
|
const t = String(k);
|
|
return /^(high|medium):/.test(t) ? t : dismissKey(t, warningSeverity(t));
|
|
}));
|
|
}
|
|
|
|
/* Anything dismissed whose key no longer appears among the live warnings
|
|
has been resolved; drop the key so the store cannot grow forever with
|
|
stale entries. Mutates ``dismissed`` in place; returns true if anything
|
|
was dropped so the caller knows to persist. */
|
|
function pruneDismissed(warnings, dismissed) {
|
|
const live = new Set(warnings.map(w => dismissKey(w, warningSeverity(w))));
|
|
let pruned = false;
|
|
for (const k of [...dismissed]) {
|
|
if (!live.has(k)) { dismissed.delete(k); pruned = true; }
|
|
}
|
|
return pruned;
|
|
}
|
|
|
|
/* The open list: warnings whose key the operator has not dismissed. */
|
|
function openWarnings(warnings, dismissed) {
|
|
return warnings.filter(w => !dismissed.has(dismissKey(w, warningSeverity(w))));
|
|
}
|
|
/* DISMISS_UNITS:END */
|
|
|
|
let _allWarnings = [];
|
|
|
|
function renderWarnings(warnings) {
|
|
_allWarnings = warnings || [];
|
|
const dismissed = loadDismissed();
|
|
|
|
if (pruneDismissed(_allWarnings, dismissed)) saveDismissed(dismissed);
|
|
|
|
const open = openWarnings(_allWarnings, dismissed);
|
|
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(dismissKey(w, warningSeverity(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(dismissKey(w, warningSeverity(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;
|
|
}
|
|
|
|
/* ═══════════════════════════════════════
|
|
LOOPS (open watchdog alerts)
|
|
═══════════════════════════════════════ */
|
|
|
|
/* LIFT_A2_HELPERS:BEGIN */
|
|
function formatMoney(usd) {
|
|
if (usd == null) return 'n/a';
|
|
return '$' + Number(usd).toFixed(2);
|
|
}
|
|
|
|
function formatRelativeTime(iso8601) {
|
|
if (!iso8601) return '';
|
|
const now = new Date();
|
|
const date = new Date(iso8601);
|
|
const diffMs = now - date;
|
|
const diffSeconds = Math.floor(diffMs / 1000);
|
|
if (diffSeconds < 10) return 'just now';
|
|
if (diffSeconds < 60) return Math.floor(diffSeconds) + 's ago';
|
|
const diffMinutes = Math.floor(diffSeconds / 60);
|
|
if (diffMinutes < 60) return diffMinutes + 'm ago';
|
|
const diffHours = Math.floor(diffMinutes / 60);
|
|
if (diffHours < 24) return diffHours + 'h ago';
|
|
const diffDays = Math.floor(diffHours / 24);
|
|
if (diffDays < 30) return diffDays + 'd ago';
|
|
return diffDays + 'd ago';
|
|
}
|
|
|
|
function truncate(text, maxLen) {
|
|
if (!text) return '';
|
|
if (text.length <= maxLen) return text;
|
|
return text.substring(0, maxLen) + '...';
|
|
}
|
|
/* LIFT_A2_HELPERS:END */
|
|
|
|
async function loadLoops() {
|
|
const data = await apiFetch(`${API}api/watchdog/loops`);
|
|
const body = document.getElementById('loops-body');
|
|
const countEl = document.getElementById('loops-count');
|
|
const alerts = data && data.alerts ? data.alerts : [];
|
|
const resolved = data && data.resolved ? data.resolved : [];
|
|
countEl.textContent = `${alerts.length} open`;
|
|
|
|
if (alerts.length === 0 && resolved.length === 0) {
|
|
body.innerHTML = '<div class="text-muted small">No open alerts</div>';
|
|
return;
|
|
}
|
|
let html = '<div class="table-responsive"><table class="table table-vcenter card-table"><thead><tr>'
|
|
+ '<th>Severity</th><th>Session</th><th>Model</th><th>Top target</th>'
|
|
+ '<th class="num">Calls since landed</th><th class="num">$ since landed</th><th class="num">Open for</th><th></th>'
|
|
+ '</tr></thead><tbody>';
|
|
alerts.forEach(r => {
|
|
const sev = r.severity === 'critical' ? 'bg-danger'
|
|
: r.severity === 'warning' ? 'bg-warning'
|
|
: 'bg-info';
|
|
const title = escapeHtml(truncate(r.title || r.dedup_key || '', 40));
|
|
const fullTitle = escapeHtml(r.title || r.dedup_key || '');
|
|
const model = r.model_id ? escapeHtml(r.model_id) : '<span class="text-muted">unknown</span>';
|
|
const topWhat = r.top_what ? escapeHtml(r.top_what) : '<span class="text-muted">—</span>';
|
|
const calls = r.calls_since_landed != null ? escapeHtml(String(r.calls_since_landed)) : '?';
|
|
const cost = formatMoney(r.cost_since_landed_usd);
|
|
const relTime = formatRelativeTime(r.opened_at);
|
|
const dataModel = r.model_id || '';
|
|
const dataProvider = r.provider || '';
|
|
const dataReason = 'looping session ' + (r.title || r.dedup_key || '');
|
|
const blockBtn = `<button class="btn btn-sm btn-outline-danger" style="font-size:.62rem;padding:1px 6px" data-model="${escapeHtml(dataModel)}" data-provider="${escapeHtml(dataProvider)}" data-reason="${escapeHtml(dataReason)}" onclick="blockModelConfirm(event)">Block</button>`;
|
|
html += '<tr>'
|
|
+ `<td><span class="badge ${sev}" style="font-size:.62rem">${escapeHtml(r.severity)}</span></td>`
|
|
+ `<td><span class="small text-truncate" style="max-width:200px" title="${fullTitle}">${title}</span></td>`
|
|
+ `<td>${model}</td>`
|
|
+ `<td class="text-muted" style="font-size:.65rem">${topWhat}</td>`
|
|
+ `<td class="num">${calls}</td>`
|
|
+ `<td class="num">${cost}</td>`
|
|
+ `<td class="num">${relTime}</td>`
|
|
+ `<td class="text-end">${blockBtn}</td>`
|
|
+ '</tr>';
|
|
});
|
|
if (resolved.length > 0) {
|
|
resolved.forEach(r => {
|
|
const sev = r.severity === 'critical' ? 'bg-danger'
|
|
: r.severity === 'warning' ? 'bg-warning'
|
|
: 'bg-info';
|
|
const title = escapeHtml(truncate(r.title || r.dedup_key || '', 40));
|
|
const fullTitle = escapeHtml(r.title || r.dedup_key || '');
|
|
const model = r.model_id ? escapeHtml(r.model_id) : '<span class="text-muted">unknown</span>';
|
|
const topWhat = r.top_what ? escapeHtml(r.top_what) : '<span class="text-muted">—</span>';
|
|
const calls = r.calls_since_landed != null ? escapeHtml(String(r.calls_since_landed)) : '?';
|
|
const cost = formatMoney(r.cost_since_landed_usd);
|
|
const relTime = formatRelativeTime(r.opened_at);
|
|
html += '<tr class="opacity-50">'
|
|
+ `<td><span class="badge ${sev}" style="font-size:.62rem">${escapeHtml(r.severity)}</span></td>`
|
|
+ `<td><span class="small text-truncate" style="max-width:200px" title="${fullTitle}">${title}</span></td>`
|
|
+ `<td>${model}</td>`
|
|
+ `<td class="text-muted" style="font-size:.65rem">${topWhat}</td>`
|
|
+ `<td class="num">${calls}</td>`
|
|
+ `<td class="num">${cost}</td>`
|
|
+ `<td class="num">${relTime}</td>`
|
|
+ '<td></td>'
|
|
+ '</tr>';
|
|
});
|
|
}
|
|
html += '</tbody></table></div>';
|
|
body.innerHTML = html;
|
|
}
|
|
|
|
/* BLOCK_CONFIRM:BEGIN */
|
|
function blockModelConfirm(event) {
|
|
const btn = event.currentTarget;
|
|
const model = btn.dataset.model;
|
|
const prov = btn.dataset.provider;
|
|
const reason = btn.dataset.reason;
|
|
if (!model) return;
|
|
const row = btn.closest('div.d-flex') || btn.closest('tr');
|
|
if (!row) return;
|
|
const existing = row.nextElementSibling;
|
|
if (existing && existing.classList.contains('inline-confirm-row')) {
|
|
existing.remove();
|
|
return;
|
|
}
|
|
const confirmRow = document.createElement('div');
|
|
confirmRow.className = 'inline-confirm-row d-flex align-items-center gap-2 py-1';
|
|
confirmRow.style.cssText = 'border-bottom:1px solid rgba(255,255,255,.05);background:rgba(220,38,38,.08);padding:4px 8px';
|
|
confirmRow.innerHTML = '<input type="text" class="form-control form-control-sm" style="font-size:.72rem;flex:1;min-width:120px" value="' + escapeHtml(reason) + '">'
|
|
+ '<button class="btn btn-sm btn-danger" style="font-size:.62rem;padding:1px 8px">Confirm</button>'
|
|
+ '<button class="btn btn-sm btn-outline-secondary" style="font-size:.62rem;padding:1px 8px">Cancel</button>';
|
|
|
|
const cancelBtn = confirmRow.querySelector('button:last-child');
|
|
cancelBtn.onclick = () => confirmRow.remove();
|
|
|
|
const confirmBtn = confirmRow.querySelector('button:nth-of-type(1)');
|
|
const input = confirmRow.querySelector('input');
|
|
confirmBtn.onclick = async () => {
|
|
const reason = input.value;
|
|
confirmBtn.disabled = true;
|
|
const resp = await apiFetch(
|
|
`${API}api/models/${encodeURIComponent(model)}/${encodeURIComponent(prov)}/availability`,
|
|
{ method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({ availability: 'blocked', reason: reason }) }
|
|
);
|
|
if (resp) {
|
|
toast('Model blocked', 'success');
|
|
confirmRow.remove();
|
|
// Refresh dependent views
|
|
if (typeof loadStallRollup === 'function') loadStallRollup(document.querySelector('#stall-window .active')?.dataset?.hours || 24);
|
|
if (typeof loadBlockedModels === 'function') loadBlockedModels();
|
|
if (typeof loadLoops === 'function') loadLoops();
|
|
if (typeof loadModels === 'function') loadModels();
|
|
} else {
|
|
confirmBtn.disabled = false;
|
|
confirmRow.querySelector('.inline-error')?.remove();
|
|
const err = document.createElement('div');
|
|
err.className = 'text-danger small inline-error';
|
|
err.textContent = 'Block failed';
|
|
confirmRow.appendChild(err);
|
|
}
|
|
};
|
|
|
|
row.after(confirmRow);
|
|
}
|
|
|
|
function unblockModelConfirm(modelId, provider) {
|
|
const btn = event.target;
|
|
const row = btn.closest('div.d-flex') || btn.closest('tr');
|
|
if (!row) return;
|
|
const existing = row.nextElementSibling;
|
|
if (existing && existing.classList.contains('inline-confirm-row')) {
|
|
existing.remove();
|
|
return;
|
|
}
|
|
const confirmRow = document.createElement('div');
|
|
confirmRow.className = 'inline-confirm-row d-flex align-items-center gap-2 py-1';
|
|
confirmRow.style.cssText = 'border-bottom:1px solid rgba(255,255,255,.05);background:rgba(220,38,38,.08);padding:4px 8px';
|
|
confirmRow.innerHTML = '<span class="small text-danger">Unblock this model?</span>'
|
|
+ '<button class="btn btn-sm btn-warning" style="font-size:.62rem;padding:1px 8px">Confirm</button>'
|
|
+ '<button class="btn btn-sm btn-outline-secondary" style="font-size:.62rem;padding:1px 8px">Cancel</button>';
|
|
|
|
const cancelBtn = confirmRow.querySelector('button:last-child');
|
|
cancelBtn.onclick = () => confirmRow.remove();
|
|
|
|
const confirmBtn = confirmRow.querySelector('button:nth-of-type(1)');
|
|
confirmBtn.onclick = async () => {
|
|
confirmBtn.disabled = true;
|
|
const resp = await apiFetch(
|
|
`${API}api/models/${encodeURIComponent(modelId)}/${encodeURIComponent(provider)}/availability`,
|
|
{ method:'DELETE' }
|
|
);
|
|
if (resp) {
|
|
toast('Model unblocked', 'success');
|
|
confirmRow.remove();
|
|
if (typeof loadBlockedModels === 'function') loadBlockedModels();
|
|
if (typeof loadModels === 'function') loadModels();
|
|
if (typeof loadStallRollup === 'function') loadStallRollup(document.querySelector('#stall-window .active')?.dataset?.hours || 24);
|
|
} else {
|
|
confirmBtn.disabled = false;
|
|
confirmRow.querySelector('.inline-error')?.remove();
|
|
const err = document.createElement('div');
|
|
err.className = 'text-danger small inline-error';
|
|
err.textContent = 'Unblock failed';
|
|
confirmRow.appendChild(err);
|
|
}
|
|
};
|
|
|
|
row.after(confirmRow);
|
|
}
|
|
/* BLOCK_CONFIRM:END */
|
|
|
|
/* ═══════════════════════════════════════
|
|
INIT
|
|
═══════════════════════════════════════ */
|
|
|
|
function init() {
|
|
renderStaticIcons();
|
|
bindHomeViews();
|
|
bindWarnings();
|
|
bindActivityRange();
|
|
loadSnapshot();
|
|
loadHistory();
|
|
loadActivity();
|
|
loadLiveHistory();
|
|
loadLoops();
|
|
connectSSE();
|
|
// Auto-refresh
|
|
setInterval(() => {
|
|
loadSnapshot();
|
|
loadHistory();
|
|
loadActivity();
|
|
loadLiveHistory();
|
|
loadLoops();
|
|
}, REFRESH_MS);
|
|
}
|
|
|
|
init();
|
|
</script>
|
|
</body>
|
|
</html>
|