Files
6krrt/admin/frontend/controls.html
adlee-was-taken 7f616dc256 style(admin-ui): drop the note pill under the unit in persisted cells
The badge-only wrap fixed the 800px overflow but left classifier.degraded_warn_min
with a three-line pill squeezed beside its unit. Letting the hints span wrap
puts the unit on one line and the pill under it in two, matching how
degraded_warn_threshold already reads. Table width at 800 is unchanged (732px).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KkCGRantZsSwmcFpet6FTa
2026-10-05 01:46:15 -04:00

1882 lines
95 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>Controls &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);
}
.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-36px
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}
}
/* ── Settings rows: ONE row grammar shared by Runtime Knobs and Persisted
Config — `key ......... [meta] [control]` on a 3-track grid with a fixed
control track, so every switch/input/select lands on the same right edge
and the eye scans a single column instead of chasing floating controls.
The old layout used Bootstrap .row col-5/col-3/col-4, which put the
control mid-row and spent a whole column restating the value the control
already shows. ── */
/* Negative margin == row padding, so the keys line up with the card body's
own content edge while the hover tint still bleeds to the card's inner edge. */
.settings-list{margin:0 -.5rem}
.setting-row{
display:grid;
grid-template-columns:minmax(0,1fr) auto 176px;
align-items:center;
gap:.75rem;
/* Fixed row height: a switch and a text input are ~10px apart in natural
height, which made the list look ragged when the two alternate. */
min-height:38px;
padding:.25rem .5rem;
border-left:2px solid transparent;
border-radius:6px;
}
.setting-row+.setting-row{border-top:1px solid rgba(255,255,255,.05)}
.setting-row:hover{background:rgba(255,255,255,.025)}
.setting-row.is-dirty{border-left-color:#fbbf24;background:rgba(245,158,11,.06)}
/* The knob table keeps .setting-row on its <tr>s -- four JS sites select
#config-list .setting-row[data-key] -- but the rule above is a div-grid
and destroys table layout, so inside this table the row is table-native.
Scoped so any future div list keeps the grid grammar. */
.knobs-table .setting-row{display:table-row;min-height:0;padding:0;border-radius:0;border-left:2px solid transparent}
.knobs-table .setting-row.is-dirty{border-left-color:#fbbf24;background:rgba(245,158,11,.06)}
/* Persisted-cell hints: the note badge is white-space:nowrap by default, so a
long note sets the cell's min-content width and pushes the table past an
800px viewport. Let the hints wrap (the pill drops under the unit) and the
pill's own text wrap. d-inline-flex is !important, so this targets the
cell's direct .ms-2 child rather than overriding display. */
.knobs-table td > .ms-2{flex-wrap:wrap;max-width:100%}
.knobs-table td > .ms-2 .badge{white-space:normal;text-align:left}
.knobs-table .setting-key{white-space:normal;overflow:visible;text-overflow:clip}
.knobs-table .setting-key .scope{display:inline}
/* -- Category header rows: a subtle accent that segments the knob list
without adding visual weight. The chevron is the only interactive cue;
the label and icon supply context. -- */
.cat-header th{
background:rgba(255,255,255,.02);
border-bottom:1px solid rgba(255,255,255,.08);
cursor:pointer;user-select:none;
font-size:.85rem;font-weight:600;
padding:.35rem .6rem;
color:var(--tblr-secondary)
}
.cat-header th:hover{background:rgba(255,255,255,.045)}
.cat-header .cat-label{color:#e2e8f0}
.cat-chevron{display:inline-flex;align-items:center;color:var(--tblr-secondary);opacity:.7;transition:transform .18s ease}
.cat-chevron:not(.cat-chevron-open){transform:rotate(90deg)}
.cat-icon{opacity:.7;margin-right:.15rem}
.adv-subheader th{
cursor:pointer;user-select:none;
font-size:.78rem;font-weight:500;
padding:.2rem .6rem;
color:var(--tblr-secondary);opacity:.7
}
.adv-subheader th:hover{opacity:1;color:#e2e8f0}
.setting-key{
font-size:.82rem;line-height:1.25;
overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
}
.setting-key .scope{color:var(--tblr-secondary);opacity:.7}
.setting-meta{font-size:.72rem;white-space:nowrap;color:var(--tblr-secondary);font-variant-numeric:tabular-nums}
.setting-control{display:flex;justify-content:flex-end;align-items:center;min-width:0}
.setting-control .form-control,
.setting-control .form-select{width:100%;font-variant-numeric:tabular-nums}
/* Bootstrap floats .form-check-input and reserves 1.5em of left padding;
both fight right-alignment inside the control track. */
.setting-control .form-check{margin:0;padding:0;min-height:0;display:flex;justify-content:flex-end}
.setting-control .form-check-input{margin:0;float:none}
.settings-hint{font-size:.75rem;margin-bottom:.5rem}
/* Card bodies that are pure lists: let rows own the vertical rhythm. */
.card-body.settings-body{padding:.75rem 1rem 1rem}
/* 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/">
<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">Controls</h2>
<div class="text-muted mt-1">Maintenance triggers, runtime knobs, and persisted configuration</div>
</div>
</div>
</div>
</div>
<div class="page-body">
<div class="container-xl">
<div class="row row-cards align-items-start">
<!-- Row 1: Operational Triggers as a full-width strip. It is one
line of buttons, so as a half-width card beside the much taller
Runtime Knobs it left a column of dead space no matter what
align-items said. Buttons live in .card-actions; the body is a
single description/status line. -->
<div class="col-12">
<div class="card">
<div class="card-header">
<h3 class="card-title"><span class="me-2" data-icon="zap"></span>Operational Triggers</h3>
<div class="card-actions d-flex gap-2 flex-wrap">
<button class="btn btn-success btn-sm" onclick="triggerJob('refresh-catalog')">Refresh Catalog</button>
<button class="btn btn-success btn-sm" onclick="triggerJob('seed-energy')">Seed Energy</button>
<button class="btn btn-success btn-sm" onclick="triggerJob('apply-feedback')">Apply Feedback</button>
<button class="btn btn-danger btn-sm" onclick="triggerJob('restart-service')">Restart Service</button>
</div>
</div>
<div class="card-body py-2 d-flex flex-wrap align-items-center justify-content-between gap-2">
<span class="text-muted" style="font-size:.78rem">Fire-and-forget maintenance jobs.</span>
<span id="job-status" class="text-muted text-end" style="font-size:0.75rem"></span>
</div>
</div>
</div>
<!-- Row 1b: Classifier. A dedicated card, not a row in the generic
Runtime Knobs list -- classifier.mode has cross-field structure
(cloud_llm needs a pinned primary OR auto; local_encoder needs
a model id) that a flat scalar/boolean input can't safely
represent, the same reasoning behind Local Compute's own card
in the gaming-mode work this mirrors. -->
<div class="col-12">
<div class="card" id="classifier-config-card">
<div class="card-header">
<h3 class="card-title"><span class="me-2" data-icon="cpu"></span>Classifier</h3>
<div class="card-actions d-flex align-items-center gap-2">
<span id="classifier-pending-badge" class="badge bg-warning" style="display:none"></span>
<span id="classifier-mode-badge" class="badge bg-secondary">&mdash;</span>
</div>
</div>
<div class="card-body py-3">
<p class="text-muted mb-3" style="font-size:.78rem">
Which implementation answers PRIMARY. A peer to the fallback cascade, not a
replacement for it: whichever mode is primary, a failure still walks the same
cascade (stale session &rarr; session history &rarr; <code>cloud_fallback</code> &rarr;
the static guess) unmodified.
</p>
<div class="row g-3 align-items-start">
<div class="col-md-4">
<label class="form-label" for="classifier-mode-select" style="font-size:.78rem">mode</label>
<select class="form-select form-select-sm" id="classifier-mode-select"
onchange="onClassifierModeChange()" disabled>
<option value="" selected>loading...</option>
<option value="local_llm">local_llm</option>
<option value="cloud_llm">cloud_llm</option>
<option value="local_encoder">local_encoder</option>
<option value="local_decision">local_decision</option>
</select>
</div>
<div class="col-md-8" id="classifier-mode-fields"></div>
</div>
<div id="classifier-resolved-primary" class="text-muted mt-2" style="font-size:.75rem;display:none"></div>
<!-- Read-only: classifier.candidate_categories is a structural
list (a validated subset of proficiency.categories), not a
scalar knob, so it gets a readout rather than an editor.
The excluded half is the part worth showing -- it is the
answer to "why does nothing ever classify as X". -->
<div id="classifier-candidate-categories" class="text-muted mt-2" style="font-size:.75rem;display:none"></div>
<div id="classifier-config-error" class="alert alert-warning mt-3 mb-0 py-2" style="display:none;font-size:.78rem"></div>
<div class="d-flex align-items-center justify-content-between mt-3">
<span id="classifier-config-status" class="text-muted" style="font-size:.72rem"></span>
<button class="btn btn-primary btn-sm" id="classifier-save-btn" onclick="saveClassifierConfig()" disabled>Save</button>
</div>
</div>
</div>
</div>
<!-- Row 1c: gaming mode. Deliberately NOT left to the generic knob
list below: this is the one toggle whose consequences an
operator has to read before flipping it, and a bare switch in a
column of eight says nothing about the cloud classifier it
requires or about what still runs for free ahead of it. -->
<div class="col-12">
<div class="card" id="local-compute-card">
<div class="card-header">
<h3 class="card-title"><span class="me-2" data-icon="cpu"></span>Local Compute</h3>
<div class="card-actions d-flex align-items-center gap-2">
<span id="local-compute-state" class="badge bg-secondary">&mdash;</span>
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" id="local-compute-toggle" onchange="setLocalCompute(this.checked)">
</div>
</div>
</div>
<div class="card-body py-2">
<p class="text-muted mb-2" style="font-size:.78rem">
On means the router may use this machine's GPU. Turn it <strong>off</strong> when you
stop Ollama to play a game: every local call is then <em>skipped</em> rather than
attempted and timed out &mdash; classifier, health probe, local verification, the
local-vision fallback, and local dispatch rows, which drop out of the candidate
set instead of being picked and then failing.
</p>
<p class="text-muted mb-2" style="font-size:.78rem">
Turning it off <strong>requires <code>classifier.cloud_fallback</code></strong>. Skipping the
local classifier does not make classification remote &mdash; without a cloud
classifier it stops classifying, and every request falls through to a static
guess recorded as <code>general_chat</code>, which is indistinguishable from a real
classification afterwards.
</p>
<p class="text-muted mb-0" style="font-size:.78rem">
The cloud classifier replaces the <em>local model</em>, not the free steps ahead of
it: this session's cached classification and this session's history in
<code>route_decisions</code> are both real classifications of this same session, so paying
to re-derive an answer already held would be spending money for nothing.
</p>
<div id="local-compute-error" class="alert alert-warning mt-2 mb-0 py-2" style="display:none;font-size:.78rem"></div>
</div>
</div>
</div>
<!-- Row 1d: cloud_fallback. Independent always-available fallback
classifier, NOT a mode-choice option. Used only when the local
classifier is unreachable -- not a primary-mode setting. -->
<div class="col-12">
<div class="card" id="cloud-fallback-card">
<div class="card-header">
<h3 class="card-title"><span class="me-2" data-icon="cpu"></span>Cloud Fallback</h3>
<div class="card-actions d-flex align-items-center gap-2">
<span id="cloud-fallback-state" class="badge bg-secondary">&mdash;</span>
</div>
</div>
<div class="card-body py-3">
<p class="text-muted mb-3" style="font-size:.78rem">
A cloud classifier used only when the local classifier is unreachable.
Independent of the mode choice above — not a primary-mode option.
</p>
<div class="row g-2">
<div class="col-md-6">
<label class="form-label" for="cf-base-url" style="font-size:.78rem">base_url</label>
<input class="form-control form-control-sm" id="cf-base-url" placeholder="base_url">
</div>
<div class="col-md-6">
<label class="form-label" for="cf-model" style="font-size:.78rem">model</label>
<input class="form-control form-control-sm" id="cf-model" placeholder="model">
</div>
<div class="col-md-6">
<label class="form-label" for="cf-api-key-env" style="font-size:.78rem">api_key_env</label>
<input class="form-control form-control-sm" id="cf-api-key-env" placeholder="api_key_env (optional)">
</div>
<div class="col-md-3">
<label class="form-label" for="cf-timeout" style="font-size:.78rem">timeout_seconds</label>
<input class="form-control form-control-sm" type="number" id="cf-timeout" placeholder="timeout_seconds">
</div>
<div class="col-md-3">
<label class="form-label" for="cf-max-tokens" style="font-size:.78rem">max_output_tokens</label>
<input class="form-control form-control-sm" type="number" id="cf-max-tokens" placeholder="max_output_tokens">
</div>
</div>
<div id="cloud-fallback-error" class="alert alert-warning mt-3 mb-0 py-2" style="display:none;font-size:.78rem"></div>
<div class="d-flex align-items-center justify-content-between mt-3">
<span id="cloud-fallback-status" class="text-muted" style="font-size:.72rem"></span>
<div class="d-flex gap-2">
<button class="btn btn-outline-secondary btn-sm" onclick="clearCloudFallback()">Remove</button>
<button class="btn btn-primary btn-sm" onclick="saveCloudFallback()">Save</button>
</div>
</div>
</div>
</div>
</div>
<!-- Watchdog -->
<div class="col-12">
<div class="card" id="watchdog-card">
<div class="card-header">
<h3 class="card-title"><span class="me-2" data-icon="alert"></span>Watchdog</h3>
<div class="card-actions d-flex align-items-center gap-2">
<span id="watchdog-last-tick" class="badge bg-secondary">no tick</span>
</div>
</div>
<div class="card-body py-3">
<div class="row g-3">
<div class="col-md-4">
<div class="text-muted mb-1" style="font-size:.78rem">Last tick</div>
<div id="wd-tick-time" class="small">-</div>
</div>
<div class="col-md-4">
<div class="text-muted mb-1" style="font-size:.78rem">Sessions seen</div>
<div id="wd-sessions" class="small">-</div>
</div>
<div class="col-md-4">
<div class="text-muted mb-1" style="font-size:.78rem">Flagged verdicts</div>
<div id="wd-flagged" class="small">-</div>
</div>
</div>
<div class="mt-3">
<div class="text-muted mb-1" style="font-size:.78rem">Open alerts</div>
<div id="wd-alerts" class="small">-</div>
</div>
<div class="mt-3">
<div class="text-muted mb-2" style="font-size:.78rem">Notification channels</div>
<div id="wd-channels" class="d-flex flex-wrap gap-2">-</div>
</div>
<div class="d-flex align-items-center justify-content-between mt-3">
<span id="watchdog-status" class="text-muted" style="font-size:.72rem"></span>
<div class="d-flex gap-2">
<button class="btn btn-outline-secondary btn-sm" onclick="loadWatchdog()">Refresh</button>
<button class="btn btn-success btn-sm" onclick="testWatchdogAlert()">Test Alert</button>
</div>
</div>
</div>
</div>
</div>
<!-- One knob table: runtime and persisted state pair by config key,
so drift (live value the next restart will undo) reads on the
same row as the controls that cause and fix it. -->
<div class="col-12">
<div class="card">
<div class="card-header">
<h3 class="card-title"><span class="me-2" data-icon="settings"></span>Knobs</h3>
<div class="card-actions d-flex align-items-center gap-2">
<span id="config-save-status" class="text-muted" style="font-size:.72rem"></span>
<button class="btn btn-primary btn-sm" id="config-save-btn" onclick="saveAllConfig()" disabled>Save</button>
</div>
</div>
<div class="card-body settings-body">
<table class="table table-vcenter card-table knobs-table">
<thead>
<tr>
<th title="Dotted config.yaml path. Pairs the live and persisted halves of the same knob.">Knob</th>
<th title="Live on the next request - in-memory only, reverts on restart, no config.yaml write.">Live</th>
<th title="Applied on restart - allowlisted keys, written to config/config.local.yaml (machine-local overlay; config.yaml stays clean).">Persisted</th>
<th title="Where the persisted value came from: config.yaml (base) or config.local.yaml (overlay).">Layer</th>
</tr>
</thead>
<tbody id="config-list">
<tr><td colspan="4" class="text-muted fst-italic small px-2 py-1">Loading config&hellip;</td></tr>
</tbody>
</table>
<p class="text-muted settings-hint">Live on the next request &mdash; in-memory only, reverts on restart, no <code>config.yaml</code> write.</p>
<p class="text-muted settings-hint">Applied on restart &mdash; allowlisted keys, written to <code>config/config.local.yaml</code> (machine-local overlay; <code>config.yaml</code> stays clean).</p>
</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>
<!-- Toast (Bootstrap native) -->
<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/controls.html — single-file controls page
API base: relative (works under /admin/)
═══════════════════════════════════════════════════ */
const API = ''; // relative to /admin/
const SSE_URL = '/events/decisions'; // root-level endpoint
/* ═─ Inline icon helper (no tabler-icons webfont) ═─ */
function icon(name, size) {
const s = size || 18;
const svgs = {
zap: `<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"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>`,
settings: `<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="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>`,
database: `<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"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>`,
cpu: `<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="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><line x1="9" y1="1" x2="9" y2="4"/><line x1="15" y1="1" x2="15" y2="4"/><line x1="9" y1="20" x2="9" y2="23"/><line x1="15" y1="20" x2="15" y2="23"/><line x1="20" y1="9" x2="23" y2="9"/><line x1="20" y1="14" x2="23" y2="14"/><line x1="1" y1="9" x2="4" y2="9"/><line x1="1" y1="14" x2="4" y2="14"/></svg>`,
chevronDown:`<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="6 9 12 15 18 9"/></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>`,
info: `<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="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></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;
}
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;
}
}
/* Preserves the server's `detail` on a non-2xx response instead of flattening
it to a generic status message -- the classifier-config validation errors
(e.g. "cloud_primary is required when cloud_primary_auto is false") are
exactly the information a save-time failure needs to show. */
async function apiFetchDetail(url, opts = {}) {
const resp = await fetch(url, opts);
const body = await resp.json().catch(() => ({}));
if (!resp.ok) {
throw new Error(body.detail || `${resp.status} ${resp.statusText}`);
}
return body;
}
/* ═══════════════════════════════════════
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.onerror = () => {
navbarSse('offline', 'reconnecting');
};
} catch (e) {
navbarSse('offline');
}
}
/* ═══════════════════════════════════════
OPERATIONAL TRIGGERS
═══════════════════════════════════════ */
async function triggerJob(action) {
const statusEl = document.getElementById('job-status');
let url;
switch(action) {
case 'refresh-catalog': url = `${API}api/refresh-catalog`; break;
case 'seed-energy': url = `${API}api/seed-energy?samples=5`; break;
case 'apply-feedback': url = `${API}api/apply-feedback?dry_run=true`; break;
case 'restart-service': url = `${API}api/restart-service`; break;
default: return;
}
statusEl.innerHTML = `<span style="color:var(--tblr-warning)">Running ${action}&hellip;</span>`;
const resp = await apiFetch(url, { method:'POST' });
if (resp) {
if (resp.status === 'restarting') {
statusEl.innerHTML = `<span style="color:var(--tblr-danger)">Service restarting, page will reload automatically</span>`;
toast('Service restart triggered', 'info');
setTimeout(() => location.reload(), 5000);
} else {
statusEl.innerHTML = `<span style="color:var(--tblr-success)">${action}: ${resp.status || 'done'}</span>`;
toast(`${action} completed`, 'success');
}
} else {
statusEl.innerHTML = `<span style="color:var(--tblr-danger)">${action} failed</span>`;
toast(`${action} failed`, 'error');
}
}
/* ═══════════════════════════════════════
RUNTIME KNOBS
═══════════════════════════════════════ */
function knobDisplayValue(v) {
if (v !== null && typeof v === 'object') {
if ('enabled' in v) return String(v.enabled);
try { return JSON.stringify(v); } catch (_) { return String(v); }
}
if (typeof v === 'boolean') return String(v);
return String(v);
}
// Knobs (and Persisted Config keys, below) with only a few legal values get
// a <select> instead of a free-text input — the values come straight from
// the Pydantic enum/validator on the backend, so this list stays in sync
// with config.py by hand, not by inspection.
const ENUM_VALUES = {
default_flex_preference: ['no-flex', 'auto', 'prefer-flex', 'force-flex'],
'routing.default_flex_preference': ['no-flex', 'auto', 'prefer-flex', 'force-flex'],
'logging.level': ['debug', 'info', 'warning', 'error'],
};
function enumSelect(key, value, { dataAttr = 'data-knob', onchangeExpr = '' } = {}) {
const opts = ENUM_VALUES[key].map(v =>
`<option value="${v}"${v === value ? ' selected' : ''}>${v}</option>`
).join('');
const onchange = onchangeExpr ? ` onchange="${onchangeExpr}"` : '';
return `<select class="form-select form-select-sm toggle-input" ${dataAttr}="${key}"${onchange}>${opts}</select>`;
}
// A dotted key reads as scope + leaf; dimming the scope makes a column of
// `objective.*` / `pinch.*` keys scannable by the part that differs.
function keyHtml(key) {
const cut = key.lastIndexOf('.');
if (cut === -1) return escapeHtml(key);
return `<span class="scope">${escapeHtml(key.slice(0, cut + 1))}</span>${escapeHtml(key.slice(cut + 1))}`;
}
// Units the config file expresses but the key name doesn't.
const UNITS = {
'objective.max_energy_per_request': 'kWh',
'objective.plan_kwh_per_period': 'kWh',
'classifier.cooldown_seconds': 's',
'classifier.degraded_warn_min': 'decisions/24h',
'classifier.max_input_chars': 'chars',
};
/* A consequence the control itself cannot show. Keyed by both the runtime knob
name and the persisted-config key, since these knobs appear in both panels.
Keep `text` to one short line -- the meta track shares a row with the key and
the control -- and put the rest in `title`.
`cls` picks the register: bg-warning (amber, alert glyph) where flipping the
control COSTS something, bg-info (blue, info glyph) for a plain relationship
between two rows. NOT bg-secondary -- that is the provenance badge's colour,
and two grey badges in one meta track read as one run-on label. */
const PROBE_NOTE = {
text: 'off stops the cache-loss measurement',
title: 'telemetry only: the evidence for whether context pruning needs rewriting',
cls: 'bg-warning',
glyph: 'alert',
};
/* The gate and the dial are two rows that only mean anything together, and
they render adjacent in both panels (admin.py keeps _runtime_state and
_CONFIG_GET_ORDER in this order). Saying so in four words beats a paragraph
above the list. */
const INCUMBENT_GATE_NOTE = {
text: 'enables the challenger dial',
title: 'Off: ranking is byte-identical to neutral and the challenger dial has no effect.',
cls: 'bg-info',
glyph: 'info',
};
const CHALLENGER_DIAL_NOTE = {
text: 'blank = neutral, 0 = full penalty',
title: 'Challenger cache rate, 0 to 1 (not a percentage). Blank follows objective.assumed_cache_rate, which costs challengers nothing; 0 prices them as fully cold prompts. Needs incumbent_cache_pricing on.',
cls: 'bg-info',
glyph: 'info',
};
/* Same pairing as the gate and the dial: a switch and the window it gates,
rendered adjacent in both panels. The window is the knob that decides how
long ONE classification keeps steering routing, so its note carries the
measurement rather than a description. */
const SESSION_CACHE_GATE_NOTE = {
text: 'enables the reuse window',
title: 'Off: every turn is classified fresh and the window below decides nothing.',
cls: 'bg-info',
glyph: 'info',
};
const SESSION_CACHE_TTL_NOTE = {
text: 'how long one label steers routing',
title: 'Seconds a session reuses its last classification, 5 to 7200. No blank and no zero: to stop reusing labels turn session_cache off, because 0 still caches and the classifier-failure cascade still replays the entry. Measured on live traffic: one classification drove 107 consecutive turns.',
cls: 'bg-info',
glyph: 'info',
};
const DEGRADED_WARN_MIN_NOTE = {
text: 'silent below this many decisions in 24h',
title: 'No warning is raised until the classifier completes at least this many decisions in a 24-hour window. Below the threshold the degraded-count warning is suppressed entirely.',
cls: 'bg-info',
glyph: 'info',
};
const DEGRADED_WARN_THRESHOLD_NOTE = {
text: 'share of degraded classifications that warns',
title: 'When the fraction of degraded (non-ok) classifier verdicts exceeds this ratio within the warn-min window, an amber badge appears on the admin navbar. 0.01 = 1% of classifications degraded.',
cls: 'bg-info',
glyph: 'info',
};
const KNOB_NOTES = {
pinch_prefix_probe: PROBE_NOTE,
'pinch.prefix_probe': PROBE_NOTE,
session_cache_enabled: SESSION_CACHE_GATE_NOTE,
'session_cache.enabled': SESSION_CACHE_GATE_NOTE,
session_cache_staleness_seconds: SESSION_CACHE_TTL_NOTE,
'session_cache.staleness_seconds': SESSION_CACHE_TTL_NOTE,
incumbent_cache_pricing: INCUMBENT_GATE_NOTE,
'objective.incumbent_cache_pricing': INCUMBENT_GATE_NOTE,
incumbent_challenger_cache_rate: CHALLENGER_DIAL_NOTE,
'objective.incumbent_challenger_cache_rate': CHALLENGER_DIAL_NOTE,
classifier_degraded_warn_min: DEGRADED_WARN_MIN_NOTE,
'classifier.degraded_warn_min': DEGRADED_WARN_MIN_NOTE,
classifier_degraded_warn_threshold: DEGRADED_WARN_THRESHOLD_NOTE,
'classifier.degraded_warn_threshold': DEGRADED_WARN_THRESHOLD_NOTE,
};
function noteBadge(key) {
const note = KNOB_NOTES[key];
if (!note) return '';
return `<span class="badge ${note.cls}" title="${escapeHtml(note.title)}">${icon(note.glyph || 'alert', 12)} ${escapeHtml(note.text)}</span>`;
}
/* Bounds for the numeric runtime knobs, so the browser refuses out of range
before the server has to. A knob absent here renders as a plain number
field. `placeholder` is what an EMPTY field means, and for the dial that is
neutral -- never zero, which is the other end of the same dial. */
const NUMBER_BOUNDS = {
incumbent_challenger_cache_rate: { min: 0, max: 1, step: 0.001, placeholder: 'neutral' },
// No `placeholder`, deliberately: an empty field means nothing here, and a
// placeholder is how the dial above says that it does. Blank posts null and
// the server refuses it, naming session_cache_enabled as the switch the
// operator was reaching for. min is 5 rather than 0 -- 0 is not "off", it
// still caches and the classifier-failure cascade still replays the entry.
session_cache_staleness_seconds: { min: 5, max: 7200, step: 1 },
classifier_degraded_warn_threshold: { min: 0.001, max: 1, step: 0.01 },
classifier_max_input_chars: { min: 0, step: 1000 },
};
// Category definitions for the knob table section headers. Order here matches
// _CONFIG_GET_ORDER in admin.py so sections render in the same sequence the
// YAML uses. `icon` picks a renderStaticIcons-compatible name.
const CATEGORIES = [
{ id: 'routing_quality_cost', label: 'Routing / Quality / Cost', icon: 'settings' },
{ id: 'classification', label: 'Classification', icon: 'info' },
{ id: 'caching_context', label: 'Caching / Context', icon: 'database' },
{ id: 'local_hardware', label: 'Local Hardware', icon: 'cpu' },
{ id: 'safety_nets', label: 'Safety Nets', icon: 'alert' },
{ id: 'watchdog', label: 'Watchdog', icon: 'alert' },
];
// Collapsible category and advanced-section state. Persisted across 30s
// re-renders: toggling mutates this Set, and renderKnobsTable reads it.
// Category keys are `cat.<id>.collapsed`; advanced keys are `cat.<id>.adv`.
let collapsedCategories = new Set();
// Categories whose Advanced accordion has been seeded collapsed. Per category,
// not global: see seedAdvancedCollapsed.
let _advSeededCategories = new Set();
/* SEED_ADV_COLLAPSED:BEGIN */
function seedAdvancedCollapsed(byCategory, rows, categoryIds, seeded, collapsed) {
// Seed a category's Advanced accordion as collapsed the FIRST time that
// category renders an advanced row, not on the first render overall. The
// runtime payload can arrive before the config payload, and persisted-only
// advanced knobs (watchdog.detector.*) are absent from that early render; a
// category marked seeded before its advanced rows exist would load expanded.
// Later renders leave whatever the user toggled alone.
for (const id of categoryIds) {
if (seeded.has(id)) continue;
const hasAdvanced = (byCategory[id] || []).some(key => {
const row = rows[key];
return (row.runtimeItem && row.runtimeItem.advanced) ||
(row.persistedItem && row.persistedItem.advanced);
});
if (!hasAdvanced) continue;
seeded.add(id);
collapsed.add(id + '.adv');
}
}
/* SEED_ADV_COLLAPSED:END */
/* One knob table replaces the old runtime-list/config-list div columns.
Runtime rows and persisted rows pair by the dotted config key the
/admin/api/runtime payload carries in `config_key` -- the join lives here,
once, instead of being re-derived from the knob registries in JS. */
let _runtimeState = null; // /admin/api/runtime payload
let _configState = null; // /admin/api/config payload
/* MERGE_KNOB_ROWS:BEGIN */
function normalizeKnobValue(v) {
// Numbers compare numerically (0.1 vs "0.10" is one value), booleans stay
// booleans, and null/absent is "no information", which never disagrees.
if (v === null || v === undefined) return null;
if (typeof v === 'boolean') return v;
const n = Number(v);
return Number.isFinite(n) ? n : String(v);
}
function knobValuesDrift(a, b) {
const na = normalizeKnobValue(a);
const nb = normalizeKnobValue(b);
if (na === null || nb === null) return false;
return na !== nb;
}
function mergeKnobRows(runtimeItems, persistedItems) {
// Rows keyed by dotted config key = the UNION of the two payloads' keys.
// Persisted keys first, in the payload's order (the API returns allowlist
// order), then runtime-only knobs in the runtime payload's order. A
// one-sided knob gets a row with the other column empty.
const runtimeByConfigKey = {};
for (const [knob, item] of Object.entries(runtimeItems || {})) {
if (!item || !item.config_key) continue;
runtimeByConfigKey[item.config_key] = { ...item, knob };
}
const rows = {};
for (const key of Object.keys(persistedItems || {})) {
rows[key] = {
configKey: key,
runtimeItem: runtimeByConfigKey[key] || null,
persistedItem: persistedItems[key] || null,
};
}
for (const [configKey, item] of Object.entries(runtimeByConfigKey)) {
if (rows[configKey]) continue;
rows[configKey] = { configKey, runtimeItem: item, persistedItem: null };
}
// Drift reads ONLY the runtime payload: its `persisted` field is the merged
// base+overlay value from load_config, so it says what a restart would
// load. The persisted column's input can hold an unsaved dirty edit, which
// is NOT what a restart would load, so it never feeds this. Runtime-only
// knobs (no persisted column) drift the same way -- the restart reverts
// them just the same.
for (const row of Object.values(rows)) {
row.drift = row.runtimeItem !== null
&& knobValuesDrift(row.runtimeItem.runtime, row.runtimeItem.persisted);
}
return rows;
}
/* MERGE_KNOB_ROWS:END */
// The restart signal, in the knob cell where the row's identity is read:
// what the file would load vs what is running now, both resolved. The
// persisted column may show a raw/blank YAML value while this tooltip shows
// the resolved one -- that distinction is deliberate.
function driftBadge(rt) {
const persistedStr = knobDisplayValue(rt.persisted);
const runtimeStr = knobDisplayValue(rt.runtime);
return `<span class="badge bg-warning-subtle text-warning" title="config.yaml says ${escapeHtml(persistedStr)}; running with ${escapeHtml(runtimeStr)}">reverts on restart</span>`;
}
function runtimeControlHtml(knob, knobState) {
const persisted = knobState.persisted;
const runtime = knobState.runtime;
const runtimeStr = knobDisplayValue(runtime);
if (typeof persisted === 'boolean') {
return `<div class="form-check form-switch">
<input class="form-check-input toggle-input" type="checkbox" data-knob="${knob}" ${runtime ? 'checked' : ''} onchange="toggleKnob('${knob}', this.checked)">
</div>`;
}
if (ENUM_VALUES[knob]) {
return enumSelect(knob, runtime, { onchangeExpr: `toggleKnob('${knob}', this.value)` });
}
if (typeof persisted === 'number') {
// Blank posts null, NOT 0. `Number('')` is 0 in JavaScript and 0 is a
// real, costly setting on both numeric knobs, so clearing a field must
// never be read as choosing it. What null MEANS is the server's call.
const b = NUMBER_BOUNDS[knob] || {};
const attrs = [
b.min !== undefined ? `min="${b.min}"` : '',
b.max !== undefined ? `max="${b.max}"` : '',
b.step !== undefined ? `step="${b.step}"` : '',
b.placeholder ? `placeholder="${escapeHtml(b.placeholder)}"` : '',
].filter(Boolean).join(' ');
const shown = (runtime === null || runtime === undefined) ? '' : String(runtime);
return `<input class="form-control form-control-sm toggle-input" type="number" ${attrs} data-knob="${knob}" value="${escapeHtml(shown)}" onchange="toggleKnob('${knob}', this.value.trim() === '' ? null : Number(this.value))">`;
}
if (typeof persisted === 'string') {
return `<input class="form-control form-control-sm toggle-input" type="text" data-knob="${knob}" value="${escapeHtml(runtimeStr)}" onchange="toggleKnob('${knob}', this.value)">`;
}
return `<span class="text-muted small">${escapeHtml(runtimeStr)}</span>`;
}
function persistedControlHtml(key, entry, dirtyValue) {
// `dirtyValue` is the user's current input on a re-render that would
// otherwise reset it (loadControls re-runs after every toggle and ~1.5s
// after a save); undefined means render from the payload value.
const dirty = dirtyValue !== undefined && dirtyValue !== null;
const val = dirty
? (typeof entry.value === 'boolean' ? dirtyValue === 'true' : dirtyValue)
: entry.value;
let control;
if (typeof val === 'boolean') {
// A switch, matching the Live column -- the bare checkbox here was the
// one control on the page that didn't look like the others.
control = `<div class="form-check form-switch">
<input class="form-check-input config-input toggle-input" type="checkbox" ${val ? 'checked' : ''} data-config-input>
</div>`;
} else if (key === 'routing.default_profile') {
control = profileSelect(val);
} else if (ENUM_VALUES[key]) {
control = enumSelect(key, val, { dataAttr: 'data-config-input' });
} else {
control = `<input class="form-control form-control-sm config-input toggle-input" type="text" value="${val === null ? '' : escapeHtml(String(val))}" placeholder="${val === null ? 'null' : ''}" data-config-input>`;
}
// Units the config file expresses but the key name doesn't, plus the
// knob's consequence note, stay beside the persisted control.
const hints = [
UNITS[key] ? escapeHtml(UNITS[key]) : '',
noteBadge(key),
].filter(Boolean).join(' ');
return `${control}${hints ? `<span class="d-inline-flex align-items-center gap-1 ms-2">${hints}</span>` : ''}`;
}
function knobRowHtml(row, catId, isAdv) {
const rt = row.runtimeItem;
const entry = row.persistedItem;
const keyCell = row.drift
? `${keyHtml(row.configKey)} ${driftBadge(rt)}`
: keyHtml(row.configKey);
const liveCell = rt
? runtimeControlHtml(rt.knob, rt)
: '<span class="text-muted small">-</span>';
let persistedCell = '<span class="text-muted small">-</span>';
let layerCell = '<td class="setting-meta"></td>';
let extraAttrs = '';
if (entry) {
persistedCell = persistedControlHtml(row.configKey, entry, row.dirtyValue);
// Provenance is the layer column: where the effective value came from.
let layer = '';
if (entry.source === 'overlay') {
layer = '<span class="badge bg-purple" title="set in config.local.yaml">overlay</span>';
} else if (entry.source === 'base') {
layer = '<span class="badge bg-secondary" title="from config.yaml">base</span>';
}
layerCell = `<td class="setting-meta">${layer}</td>`;
extraAttrs = ` data-key="${escapeHtml(row.configKey)}" data-orig="${escapeHtml(String(entry.value === null ? '' : entry.value))}"`;
}
if (catId) extraAttrs += ` data-cat="cat-${catId}"`;
if (isAdv) extraAttrs += ' data-adv="true"';
return `<tr class="setting-row"${extraAttrs}>
<td class="setting-key">${keyCell}</td>
<td>${liveCell}</td>
<td>${persistedCell}</td>
${layerCell}
</tr>`;
}
// Before a re-render: the current input of every dirty persisted row, so an
// in-flight unsaved edit survives the refetch instead of silently reverting.
function captureDirtyPersistedInputs() {
const saved = {};
document.querySelectorAll('#config-list .setting-row[data-key]').forEach(row => {
const input = row.querySelector('[data-config-input]');
if (!input) return;
const current = input.type === 'checkbox' ? String(input.checked) : input.value;
if (current !== row.getAttribute('data-orig')) {
saved[row.getAttribute('data-key')] = current;
}
});
return saved;
}
// One render path over both payloads. Either may be null (a failed fetch,
// or the runtime fetch completing before the config one): rows render from
// whatever is available, and the whole table re-renders idempotently.
function renderKnobsTable() {
const list = document.getElementById('config-list');
const dirtyInputs = captureDirtyPersistedInputs();
const rows = mergeKnobRows(_runtimeState, _configState);
const keys = Object.keys(rows);
if (!keys.length) {
list.innerHTML = '<tr><td colspan="4" class="text-muted fst-italic small px-2 py-1">No knobs to show</td></tr>';
return;
}
// Group rows by category using the category/advanced metadata from the API.
const byCategory = {};
const uncategorized = [];
for (const key of keys) {
const row = rows[key];
row.dirtyValue = row.persistedItem ? dirtyInputs[key] : undefined;
const cat = (row.runtimeItem && row.runtimeItem.category) ||
(row.persistedItem && row.persistedItem.category) || '';
if (!cat) { uncategorized.push(key); continue; }
if (!byCategory[cat]) byCategory[cat] = [];
byCategory[cat].push(key);
}
const html = [];
// Advanced subsections load collapsed, so the user sees the clean knob list
// before the Advanced expander. Seeded per category, once.
seedAdvancedCollapsed(byCategory, rows, CATEGORIES.map(c => c.id),
_advSeededCategories, collapsedCategories);
for (const cat of CATEGORIES) {
const catKeys = byCategory[cat.id] || [];
if (!catKeys.length) continue;
const isCatCollapsed = collapsedCategories.has(cat.id + '.collapsed');
// Category header with chevron toggle, rendered via data-icon for
// renderStaticIcons(). All 4 columns spanned so the click target
// is the full table width.
html.push(`<tr class="cat-header" data-cat="cat-${cat.id}">
<th colspan="4" id="cat-${cat.id}" class="cat-header-cell" onclick="toggleCategory('${cat.id}')">
<span class="d-inline-flex align-items-center gap-2">
<span data-icon="chevronDown" class="cat-chevron${isCatCollapsed ? '' : ' cat-chevron-open'}"></span>
<span data-icon="${cat.icon}" class="cat-icon"></span>
<span class="cat-label">${cat.label}</span>
</span>
</th>
</tr>`);
// Split into standard and advanced rows.
const standard = [];
const advanced = [];
for (const key of catKeys) {
const row = rows[key];
const isAdv = (row.runtimeItem && row.runtimeItem.advanced) ||
(row.persistedItem && row.persistedItem.advanced) || false;
(isAdv ? advanced : standard).push(key);
}
// Standard (non-advanced) rows
for (const key of standard) {
html.push(knobRowHtml(rows[key], cat.id, false));
}
// Advanced sub-header + rows (default collapsed on first render)
if (advanced.length) {
const advKey = cat.id + '.adv';
const isAdvCollapsed = collapsedCategories.has(advKey);
html.push(`<tr class="adv-subheader" data-cat="cat-${cat.id}" data-adv-subheader="${cat.id}">
<th colspan="4" class="adv-subheader-cell" onclick="toggleAdvanced('${cat.id}')">
<span class="d-inline-flex align-items-center gap-2 text-muted">
<span data-icon="chevronDown" class="cat-chevron${isAdvCollapsed ? '' : ' cat-chevron-open'}"></span>
<span class="small">Advanced</span>
</span>
</th>
</tr>`);
for (const key of advanced) {
html.push(knobRowHtml(rows[key], cat.id, true));
}
}
}
// Uncategorized rows at the end (fallback for knob metadata edge cases).
for (const key of uncategorized) {
html.push(knobRowHtml(rows[key], '', false));
}
list.innerHTML = html.join('');
// Apply collapsed state to category rows after rendering.
for (const cat of CATEGORIES) {
if (collapsedCategories.has(cat.id + '.collapsed')) {
const rows = document.querySelectorAll(`#config-list tr[data-cat="cat-${cat.id}"]:not(.cat-header)`);
rows.forEach(r => { r.style.display = 'none'; });
}
if (collapsedCategories.has(cat.id + '.adv')) {
const rows = document.querySelectorAll(`#config-list tr[data-cat="cat-${cat.id}"][data-adv="true"]`);
rows.forEach(r => { r.style.display = 'none'; });
}
}
// Re-render data-icon attributes that were just injected.
renderStaticIcons();
updateDefaultProfileHint();
markDirty();
// If the URL carries a category anchor, expand that section now.
if (window.location.hash && window.location.hash.startsWith('#cat-')) {
const hashCat = window.location.hash.replace('#cat-', '');
if (collapsedCategories.has(hashCat + '.collapsed')) {
collapsedCategories.delete(hashCat + '.collapsed');
const rows = document.querySelectorAll(`#config-list tr[data-cat="cat-${hashCat}"]:not(.cat-header)`);
rows.forEach(r => { r.style.display = ''; });
const chevron = document.querySelector(`#config-list tr[data-cat="cat-${hashCat}"].cat-header .cat-chevron`);
if (chevron) chevron.classList.add('cat-chevron-open');
}
}
}
// Toggle a category section: collapse/expand all knob rows within it.
function toggleCategory(catId) {
const key = catId + '.collapsed';
if (collapsedCategories.has(key)) {
collapsedCategories.delete(key);
} else {
collapsedCategories.add(key);
}
const isCollapsed = collapsedCategories.has(key);
// All knob rows + advanced sub-header under this category.
const rows = document.querySelectorAll(
`#config-list tr[data-cat="cat-${catId}"]:not(.cat-header)`
);
rows.forEach(r => { r.style.display = isCollapsed ? 'none' : ''; });
// Flip the chevron on the category header.
const chevron = document.querySelector(
`#config-list tr[data-cat="cat-${catId}"].cat-header .cat-chevron`
);
if (chevron) chevron.classList.toggle('cat-chevron-open', !isCollapsed);
}
// Toggle an advanced subsection within a category: collapse/expand only the
// rows flagged data-adv="true" under that category.
function toggleAdvanced(catId) {
const key = catId + '.adv';
if (collapsedCategories.has(key)) {
collapsedCategories.delete(key);
} else {
collapsedCategories.add(key);
}
const isCollapsed = collapsedCategories.has(key);
const rows = document.querySelectorAll(
`#config-list tr[data-cat="cat-${catId}"][data-adv="true"]`
);
rows.forEach(r => { r.style.display = isCollapsed ? 'none' : ''; });
// Flip the chevron on the advanced sub-header.
const chevron = document.querySelector(
`#config-list tr[data-adv-subheader="${catId}"] .cat-chevron`
);
if (chevron) chevron.classList.toggle('cat-chevron-open', !isCollapsed);
}
function renderRuntime(state) {
_runtimeState = state;
renderKnobsTable();
}
/* A knob POST that keeps the server's `detail`. The generic apiFetch swallows
it, which is fine for a knob that cannot be refused — local_compute_enabled
can be, and "Failed to toggle" instead of the reason would defeat the whole
point of refusing rather than warning. */
async function postKnob(knob, value) {
const resp = await fetch(`${API}api/runtime/${encodeURIComponent(knob)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value }),
});
const body = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(body.detail || `${resp.status} ${resp.statusText}`);
return body;
}
async function toggleKnob(knob, value) {
const isSwitch = typeof value === 'boolean';
try {
await postKnob(knob, value);
toast(`${knob} ${isSwitch ? 'toggled' : 'set'}`, 'success');
if (knob === LOCAL_COMPUTE_KNOB) renderLocalCompute(value);
// A numeric knob can be stored as something other than what was typed --
// a blank dial posts null and the server resolves it to the neutral rate --
// so re-read rather than leave the field disagreeing with the router.
if (NUMBER_BOUNDS[knob] || value === null) loadControls();
} catch (e) {
toast(e.message || `Failed to ${isSwitch ? 'toggle' : 'set'} ${knob}`, 'error');
// Re-read rather than assume the inverse: the request may have failed
// after the flip, and a switch showing a value the router does not hold
// is worse than a round-trip.
loadControls();
}
}
/* ═══════════════════════════════════════
LOCAL COMPUTE ("gaming mode")
═══════════════════════════════════════ */
const LOCAL_COMPUTE_KNOB = 'local_compute_enabled';
function renderLocalCompute(enabled) {
const toggle = document.getElementById('local-compute-toggle');
const state = document.getElementById('local-compute-state');
if (!toggle || !state) return;
toggle.checked = enabled === true;
state.className = enabled ? 'badge bg-success' : 'badge bg-warning';
state.textContent = enabled ? 'local compute on' : 'gaming mode';
}
function showLocalComputeError(message) {
const box = document.getElementById('local-compute-error');
if (!box) return;
if (!message) {
box.style.display = 'none';
box.textContent = '';
return;
}
box.textContent = message;
box.style.display = 'block';
}
async function setLocalCompute(enabled) {
showLocalComputeError('');
try {
await postKnob(LOCAL_COMPUTE_KNOB, enabled);
renderLocalCompute(enabled);
toast(enabled ? 'Local compute enabled' : 'Gaming mode on — local calls skipped', 'success');
loadControls(); // The generic knob row mirrors this one; keep them agreed.
} catch (e) {
// The refusal is the feature. Park it above the toggle where it stays
// readable, rather than only in a toast that vanishes in four seconds.
showLocalComputeError(e.message || 'Failed to change local compute');
renderLocalCompute(!enabled);
}
}
/* ═══════════════════════════════════════
PERSISTED CONFIG EDITOR
═══════════════════════════════════════ */
let _profilesCache = null;
function renderConfig(config) {
_configState = config;
renderKnobsTable();
}
function profileSelect(currentValue) {
// Always build a fresh option list so a failed /admin/api/profiles fetch or a
// hand-edited default_profile value never leaves the control blank. If the
// persisted value is not in the live list, render it as the lone option so
// the operator still sees (and can save back) the current value.
const profiles = _profilesCache || [];
const options = profiles.slice();
const optionNames = new Set(profiles.map(p => p.name));
if (currentValue !== null && currentValue !== undefined && currentValue !== '' && !optionNames.has(currentValue)) {
options.push({ name: currentValue, zero_admit: false, source: 'orphan' });
}
const opts = options.map(p =>
`<option value="${escapeHtml(p.name)}"${p.name === currentValue ? ' selected' : ''}>${escapeHtml(p.name)}</option>`
).join('');
return `<select class="form-select form-select-sm config-input toggle-input" data-config-input data-profile-select>${opts}</select>`;
}
function updateDefaultProfileHint() {
const row = document.querySelector('#config-list .setting-row[data-key="routing.default_profile"]');
if (!row) return;
const input = row.querySelector('[data-profile-select]');
if (!input) return;
const profiles = _profilesCache || [];
const selected = profiles.find(p => p.name === input.value);
const meta = row.querySelector('.setting-meta');
const existing = meta.querySelector('.default-profile-hint');
if (existing) existing.remove();
if (selected && selected.zero_admit) {
const hint = document.createElement('span');
hint.className = 'badge bg-warning default-profile-hint ms-2';
hint.title = 'This profile currently admits no models';
hint.innerHTML = `${icon('alert', 12)} admits 0 models`;
meta.appendChild(hint);
}
}
/* The inputs hold the values, so the only thing worth flagging is an edit that
hasn't been written yet: an amber rail on the row, a live count on Save. */
function rowValue(row) {
const input = row.querySelector('[data-config-input]');
return input.type === 'checkbox' ? String(input.checked) : input.value;
}
function markDirty() {
const rows = document.querySelectorAll('#config-list .setting-row[data-key]');
let dirty = 0;
rows.forEach(row => {
const changed = rowValue(row) !== row.getAttribute('data-orig');
row.classList.toggle('is-dirty', changed);
if (changed) dirty += 1;
});
const btn = document.getElementById('config-save-btn');
btn.disabled = dirty === 0;
btn.textContent = dirty ? `Save ${dirty} change${dirty === 1 ? '' : 's'}` : 'Save';
return dirty;
}
async function saveAllConfig() {
const rows = [...document.querySelectorAll('#config-list .setting-row[data-key]')]
.filter(row => rowValue(row) !== row.getAttribute('data-orig'));
const statusEl = document.getElementById('config-save-status');
if (!rows.length) return;
let ok = 0;
let failed = 0;
for (const row of rows) {
const key = row.getAttribute('data-key');
const input = row.querySelector('[data-config-input]');
const val = input.type === 'checkbox' ? input.checked : input.value;
let parsed = val;
if (typeof val === 'string') {
if (val.trim() === '') {
parsed = null;
} else {
const num = Number(val);
if (!isNaN(num)) parsed = num;
}
}
statusEl.textContent = `saving ${key}…`;
statusEl.style.color = 'var(--tblr-secondary)';
const res = await apiFetch(
`${API}api/config/${encodeURIComponent(key)}`,
{ method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({ value: parsed }) }
);
if (res === null) {
failed += 1;
row.style.color = 'var(--tblr-danger)';
} else {
ok += 1;
row.style.color = '';
}
}
const allOk = failed === 0;
statusEl.textContent = allOk ? `saved ${ok}` : `${failed} write(s) failed, ${ok} saved`;
statusEl.style.color = allOk ? 'var(--tblr-success)' : 'var(--tblr-danger)';
if (allOk) toast('Config updated', 'success');
setTimeout(loadControls, 1500);
}
/* ═══════════════════════════════════════
CLASSIFIER CONFIG
═══════════════════════════════════════
The profiles-page lesson applied here rather than relearned: this must
show what dispatch actually does, not a static echo of the config. The
GET response's resolved_primary comes from the SAME function classify()
calls (routing.cheapest_classifier_candidate), computed server-side --
this file never re-derives it, it only renders what the server already
resolved live. */
let _classifierConfigData = null;
function classifierModeFieldsHtml(mode, data) {
const cp = (data && data.cloud_primary) || {};
if (mode === 'cloud_llm') {
const auto = data ? !!data.cloud_primary_auto : false;
return `
<div class="form-check form-switch mb-2">
<input class="form-check-input" type="checkbox" id="classifier-cloud-auto"
${auto ? 'checked' : ''} onchange="onClassifierCloudAutoToggle()">
<label class="form-check-label" for="classifier-cloud-auto" style="font-size:.78rem">
auto (resolve the cheapest routable model, live)
</label>
</div>
<div id="classifier-cloud-pinned-fields" class="row g-2" style="${auto ? 'display:none' : ''}">
<div class="col-md-6">
<label class="form-label" for="classifier-cloud-base-url" style="font-size:.78rem">base_url</label>
<input class="form-control form-control-sm" id="classifier-cloud-base-url"
placeholder="base_url" value="${escapeHtml(cp.base_url || '')}">
</div>
<div class="col-md-6">
<label class="form-label" for="classifier-cloud-model" style="font-size:.78rem">model</label>
<input class="form-control form-control-sm" id="classifier-cloud-model"
placeholder="model" value="${escapeHtml(cp.model || '')}">
</div>
<div class="col-md-6">
<label class="form-label" for="classifier-cloud-api-key-env" style="font-size:.78rem">api_key_env</label>
<input class="form-control form-control-sm" id="classifier-cloud-api-key-env"
placeholder="api_key_env (optional)" value="${escapeHtml(cp.api_key_env || '')}">
</div>
<div class="col-md-3">
<label class="form-label" for="classifier-cloud-timeout" style="font-size:.78rem">timeout_seconds</label>
<input class="form-control form-control-sm" type="number" id="classifier-cloud-timeout"
placeholder="timeout_seconds" value="${cp.timeout_seconds != null ? cp.timeout_seconds : ''}">
</div>
</div>`;
}
if (mode === 'local_encoder') {
const enc = (data && data.encoder) || {};
return `
<div class="row g-2">
<div class="col-md-6">
<label class="form-label" for="classifier-encoder-model" style="font-size:.78rem">model</label>
<input class="form-control form-control-sm" id="classifier-encoder-model"
placeholder="model (default: BAAI/bge-large-en-v1.5)"
value="${escapeHtml(enc.model || '')}">
</div>
<div class="col-md-3">
<label class="form-label" for="classifier-encoder-device" style="font-size:.78rem">device</label>
<select class="form-select form-select-sm" id="classifier-encoder-device">
<option value="cpu" ${enc.device !== 'cuda' ? 'selected' : ''}>cpu</option>
<option value="cuda" ${enc.device === 'cuda' ? 'selected' : ''}>cuda</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label" for="classifier-encoder-threshold" style="font-size:.78rem">confidence_min</label>
<div class="input-group input-group-sm">
<input class="form-control form-control-sm" type="number" step="1" min="0" max="100"
id="classifier-encoder-threshold" placeholder="0-100"
value="${enc.confidence_min != null ? Math.round(enc.confidence_min * 100) : ''}">
<span class="input-group-text">%</span>
</div>
</div>
</div>`;
}
if (mode === 'local_decision') {
const dec = (data && data.decision) || {};
return `
<div class="row g-2">
<div class="col-md-6">
<label class="form-label" for="classifier-decision-base-url" style="font-size:.78rem"
title="Ollama server the classifier calls">base_url</label>
<input class="form-control form-control-sm" id="classifier-decision-base-url"
data-key="decision.base_url"
placeholder="http://localhost:11434"
value="${escapeHtml(dec.base_url || '')}">
</div>
<div class="col-md-6">
<label class="form-label" for="classifier-decision-model" style="font-size:.78rem"
title="Ollama model tag that answers the one-letter question">model</label>
<input class="form-control form-control-sm" id="classifier-decision-model"
data-key="decision.model"
placeholder="qwen3.5:4b"
value="${escapeHtml(dec.model || '')}">
</div>
<div class="col-md-3">
<label class="form-label" for="classifier-decision-num-ctx" style="font-size:.78rem"
title="Context window requested per call, in tokens">num_ctx</label>
<input class="form-control form-control-sm" type="number" min="256" step="256" id="classifier-decision-num-ctx"
data-key="decision.num_ctx"
placeholder="8192"
value="${dec.num_ctx != null ? dec.num_ctx : ''}">
</div>
<div class="col-md-3">
<label class="form-label" for="classifier-decision-timeout-s" style="font-size:.78rem"
title="Per-call timeout in seconds. A hung server costs this much per request until the backoff opens">timeout_s</label>
<input class="form-control form-control-sm" type="number" min="1" step="1" id="classifier-decision-timeout-s"
data-key="decision.timeout_s"
placeholder="10"
value="${dec.timeout_s != null ? dec.timeout_s : ''}">
</div>
<div class="col-md-3">
<label class="form-label" for="classifier-decision-confidence-min" style="font-size:.78rem"
title="Verdicts below this share of the option mass go to the cascade instead (0 to 1)">confidence_min</label>
<input class="form-control form-control-sm" type="number" min="0" max="1" step="0.05" id="classifier-decision-confidence-min"
data-key="decision.confidence_min"
placeholder="0.5"
value="${dec.confidence_min != null ? dec.confidence_min : ''}">
</div>
<div class="col-md-3">
<label class="form-label" for="classifier-decision-coverage-min" style="font-size:.78rem"
title="Minimum total probability on the option letters; below it the model did not answer with a letter (0 to 1)">coverage_min</label>
<input class="form-control form-control-sm" type="number" min="0" max="1" step="0.05" id="classifier-decision-coverage-min"
data-key="decision.coverage_min"
placeholder="0.3"
value="${dec.coverage_min != null ? dec.coverage_min : ''}">
</div>
</div>
<div class="form-check form-switch mt-2">
<input class="form-check-input" type="checkbox" id="classifier-decision-tier-enabled"
data-key="decision.tier_enabled"
${dec.tier_enabled ? 'checked' : ''}>
<label class="form-check-label" for="classifier-decision-tier-enabled" style="font-size:.78rem">tier_enabled</label>
</div>`;
}
return `<span class="text-muted" style="font-size:.78rem">Today's default: the local model configured under <code>classifier.model</code> above.</span>`;
}
function renderClassifierResolvedPrimary(data) {
const el = document.getElementById('classifier-resolved-primary');
if (data.mode === 'cloud_llm' && data.cloud_primary_auto) {
if (data.resolved_primary) {
el.style.display = '';
el.innerHTML = `resolves right now to <strong>${escapeHtml(data.resolved_primary.model_id)}</strong>
<span class="text-muted">(${escapeHtml(data.resolved_primary.provider)}, live)</span>`;
} else {
el.style.display = '';
el.innerHTML = `<span class="text-warning">no routable model currently has both a catalog price and clears the routing filters -- would fall through to the cascade</span>`;
}
} else {
el.style.display = 'none';
}
}
function onClassifierModeChange() {
const mode = document.getElementById('classifier-mode-select').value;
document.getElementById('classifier-mode-fields').innerHTML =
classifierModeFieldsHtml(mode, _classifierConfigData && _classifierConfigData.mode === mode ? _classifierConfigData : null);
document.getElementById('classifier-resolved-primary').style.display = 'none';
showClassifierConfigError('');
}
/* Deliberately NOT a call to onClassifierModeChange(): that fully
regenerates the fields from _classifierConfigData, which would discard
the very checkbox state this handler exists to react to (and anything
already typed into the pinned fields) the instant the box was ticked.
This only toggles which half is visible. */
function onClassifierCloudAutoToggle() {
const auto = document.getElementById('classifier-cloud-auto').checked;
const pinned = document.getElementById('classifier-cloud-pinned-fields');
if (pinned) pinned.style.display = auto ? 'none' : '';
document.getElementById('classifier-resolved-primary').style.display = 'none';
showClassifierConfigError('');
}
function showClassifierConfigError(message) {
const box = document.getElementById('classifier-config-error');
if (!message) { box.style.display = 'none'; box.textContent = ''; return; }
box.textContent = message;
box.style.display = 'block';
}
/* Read-only readout of classifier.candidate_categories. textContent rather
than innerHTML throughout: these strings come from a config file, and a
readout is not worth an XSS sink. */
function renderClassifierCandidateCategories(data) {
const el = document.getElementById('classifier-candidate-categories');
if (!el) return;
const candidates = data.candidate_categories || [];
const excluded = data.excluded_categories || [];
if (!candidates.length) { el.style.display = 'none'; el.textContent = ''; return; }
el.textContent = '';
const label = document.createElement('span');
label.textContent = 'labels the classifier may return: ';
el.appendChild(label);
const list = document.createElement('code');
list.textContent = candidates.join(', ');
el.appendChild(list);
if (excluded.length) {
const note = document.createElement('div');
note.className = 'mt-1';
const head = document.createElement('span');
head.textContent = 'excluded from the scoring axis, so they stop accumulating outcomes: ';
note.appendChild(head);
const ex = document.createElement('code');
ex.textContent = excluded.join(', ');
note.appendChild(ex);
el.appendChild(note);
}
el.style.display = '';
}
async function loadClassifierConfig() {
const data = await apiFetch(`${API}api/classifier-config`);
if (!data) {
/* Stays disabled; only the placeholder label flips, the option itself
is kept (a later success reuses the same select). */
document.getElementById('classifier-mode-select').disabled = true;
document.getElementById('classifier-save-btn').disabled = true;
const placeholder = document.querySelector('#classifier-mode-select option[value=""]');
if (placeholder) placeholder.textContent = 'unavailable';
showClassifierConfigError('classifier config unavailable; controls disabled');
return;
}
_classifierConfigData = data;
/* saveClassifierConfig re-awaits this after every successful save, so the
placeholder may already be gone by the second run -- guard, don't crash. */
document.querySelector('#classifier-mode-select option[value=""]')?.remove();
document.getElementById('classifier-mode-select').value = data.mode;
document.getElementById('classifier-mode-select').disabled = false;
document.getElementById('classifier-save-btn').disabled = false;
document.getElementById('classifier-mode-fields').innerHTML = classifierModeFieldsHtml(data.mode, data);
renderClassifierResolvedPrimary(data);
renderClassifierCandidateCategories(data);
/* The header badge states what is RUNNING, not what is saved: a save only
persists, and the router reads this block at startup, so the two differ
until the next restart. The amber badge is that gap made visible. */
const running = data.running_mode || data.mode;
const badge = document.getElementById('classifier-mode-badge');
badge.textContent = running + (data.mode_source === 'overlay' && !data.restart_pending ? ' (overlay)' : '');
badge.className = running === 'local_llm' ? 'badge bg-secondary' : 'badge bg-info';
const pending = document.getElementById('classifier-pending-badge');
if (data.restart_pending) {
pending.textContent = data.mode !== running
? `restart pending: ${data.mode}`
: 'restart pending: settings changed';
pending.title = `running ${running}, saved ${data.mode}; the router reads this block at startup`;
pending.style.display = '';
} else {
pending.style.display = 'none';
}
}
function collectClassifierConfigBody() {
const mode = document.getElementById('classifier-mode-select').value;
const body = { mode };
if (mode === 'cloud_llm') {
const auto = document.getElementById('classifier-cloud-auto').checked;
body.cloud_primary_auto = auto;
if (!auto) {
const baseUrl = document.getElementById('classifier-cloud-base-url').value.trim();
const model = document.getElementById('classifier-cloud-model').value.trim();
// Send null, not {base_url: "", model: ""}, when either is blank --
// an object with keys is truthy, so the backend's own "cloud_primary
// is required" check would not catch an empty-but-present block.
body.cloud_primary = (baseUrl && model) ? { base_url: baseUrl, model } : null;
if (body.cloud_primary) {
const apiKeyEnv = document.getElementById('classifier-cloud-api-key-env').value.trim();
if (apiKeyEnv) body.cloud_primary.api_key_env = apiKeyEnv;
const timeout = document.getElementById('classifier-cloud-timeout').value;
if (timeout !== '') body.cloud_primary.timeout_seconds = parseInt(timeout, 10);
}
}
} else if (mode === 'local_encoder') {
const encoder = {};
const model = document.getElementById('classifier-encoder-model').value.trim();
if (model) encoder.model = model;
encoder.device = document.getElementById('classifier-encoder-device').value;
const thresholdPct = document.getElementById('classifier-encoder-threshold').value;
// The field is 0-100 for a human to type ("80" meaning 80%); the backend
// wants the 0.0-1.0 probability classify_zero_shot actually returns.
if (thresholdPct !== '') encoder.confidence_min = parseFloat(thresholdPct) / 100;
body.encoder = encoder;
} else if (mode === 'local_decision') {
// A blank field is left OUT so the server default applies and keeps floating
// with the repo. Never `value || default`: that rewrites a deliberate 0
// (confidence_min 0 means "accept every verdict") into the default.
const decision = {};
const text = (id) => document.getElementById(id).value.trim();
const baseUrl = text('classifier-decision-base-url');
if (baseUrl !== '') decision.base_url = baseUrl;
const model = text('classifier-decision-model');
if (model !== '') decision.model = model;
const numCtx = text('classifier-decision-num-ctx');
if (numCtx !== '') decision.num_ctx = parseInt(numCtx, 10);
const timeoutS = text('classifier-decision-timeout-s');
if (timeoutS !== '') decision.timeout_s = parseInt(timeoutS, 10);
const confidenceMin = text('classifier-decision-confidence-min');
if (confidenceMin !== '') decision.confidence_min = parseFloat(confidenceMin);
const coverageMin = text('classifier-decision-coverage-min');
if (coverageMin !== '') decision.coverage_min = parseFloat(coverageMin);
decision.tier_enabled = document.getElementById('classifier-decision-tier-enabled').checked;
body.decision = decision;
}
return body;
}
async function saveClassifierConfig() {
showClassifierConfigError('');
const statusEl = document.getElementById('classifier-config-status');
try {
const result = await apiFetchDetail(`${API}api/classifier-config`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(collectClassifierConfigBody()),
});
statusEl.textContent = result.message || 'saved';
statusEl.style.color = 'var(--tblr-success)';
toast('Classifier config saved', 'success');
await loadClassifierConfig();
} catch (e) {
showClassifierConfigError(e.message || 'Save failed');
statusEl.textContent = '';
}
}
/* ═════════════════════════════════════════
CLOUD FALLBACK CONFIG
═════════════════════════════════════════ */
let _cloudFallbackConfigured = false;
async function loadCloudFallback() {
const data = await apiFetch(`${API}api/classifier-config`);
if (!data) return;
const cf = data.cloud_fallback;
const badge = document.getElementById('cloud-fallback-state');
const baseUrlEl = document.getElementById('cf-base-url');
const modelEl = document.getElementById('cf-model');
const apiKeyEl = document.getElementById('cf-api-key-env');
const timeoutEl = document.getElementById('cf-timeout');
const maxTokensEl = document.getElementById('cf-max-tokens');
if (cf) {
baseUrlEl.value = cf.base_url || '';
modelEl.value = cf.model || '';
apiKeyEl.value = cf.api_key_env || '';
timeoutEl.value = cf.timeout_seconds != null ? cf.timeout_seconds : '';
maxTokensEl.value = cf.max_output_tokens != null ? cf.max_output_tokens : '';
badge.textContent = 'configured';
badge.className = 'badge bg-success';
_cloudFallbackConfigured = true;
} else {
baseUrlEl.value = '';
modelEl.value = '';
apiKeyEl.value = '';
timeoutEl.value = '';
maxTokensEl.value = '';
badge.textContent = 'not configured';
badge.className = 'badge bg-secondary';
_cloudFallbackConfigured = false;
}
}
function collectCloudFallbackBody() {
const baseUrl = document.getElementById('cf-base-url').value.trim();
const model = document.getElementById('cf-model').value.trim();
// Send null, not {base_url: "", model: ""}, when either is blank --
// an object with keys is truthy, so the backend would not catch an
// empty-but-present block (same lesson as cloud_primary).
if (!baseUrl || !model) return null;
const body = { base_url: baseUrl, model };
const apiKeyEnv = document.getElementById('cf-api-key-env').value.trim();
if (apiKeyEnv) body.api_key_env = apiKeyEnv;
const timeout = document.getElementById('cf-timeout').value;
if (timeout !== '') body.timeout_seconds = parseInt(timeout, 10);
const maxTokens = document.getElementById('cf-max-tokens').value;
if (maxTokens !== '') body.max_output_tokens = parseInt(maxTokens, 10);
return body;
}
async function saveCloudFallback() {
showCloudFallbackError('');
const statusEl = document.getElementById('cloud-fallback-status');
try {
const result = await apiFetchDetail(`${API}api/cloud-fallback-config`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cloud_fallback: collectCloudFallbackBody() }),
});
statusEl.textContent = result.message || 'saved';
statusEl.style.color = 'var(--tblr-success)';
toast('Cloud fallback saved', 'success');
await loadCloudFallback();
} catch (e) {
showCloudFallbackError(e.message || 'Save failed');
statusEl.textContent = '';
}
}
function showCloudFallbackError(message) {
const box = document.getElementById('cloud-fallback-error');
if (!message) { box.style.display = 'none'; box.textContent = ''; return; }
box.textContent = message;
box.style.display = 'block';
}
function clearCloudFallback() {
document.getElementById('cf-base-url').value = '';
document.getElementById('cf-model').value = '';
document.getElementById('cf-api-key-env').value = '';
document.getElementById('cf-timeout').value = '';
document.getElementById('cf-max-tokens').value = '';
saveCloudFallback();
}
/* ═══════════════════════════════════════
WATCHDOG
═══════════════════════════════════════ */
async function loadWatchdog() {
const status = await apiFetch(`${API}api/watchdog/status`);
if (status) {
const tick = status.last_tick;
const tickEl = document.getElementById('wd-tick-time');
const sessionsEl = document.getElementById('wd-sessions');
const flaggedEl = document.getElementById('wd-flagged');
const alertsEl = document.getElementById('wd-alerts');
const badge = document.getElementById('watchdog-last-tick');
if (tick) {
tickEl.textContent = tick.ticked_at || '-';
sessionsEl.textContent = tick.sessions_seen != null ? tick.sessions_seen : '-';
badge.textContent = tick.outcome || 'ok';
badge.className = tick.outcome === 'ok' ? 'badge bg-success' : 'badge bg-warning';
}
if (status.verdicts) {
flaggedEl.textContent = `${status.verdicts.flagged || 0} / ${status.verdicts.total || 0}`;
}
alertsEl.textContent = status.open_alerts || '0';
}
const channels = await apiFetch(`${API}api/watchdog/channels`);
const chEl = document.getElementById('wd-channels');
if (channels && channels.length) {
chEl.innerHTML = channels.map(c =>
`<span class="badge ${c.enabled ? 'bg-success' : 'bg-secondary'}">${escapeHtml(c.channel_name)} (${escapeHtml(c.min_severity)})</span>`
).join('');
} else {
chEl.innerHTML = '<span class="text-muted small">no channels configured</span>';
}
}
async function testWatchdogAlert() {
const statusEl = document.getElementById('watchdog-status');
statusEl.textContent = 'sending test alert...';
statusEl.style.color = 'var(--tblr-secondary)';
const resp = await apiFetch(`${API}api/watchdog/test-alert`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ severity: 'warning' }),
});
if (resp) {
statusEl.textContent = `sent to ${resp.sent} channel(s)`;
statusEl.style.color = 'var(--tblr-success)';
toast('Test alert sent', 'success');
} else {
statusEl.textContent = 'failed';
statusEl.style.color = 'var(--tblr-danger)';
toast('Test alert failed', 'error');
}
}
/* ═══════════════════════════════════════
DATA FETCHING
═══════════════════════════════════════ */
async function loadControls() {
const runtime = await apiFetch(`${API}api/runtime`);
if (runtime) {
renderRuntime(runtime);
if (runtime[LOCAL_COMPUTE_KNOB]) {
renderLocalCompute(runtime[LOCAL_COMPUTE_KNOB].runtime === true);
}
}
if (!_profilesCache) {
const profiles = await apiFetch(`${API}api/profiles`);
if (profiles) _profilesCache = profiles;
}
const config = await apiFetch(`${API}api/config`);
if (config) renderConfig(config);
await loadClassifierConfig();
await loadCloudFallback();
loadWatchdog();
}
/* ═══════════════════════════════════════
UTILITIES
═══════════════════════════════════════ */
function escapeHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
/* ═══════════════════════════════════════
INIT
═══════════════════════════════════════ */
function init() {
renderStaticIcons();
// Delegated so it survives every re-render of the list.
const configList = document.getElementById('config-list');
configList.addEventListener('input', markDirty);
configList.addEventListener('change', (e) => {
markDirty();
if (e.target && e.target.dataset && e.target.dataset.profileSelect != null) {
updateDefaultProfileHint();
}
});
loadControls();
connectSSE();
}
init();
</script>
</body>
</html>