A declined answer left one number behind and it was in a log line, so confidence_min could not be tuned from data. route_decisions.confidence cannot say it either: the chat path re-routes through the override branch, which hard-codes 1.0, and 43,804 of 43,856 live rows hold exactly that. Measured on 2026-10-04 under local_decision (qwen3.5:4b): 769 of 2,557 turns (30%) had no fresh classification, against 0% for local_llm and local_encoder. The cause was recorded in only 4 of them. - classifier_confidence, classifier_coverage and classifier_reject on route_decisions, filled from a ClassifierAttempt carried on Classification. Both accepted and declined answers carry one, so the two distributions can be compared around the floor. Reason codes are listed in docs/data-model.md. - ClassifierRejected (a RuntimeError subclass, messages unchanged) replaces the plain RuntimeErrors at the six floor-miss sites and the two local_decision refusals, so the number and reason travel out of the raise site. - The chat path captures the classifier's verdict before the re-route and passes it to persist_route_decision (attempt_of), like it already does for source. - Admin decisions page: the source badge's tooltip shows the attempt, and session_history / session_stale get an amber badge instead of neutral grey. TUI detail popup and the live event carry the same three keys. - The /metrics degraded-share warning lists the recorded reasons instead of claiming the classifier "has been failing", which was wrong for a classifier that answers and is declined. - degraded_warn_threshold must be in (0, 1] and degraded_warn_min at least 1, refused at load: a value above 1 could never fire. The three columns arrive by ALTER and are NULL on every earlier row; metrics selects them only when present, so the live DB reads NULL until its restart. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KkCGRantZsSwmcFpet6FTa
752 lines
38 KiB
HTML
752 lines
38 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>Decisions — LLM Router Admin</title>
|
||
<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);
|
||
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.
|
||
Moving the scroll context down to <body> sidesteps it. ── */
|
||
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. ── */
|
||
.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. ── */
|
||
.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: translucent track + glow instead of flat solid. ── */
|
||
.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)}
|
||
header.navbar>.container-fluid{padding-left:20px!important;padding-right:20px!important}
|
||
.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}
|
||
.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. !important is what actually beats
|
||
it. That filter was stripping the mascot's orange color, and a sub-45px
|
||
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}
|
||
}
|
||
.tier-1{color:var(--tblr-success)}
|
||
.tier-2{color:var(--tblr-warning)}
|
||
.tier-3{color:var(--tblr-danger)}
|
||
.tier-?{color:var(--tblr-secondary)}
|
||
/* ── Filter bar ── */
|
||
.dec-filters{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
|
||
.dec-filters .form-select,.dec-filters .form-control{width:auto;min-width:120px}
|
||
.dec-filters .form-control{min-width:220px;flex:1 1 220px}
|
||
.dec-count{font-size:.8rem;color:var(--tblr-secondary);white-space:nowrap}
|
||
/* Ctx is a magnitude column: right-aligned and tabular so 153k and 9k line
|
||
up on the digit, which is the whole reason to scan it. Same treatment the
|
||
models page gives its numeric columns. */
|
||
#dec-tbody td.num, thead th.num{text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap}
|
||
/* One line per row. `nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free` is
|
||
44 characters and wrapped to two lines, doubling the height of exactly the
|
||
rows worth scanning. Truncated rather than wrapped because this is a
|
||
scanning surface; the full id is in the cell's title. */
|
||
#dec-tbody td.model-cell{max-width:22rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
/* ── Row density: this table's whole point is scanning a lot of rows at
|
||
once, so it trades card-table's normal comfortable spacing for a dense
|
||
grid — smaller type plus tighter padding, not just one or the other. ── */
|
||
#dec-tbody td, thead th{padding-top:.2rem!important;padding-bottom:.2rem!important}
|
||
#dec-tbody{font-size:.72rem}
|
||
#dec-tbody td{line-height:1.25}
|
||
/* ── Visual cues: category, tier and source read as colored badges rather
|
||
than plain text, so the whole row scans by color instead of by reading
|
||
every cell. (Kind used to lead this language; it lost its column for
|
||
being 'chat' on 99.8% of rows and is now a flag chip on the exceptions.) ── */
|
||
.dec-badge{display:inline-block;padding:.15rem .5rem;border-radius:999px;font-size:.72rem;font-weight:600;white-space:nowrap}
|
||
.flag-chip{display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;border-radius:5px;font-size:.62rem;font-weight:700;margin-right:3px;background:rgba(255,255,255,.08);color:var(--tblr-secondary);cursor:default}
|
||
.flag-chip.on{background:rgba(59,130,246,.22);color:#93c5fd}
|
||
/* The kind chip carries a WORD ('route', 'passthrough'), not one letter, so it
|
||
cannot use the 18px square above — that clips the text and leaves the pill
|
||
sitting behind the middle few characters. Width follows the text, and the
|
||
purple keeps it from reading as another single-letter flag. ── */
|
||
.flag-chip.kind{width:auto;padding:0 .4rem;background:rgba(168,85,247,.22);color:#d8b4fe}
|
||
/* 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}
|
||
</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">
|
||
<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">Decisions</h2>
|
||
<div class="text-muted mt-1">Recent routing decisions — filter and search</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="page-body">
|
||
<div class="container-xl">
|
||
<div class="row row-cards">
|
||
<div class="col-12">
|
||
<div class="card">
|
||
<div class="card-header flex-wrap gap-2">
|
||
<div class="dec-filters">
|
||
<select class="form-select form-select-sm" id="f-kind">
|
||
<option value="">All kinds</option>
|
||
</select>
|
||
<select class="form-select form-select-sm" id="f-category">
|
||
<option value="">All categories</option>
|
||
</select>
|
||
<select class="form-select form-select-sm" id="f-profile">
|
||
<option value="">All profiles</option>
|
||
</select>
|
||
<select class="form-select form-select-sm" id="f-tier">
|
||
<option value="">All tiers</option>
|
||
<option value="1">Tier 1</option>
|
||
<option value="2">Tier 2</option>
|
||
<option value="3">Tier 3</option>
|
||
</select>
|
||
<input type="text" class="form-control form-control-sm" id="f-search" placeholder="Search model, session, rejected reason…">
|
||
<select class="form-select form-select-sm" id="f-page-size" style="min-width:auto">
|
||
<option value="50">50 / page</option>
|
||
<option value="100" selected>100 / page</option>
|
||
<option value="250">250 / page</option>
|
||
</select>
|
||
<button type="button" class="btn btn-outline-secondary btn-sm" id="f-clear">Clear</button>
|
||
</div>
|
||
<span class="dec-count ms-auto" id="dec-count"></span>
|
||
</div>
|
||
<div class="table-responsive">
|
||
<table class="table table-vcenter table-hover card-table">
|
||
<thead>
|
||
<tr>
|
||
<th>Time</th><th>Category</th><th>Profile</th><th>Tier</th>
|
||
<th>Source</th><th>Model</th><th>Provider</th><th class="num">Ctx</th>
|
||
<th>Flags</th><th>Cost</th><th>Prof</th><th>Rejected</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="dec-tbody">
|
||
<tr><td colspan="12" class="text-muted">Loading…</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<div class="card-footer d-flex align-items-center justify-content-between">
|
||
<span class="text-muted small" id="dec-page-info"></span>
|
||
<div class="btn-group btn-group-sm">
|
||
<button type="button" class="btn btn-outline-secondary" id="dec-prev">← Prev</button>
|
||
<button type="button" class="btn btn-outline-secondary" id="dec-next">Next →</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div><!-- /row row-cards -->
|
||
</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>
|
||
|
||
<script>
|
||
/* ═══════════════════════════════════════════════════
|
||
admin/frontend/decisions.html — standalone decision log
|
||
API base: relative (works under /admin/)
|
||
═══════════════════════════════════════════════════ */
|
||
|
||
const API = ''; // relative to /admin/
|
||
const SSE_URL = '/events/decisions'; // root-level endpoint
|
||
const MAX_ROWS = 2000;
|
||
|
||
let _allDecisions = []; // newest first
|
||
let _knownKinds = new Set();
|
||
let _knownCategories = new Set();
|
||
let _knownProfiles = new Set();
|
||
let _pageSize = 100;
|
||
let _currentPage = 1; // 1-indexed
|
||
|
||
/* ── 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;
|
||
}
|
||
}
|
||
|
||
function escapeHtml(s) {
|
||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
}
|
||
|
||
function formatTs(ts) {
|
||
if (!ts) return '—';
|
||
try {
|
||
const d = new Date(ts);
|
||
return d.toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||
} catch { return String(ts); }
|
||
}
|
||
|
||
// Category has no small fixed set like kind does, so its badge color is a
|
||
// deterministic hash-of-the-string hue instead of a lookup table — same
|
||
// category always lands on the same color without a color map to maintain
|
||
// as new task_category values show up.
|
||
function hashCode(s) {
|
||
let h = 0;
|
||
for (let i = 0; i < s.length; i++) { h = (h << 5) - h + s.charCodeAt(i); h |= 0; }
|
||
return h;
|
||
}
|
||
function categoryStyle(label) {
|
||
const hue = Math.abs(hashCode(String(label).toLowerCase())) % 360;
|
||
return `background:hsla(${hue},70%,55%,.20);color:hsl(${hue},85%,74%)`;
|
||
}
|
||
|
||
const SOURCE_STYLES = {
|
||
classifier: 'background:rgba(59,130,246,.18);color:#93c5fd',
|
||
cached: 'background:rgba(34,197,94,.18);color:#4ade80',
|
||
fallback: 'background:rgba(239,68,68,.18);color:#f87171',
|
||
// Borrowed labels: a real classification of this session, replayed because
|
||
// the classifier declined this turn. Amber, between a fresh answer and the
|
||
// static guess, so a run of them is visible instead of reading as neutral.
|
||
session_history: 'background:rgba(245,158,11,.18);color:#fbbf24',
|
||
session_stale: 'background:rgba(245,158,11,.18);color:#fbbf24',
|
||
};
|
||
function sourceStyle(source) {
|
||
return SOURCE_STYLES[source] || 'background:rgba(148,163,184,.18);color:#cbd5e1';
|
||
}
|
||
|
||
function attemptTitle(d) {
|
||
// What the classifier itself did, for the source badge's tooltip. `confidence`
|
||
// cannot say this on a chat row (the re-route to measured context replaces it
|
||
// with 1.0), so these come from the classifier_* columns. A row with none of
|
||
// them (older rows, overrides, cache hits) gets no tooltip rather than a
|
||
// blank one.
|
||
const parts = [];
|
||
if (d.classifier_reject) parts.push(`declined: ${d.classifier_reject}`);
|
||
if (d.classifier_confidence != null) parts.push(`confidence ${Number(d.classifier_confidence).toFixed(3)}`);
|
||
if (d.classifier_coverage != null) parts.push(`coverage ${Number(d.classifier_coverage).toFixed(3)}`);
|
||
return parts.join(', ');
|
||
}
|
||
|
||
function flagChips(d) {
|
||
// Only the flags that are actually ON — showing all four dimmed on every
|
||
// row for hundreds of rows was noise, not a cue. Nothing on -> empty cell.
|
||
const flags = [
|
||
['T', 'tools', d.tools],
|
||
['I', 'images', d.images],
|
||
['J', 'json mode', d.json_mode],
|
||
['S', 'streamed', d.streamed],
|
||
];
|
||
const chips = flags.filter(([, , on]) => on)
|
||
.map(([letter, label]) => `<span class="flag-chip on" title="${escapeHtml(label)}">${letter}</span>`);
|
||
// `kind` lost its own column because it is 'chat' on 99.8% of rows (25,337
|
||
// of 25,390 measured 2026-09-11) — a column-width constant. The remaining
|
||
// 0.2% is still worth seeing, so it becomes a chip on the same
|
||
// only-when-it-says-something rule the flags above follow. The Kind filter
|
||
// is still on this page for anyone who wants to isolate them.
|
||
if (d.kind && d.kind !== 'chat') {
|
||
chips.push(`<span class="flag-chip on kind" title="kind: ${escapeHtml(d.kind)}">${escapeHtml(d.kind)}</span>`);
|
||
}
|
||
return chips.join('');
|
||
}
|
||
|
||
/* ═══════════════════════════════════════
|
||
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);
|
||
addDecision(data);
|
||
} catch (_) { /* heartbeat, ignore */ }
|
||
};
|
||
|
||
sseConn.onerror = () => {
|
||
navbarSse('offline', 'reconnecting');
|
||
};
|
||
} catch (e) {
|
||
navbarSse('offline');
|
||
}
|
||
}
|
||
|
||
function addDecision(dec) {
|
||
_allDecisions.unshift(dec);
|
||
if (_allDecisions.length > MAX_ROWS) _allDecisions.length = MAX_ROWS;
|
||
refreshFilterOptions([dec]);
|
||
renderTable();
|
||
}
|
||
|
||
/* ═══════════════════════════════════════
|
||
FILTERS
|
||
═══════════════════════════════════════ */
|
||
|
||
/* RESOLVE_URL_FILTER_VALUES:BEGIN */
|
||
// Pure helper: given the six raw URL parameter values and each control's
|
||
// current options, decide what to apply. Returns the value to set per
|
||
// control (null = leave the control untouched), which select options must
|
||
// be added first (the caller escapeHtml's them via textContent), and
|
||
// whether an unknown size fell back to the default. No DOM access - the
|
||
// caller applies the result. size is the exception: it is validated
|
||
// against the page-size options and an unknown value falls back to 100
|
||
// instead of being added as an option.
|
||
function resolveUrlFilterValues(rawParams, optionSets) {
|
||
const out = {
|
||
values: { kind: null, category: null, profile: null, tier: null, search: null, size: null },
|
||
addOptions: [], // {id, value} - the option must exist before .value sticks
|
||
sizeFallback: false,
|
||
};
|
||
const selects = [
|
||
['kind', 'f-kind'],
|
||
['category', 'f-category'],
|
||
['profile', 'f-profile'],
|
||
['tier', 'f-tier'],
|
||
];
|
||
for (const [key, id] of selects) {
|
||
const raw = String(rawParams[key] || '').trim();
|
||
if (!raw) continue;
|
||
out.values[key] = raw;
|
||
const existing = optionSets[key] || [];
|
||
if (!existing.some(v => String(v) === raw)) {
|
||
out.addOptions.push({ id: id, value: raw });
|
||
}
|
||
}
|
||
const rawSearch = String(rawParams.q || '').trim();
|
||
if (rawSearch) out.values.search = rawSearch;
|
||
const rawSize = String(rawParams.size || '').trim();
|
||
if (rawSize) {
|
||
const sizeOptions = (optionSets.size || []).map(v => String(v));
|
||
if (sizeOptions.includes(rawSize)) {
|
||
out.values.size = rawSize;
|
||
} else {
|
||
out.values.size = '100';
|
||
out.sizeFallback = true;
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
/* RESOLVE_URL_FILTER_VALUES:END */
|
||
|
||
// Read ?kind=&category=&profile=&tier=&q=&size= into the controls BEFORE the
|
||
// first render. An unknown select value is added as an escaped option so a
|
||
// link naming a category with no loaded rows still shows its filter (the
|
||
// spec accepts a bogus ?tier=4 as a phantom option with zero rows too).
|
||
// Applying size also sets the module _pageSize - pagination math reads
|
||
// _pageSize, not the select's value - and resets the page to 1.
|
||
function applyUrlFilters() {
|
||
const params = new URLSearchParams(location.search);
|
||
const optionsOf = (id) => [...document.getElementById(id).options].map(o => o.value);
|
||
const resolved = resolveUrlFilterValues(
|
||
{
|
||
kind: params.get('kind') || '',
|
||
category: params.get('category') || '',
|
||
profile: params.get('profile') || '',
|
||
tier: params.get('tier') || '',
|
||
q: params.get('q') || '',
|
||
size: params.get('size') || '',
|
||
},
|
||
{
|
||
kind: optionsOf('f-kind'),
|
||
category: optionsOf('f-category'),
|
||
profile: optionsOf('f-profile'),
|
||
tier: optionsOf('f-tier'),
|
||
size: optionsOf('f-page-size'),
|
||
}
|
||
);
|
||
for (const add of resolved.addOptions) {
|
||
const opt = document.createElement('option');
|
||
opt.value = add.value;
|
||
opt.textContent = add.value; // textContent escapes like escapeHtml
|
||
document.getElementById(add.id).appendChild(opt);
|
||
}
|
||
const ids = { kind: 'f-kind', category: 'f-category', profile: 'f-profile', tier: 'f-tier' };
|
||
for (const key of Object.keys(ids)) {
|
||
if (resolved.values[key] != null) document.getElementById(ids[key]).value = resolved.values[key];
|
||
}
|
||
if (resolved.values.search != null) document.getElementById('f-search').value = resolved.values.search;
|
||
if (resolved.values.size != null) {
|
||
document.getElementById('f-page-size').value = resolved.values.size;
|
||
_pageSize = Number(resolved.values.size) || 100;
|
||
_currentPage = 1;
|
||
}
|
||
}
|
||
|
||
// The URL is written from exactly one place: writeFiltersToUrl(). Filter
|
||
// changes reach it through resetPageAndRender(), chosen over per-call-site
|
||
// hooks because every filter handler already routes there; init calls it
|
||
// once after applying URL params to normalize the URL (e.g. a garbage
|
||
// ?size=999 is rewritten away). replaceState only - the filter log is not
|
||
// navigation history, so the history stack is never extended. Empty values
|
||
// and defaults (selects "", search "", size 100) are omitted so Clear
|
||
// resets to the bare path.
|
||
function writeFiltersToUrl() {
|
||
const val = (id) => String(document.getElementById(id).value || '').trim();
|
||
const kind = val('f-kind');
|
||
const category = val('f-category');
|
||
const profile = val('f-profile');
|
||
const tier = val('f-tier');
|
||
const q = val('f-search');
|
||
const size = val('f-page-size');
|
||
const params = new URLSearchParams();
|
||
if (kind) params.set('kind', kind);
|
||
if (category) params.set('category', category);
|
||
if (profile) params.set('profile', profile);
|
||
if (tier) params.set('tier', tier);
|
||
if (q) params.set('q', q);
|
||
if (size && size !== '100') params.set('size', size);
|
||
const qs = params.toString();
|
||
history.replaceState(null, '', qs ? '?' + qs : location.pathname);
|
||
}
|
||
|
||
function refreshFilterOptions(rows) {
|
||
const kindSel = document.getElementById('f-kind');
|
||
const catSel = document.getElementById('f-category');
|
||
const profSel = document.getElementById('f-profile');
|
||
let changed = false;
|
||
for (const d of rows) {
|
||
if (d.kind && !_knownKinds.has(d.kind)) { _knownKinds.add(d.kind); changed = true; }
|
||
if (d.task_category && !_knownCategories.has(d.task_category)) { _knownCategories.add(d.task_category); changed = true; }
|
||
if (d.profile != null && !_knownProfiles.has(d.profile)) { _knownProfiles.add(d.profile); changed = true; }
|
||
}
|
||
if (!changed) return;
|
||
const kindVal = kindSel.value, catVal = catSel.value, profVal = profSel.value;
|
||
// Union the still-set value (typically URL-applied) into the rebuilt
|
||
// option list - otherwise the rebuild silently drops it and the select
|
||
// resets to "" because .value no longer matches any option.
|
||
const unioned = (values, kept) =>
|
||
[...new Set(kept ? [...values, kept] : [...values])].sort()
|
||
.map(v => `<option value="${escapeHtml(v)}">${escapeHtml(v)}</option>`).join('');
|
||
kindSel.innerHTML = '<option value="">All kinds</option>' + unioned([...(_knownKinds)], kindVal);
|
||
catSel.innerHTML = '<option value="">All categories</option>' + unioned([...(_knownCategories)], catVal);
|
||
profSel.innerHTML = '<option value="">All profiles</option>' + unioned([...(_knownProfiles)], profVal);
|
||
kindSel.value = kindVal;
|
||
catSel.value = catVal;
|
||
profSel.value = profVal;
|
||
}
|
||
|
||
function currentFilters() {
|
||
return {
|
||
kind: document.getElementById('f-kind').value,
|
||
category: document.getElementById('f-category').value,
|
||
profile: document.getElementById('f-profile').value,
|
||
tier: document.getElementById('f-tier').value,
|
||
search: document.getElementById('f-search').value.trim().toLowerCase(),
|
||
};
|
||
}
|
||
|
||
// NULL and 0 are different facts and must not render the same.
|
||
//
|
||
// NULL means the column was never written -- it is nullable, and a NULL here
|
||
// already has one known latent consequence (a TypeError in the demand-ceiling
|
||
// comparison in metrics.py). 0 means the classifier declined to estimate:
|
||
// local_encoder mode always returns required_context_tokens=0, and
|
||
// chat_completions then takes the larger of that and its own measurement, so a
|
||
// stored 0 says "this row was never measured against real content".
|
||
//
|
||
// Rendering either as the literal "None"/"null" is the defect the TUI's ctx
|
||
// cell still has and the profile cell was written to avoid.
|
||
function ctxCell(n) {
|
||
if (n === null || n === undefined) return '<span class="text-muted">—</span>';
|
||
const v = Number(n);
|
||
if (v === 0) return '<span class="text-muted">0</span>';
|
||
if (v >= 1000000) return `${(v / 1000000).toFixed(1)}M`;
|
||
if (v >= 1000) return `${Math.round(v / 1000)}k`;
|
||
return String(v);
|
||
}
|
||
|
||
function matchesFilters(d, f) {
|
||
if (f.kind && d.kind !== f.kind) return false;
|
||
if (f.category && d.task_category !== f.category) return false;
|
||
if (f.profile && d.profile !== f.profile) return false;
|
||
if (f.tier && String(d.task_tier || '') !== f.tier) return false;
|
||
if (f.search) {
|
||
const haystack = [
|
||
d.selected_model, d.selected_provider, d.session_key,
|
||
d.rejected_reason, d.task_category, d.kind, d.profile,
|
||
].filter(Boolean).join(' ').toLowerCase();
|
||
if (!haystack.includes(f.search)) return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function resetPageAndRender() {
|
||
// writeFiltersToUrl is called HERE rather than at each handler: every
|
||
// filter change (selects, search, page size, Clear) routes through this.
|
||
_currentPage = 1;
|
||
writeFiltersToUrl();
|
||
renderTable();
|
||
}
|
||
|
||
function setupFilters() {
|
||
const ids = ['f-kind', 'f-category', 'f-profile', 'f-tier'];
|
||
ids.forEach(id => document.getElementById(id).addEventListener('change', resetPageAndRender));
|
||
document.getElementById('f-search').addEventListener('input', resetPageAndRender);
|
||
document.getElementById('f-clear').addEventListener('click', () => {
|
||
document.getElementById('f-kind').value = '';
|
||
document.getElementById('f-category').value = '';
|
||
document.getElementById('f-profile').value = '';
|
||
document.getElementById('f-tier').value = '';
|
||
document.getElementById('f-search').value = '';
|
||
document.getElementById('f-page-size').value = '100';
|
||
_pageSize = 100;
|
||
resetPageAndRender(); // rewrites the URL back to the bare path
|
||
});
|
||
}
|
||
|
||
function setupPagination() {
|
||
document.getElementById('f-page-size').addEventListener('change', (e) => {
|
||
_pageSize = Number(e.target.value) || 100;
|
||
resetPageAndRender();
|
||
});
|
||
document.getElementById('dec-prev').addEventListener('click', () => {
|
||
if (_currentPage > 1) { _currentPage--; renderTable(); }
|
||
});
|
||
document.getElementById('dec-next').addEventListener('click', () => {
|
||
_currentPage++; // clamped inside renderTable
|
||
renderTable();
|
||
});
|
||
}
|
||
|
||
/* ═══════════════════════════════════════
|
||
TABLE
|
||
═══════════════════════════════════════ */
|
||
|
||
function renderTable() {
|
||
const tbody = document.getElementById('dec-tbody');
|
||
const countEl = document.getElementById('dec-count');
|
||
const pageInfoEl = document.getElementById('dec-page-info');
|
||
const prevBtn = document.getElementById('dec-prev');
|
||
const nextBtn = document.getElementById('dec-next');
|
||
const f = currentFilters();
|
||
const rows = _allDecisions.filter(d => matchesFilters(d, f));
|
||
// "N of 1000" read as a total. It is a window: the newest 1000 rows, which
|
||
// on this deployment is about 4% of the history. Naming the window and the
|
||
// history separately is the difference between a count and a claim.
|
||
const capped = _totalDecisions != null && _totalDecisions > _allDecisions.length;
|
||
countEl.textContent = capped
|
||
? `${rows.length} of ${_allDecisions.length} loaded (${_totalDecisions.toLocaleString()} total)`
|
||
: `${rows.length} of ${_allDecisions.length}`;
|
||
countEl.title = capped
|
||
? `Filters and search run over the newest ${_allDecisions.length} decisions only. `
|
||
+ `${(_totalDecisions - _allDecisions.length).toLocaleString()} older rows are not loaded.`
|
||
: '';
|
||
if (!rows.length) {
|
||
// The empty state is where the window matters most: a search for a model
|
||
// absent from the newest 1000 used to report a confident zero against a
|
||
// history that held thousands of matches.
|
||
const note = capped
|
||
? `No match in the newest ${_allDecisions.length} decisions. `
|
||
+ `${(_totalDecisions - _allDecisions.length).toLocaleString()} older rows were not searched.`
|
||
: 'No decisions match these filters';
|
||
tbody.innerHTML = `<tr><td colspan="12" class="text-muted text-center">${escapeHtml(note)}</td></tr>`;
|
||
pageInfoEl.textContent = '';
|
||
prevBtn.disabled = true;
|
||
nextBtn.disabled = true;
|
||
return;
|
||
}
|
||
const totalPages = Math.max(1, Math.ceil(rows.length / _pageSize));
|
||
_currentPage = Math.min(Math.max(1, _currentPage), totalPages);
|
||
const start = (_currentPage - 1) * _pageSize;
|
||
const pageRows = rows.slice(start, start + _pageSize);
|
||
pageInfoEl.textContent = `Page ${_currentPage} of ${totalPages} (rows ${start + 1}–${start + pageRows.length})`;
|
||
prevBtn.disabled = _currentPage <= 1;
|
||
nextBtn.disabled = _currentPage >= totalPages;
|
||
tbody.innerHTML = pageRows.map(d => {
|
||
// Model and provider are separate columns. They used to be one cell
|
||
// joined by " / ", which read as two slashes meaning different things on
|
||
// an OpenRouter row: "xiaomi/mimo-v2.5 / openrouter" puts a vendor
|
||
// separator and a provider separator in the same string.
|
||
const model = d.selected_model || '—';
|
||
const provider = d.selected_provider || '';
|
||
const category = d.task_category || '—';
|
||
const tier = d.task_tier != null ? String(d.task_tier) : '?';
|
||
const source = d.classification_source || '—';
|
||
return `<tr>
|
||
<td class="text-nowrap">${formatTs(d.observed_at || '')}</td>
|
||
<td title="${escapeHtml(d.task_category || '')}"><span class="dec-badge" style="${categoryStyle(category)}">${escapeHtml(category)}</span></td>
|
||
<td>${escapeHtml(d.profile || '')}</td>
|
||
<td class="tier-${tier}">${escapeHtml(tier)}</td>
|
||
<td><span class="dec-badge" style="${sourceStyle(source)}" title="${escapeHtml(attemptTitle(d))}">${escapeHtml(source)}</span></td>
|
||
<td class="model-cell" title="${escapeHtml(model)}">${escapeHtml(model)}</td>
|
||
<td class="text-muted">${escapeHtml(provider)}</td>
|
||
<td class="num" title="${d.required_context_tokens != null ? Number(d.required_context_tokens).toLocaleString() + ' tokens' : 'not recorded'}">${ctxCell(d.required_context_tokens)}</td>
|
||
<td class="text-nowrap">${flagChips(d)}</td>
|
||
<td>${d.est_cost_usd != null ? '$' + Number(d.est_cost_usd).toFixed(4) : '—'}</td>
|
||
<td>${d.est_proficiency != null ? Number(d.est_proficiency).toFixed(2) : '—'}</td>
|
||
<td class="text-muted small">${escapeHtml(d.rejected_reason || '')}</td>
|
||
</tr>`;
|
||
}).join('');
|
||
}
|
||
|
||
// Rows held in memory. Everything on this page filters over these and only
|
||
// these, so the difference between it and _totalDecisions is the part of the
|
||
// history the page cannot see.
|
||
let _totalDecisions = null;
|
||
|
||
async function loadDecisions() {
|
||
// Not apiFetch: this one needs the X-Total-Count header, which carries the
|
||
// real row count so the page can say how much it is NOT searching.
|
||
let data = null;
|
||
try {
|
||
const resp = await fetch(`${API}api/decisions?limit=1000`);
|
||
if (!resp.ok) throw new Error(`${resp.status} ${resp.statusText}`);
|
||
const header = resp.headers.get('X-Total-Count');
|
||
_totalDecisions = header != null ? Number(header) : null;
|
||
data = await resp.json();
|
||
} catch (e) {
|
||
console.warn('API call failed:', e);
|
||
return;
|
||
}
|
||
_allDecisions = data;
|
||
refreshFilterOptions(data);
|
||
renderTable();
|
||
}
|
||
|
||
/* ═══════════════════════════════════════
|
||
INIT
|
||
═══════════════════════════════════════ */
|
||
|
||
function init() {
|
||
setupFilters();
|
||
setupPagination();
|
||
applyUrlFilters(); // URL -> controls before the first render
|
||
writeFiltersToUrl(); // normalize once (e.g. a garbage ?size=999 is rewritten away)
|
||
loadDecisions();
|
||
connectSSE();
|
||
}
|
||
|
||
init();
|
||
</script>
|
||
</body>
|
||
</html>
|