Files
6krrt/admin/frontend/index.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 &mdash; 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&hellip;</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 &rarr;</a>
</div>
<div id="busy-models"><div class="text-muted small">Loading&hellip;</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&hellip;</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 &rarr;</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&hellip;</div>
</div>
</div>
</div>
</div>
<div class="live-side" id="live-rail">
<div class="text-muted small">Loading&hellip;</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>&copy; 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">&mdash;</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 ? '&mdash;' : '$' + 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', '&mdash;',
'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">&mdash;</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 ? '&mdash;'
: 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 ? '&mdash;' : (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 &rarr;</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 &rarr;</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} &rarr;</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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
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">&mdash;</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">&mdash;</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>