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
1882 lines
95 KiB
HTML
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 — 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">—</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 → session history → <code>cloud_fallback</code> →
|
|
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">—</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 — 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 — 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">—</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…</td></tr>
|
|
</tbody>
|
|
</table>
|
|
<p class="text-muted settings-hint">Live on the next request — in-memory only, reverts on restart, no <code>config.yaml</code> write.</p>
|
|
<p class="text-muted settings-hint">Applied on restart — 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>© 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}…</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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
}
|
|
|
|
/* ═══════════════════════════════════════
|
|
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>
|