Files
6krrt/admin/frontend/decisions.html
adlee-was-taken 92031dbd0b feat(classifier): record the classifier's own attempt on route_decisions
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
2026-10-04 22:06:57 -04:00

752 lines
38 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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 &mdash; 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 &mdash; 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&hellip;">
<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&hellip;</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">&larr; Prev</button>
<button type="button" class="btn btn-outline-secondary" id="dec-next">Next &rarr;</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>&copy; 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function formatTs(ts) {
if (!ts) return '&mdash;';
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">&mdash;</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>