diff --git a/admin/frontend/controls.html b/admin/frontend/controls.html
index 51108f8..31d5cf1 100644
--- a/admin/frontend/controls.html
+++ b/admin/frontend/controls.html
@@ -129,6 +129,16 @@ header.navbar{padding-top:2px!important;padding-bottom:2px!important}
.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
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)}
+/* A config key wraps instead of truncating: the knob cell is the row's
+ identity, and a clipped path hides which scope the row belongs to. */
+.knobs-table .setting-key{white-space:normal;overflow:visible;text-overflow:clip}
+.knobs-table .setting-key .scope{display:inline}
.setting-key{
font-size:.82rem;line-height:1.25;
overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
@@ -183,15 +193,7 @@ header.navbar{padding-top:2px!important;padding-bottom:2px!important}
`;
+/* 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 `reverts on restart`;
+}
+
+function runtimeControlHtml(knob, knobState) {
+ const persisted = knobState.persisted;
+ const runtime = knobState.runtime;
+ const runtimeStr = knobDisplayValue(runtime);
+ if (typeof persisted === 'boolean') {
+ return `
+
+
`;
+ }
+ 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 ``;
+ }
+ if (typeof persisted === 'string') {
+ return ``;
+ }
+ return `${escapeHtml(runtimeStr)}`;
+}
+
+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 = `
+
+
`;
+ } else if (key === 'routing.default_profile') {
+ control = profileSelect(val);
+ } else if (ENUM_VALUES[key]) {
+ control = enumSelect(key, val, { dataAttr: 'data-config-input' });
+ } else {
+ control = ``;
+ }
+ // 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 ? `${hints}` : ''}`;
+}
+
+function knobRowHtml(row) {
+ 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)
+ : '-';
+ let persistedCell = '-';
+ let layerCell = '
';
+ let dirtyAttrs = '';
+ 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 = 'overlay';
+ } else if (entry.source === 'base') {
+ layer = 'base';
+ }
+ layerCell = `
`;
+}
+
+// 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 = '
`;
- } else if (ENUM_VALUES[key]) {
- control = enumSelect(key, runtime, { onchangeExpr: `toggleKnob('${key}', this.value)` });
- } else 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 -- the maximum penalty on
- // the challenger dial, and an out-of-range not-quite-off on the session
- // cache window -- so clearing a field must never be read as choosing it.
- // What null MEANS is the server's call: neutral for the dial, a refusal
- // naming session_cache_enabled for the window.
- const b = NUMBER_BOUNDS[key] || {};
- 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);
- control = ``;
- } else if (typeof persisted === 'string') {
- control = ``;
- } else {
- control = `${escapeHtml(runtimeStr)}`;
- }
-
- // The control already shows the live value, so the only facts worth a
- // second column are disagreement with the file and a consequence the
- // switch cannot state for itself.
- const diffBadge = isDiff
- ? `file: ${escapeHtml(knobDisplayValue(persisted))}`
- : '';
- const meta = [noteBadge(key), diffBadge].filter(Boolean).join(' ');
-
- return settingRow({ key, meta, control });
- }).join('');
- el.innerHTML = html || '
No runtime knobs
';
+ _runtimeState = state;
+ renderKnobsTable();
}
/* A knob POST that keeps the server's `detail`. The generic apiFetch swallows
@@ -819,50 +969,8 @@ async function setLocalCompute(enabled) {
let _profilesCache = null;
function renderConfig(config) {
- const list = document.getElementById('config-list');
- if (!config || !Object.keys(config).length) {
- list.innerHTML = '
No config data
';
- return;
- }
- const html = Object.entries(config).map(([key, raw]) => {
- const entry = raw || {};
- const val = entry.value;
- const source = entry.source;
- const isBool = typeof val === 'boolean';
- let control;
- if (isBool) {
- // A switch, matching Runtime Knobs — the bare checkbox here was the one
- // control on the page that didn't look like the others.
- control = `
-
-
`;
- } else if (key === 'routing.default_profile') {
- control = profileSelect(val);
- } else if (ENUM_VALUES[key]) {
- control = enumSelect(key, val, { dataAttr: 'data-config-input' });
- } else {
- control = ``;
- }
-
- // Provenance badge is legitimate meta: it explains where the effective
- // value came from without restating the value the control already shows.
- let meta = [UNITS[key] ? escapeHtml(UNITS[key]) : '', noteBadge(key)].filter(Boolean).join(' ');
- if (source === 'overlay') {
- meta += `${meta ? ' ' : ''}overlay`;
- } else if (source === 'base') {
- meta += `${meta ? ' ' : ''}base`;
- }
-
- return settingRow({
- key,
- meta,
- control,
- dirtyAttrs: ` data-key="${escapeHtml(key)}" data-orig="${escapeHtml(String(val === null ? '' : val))}"`,
- });
- }).join('');
- list.innerHTML = html;
- updateDefaultProfileHint();
- markDirty();
+ _configState = config;
+ renderKnobsTable();
}
function profileSelect(currentValue) {
@@ -1111,9 +1219,23 @@ function renderClassifierCandidateCategories(data) {
async function loadClassifierConfig() {
const data = await apiFetch(`${API}api/classifier-config`);
- if (!data) return;
+ 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);
diff --git a/admin/frontend/decisions.html b/admin/frontend/decisions.html
index 00da1a0..2b3671b 100644
--- a/admin/frontend/decisions.html
+++ b/admin/frontend/decisions.html
@@ -180,15 +180,7 @@ header.navbar{padding-top:2px!important;padding-bottom:2px!important}
@@ -587,27 +579,45 @@ function renderBoard() {
const q = _snap.quota || {};
const spend = q.spend || {};
const alarm = q.alarm || {};
- const vm = _snap.verdict_mix || {};
const pm = (_snap.per_model || []).filter(m => m.provider !== 'ollama-local');
const pinch = _snap.pinch || {};
const prof = _snap.top_proficiency || [];
const accounts = (q.accounts || []).length;
const hist = _homeHistory || {};
- const good = (vm.ok || 0) + (vm.succeeded || 0);
- const bad = (vm.failed || 0) + (vm.malformed || 0);
- const decisions = Object.values(vm).reduce((a, b) => a + b, 0);
+ // Decisions tile: real routed decisions plus client-reported outcomes.
+ // The `|| {...}` guard matters because a stale tab can still receive a
+ // snapshot payload that predates this field -- without it, a missing
+ // object throws and blanks the whole board.
+ const dout = _snap.decision_outcomes
+ || {decisions: 0, client_reports: 0, client_ok: 0, client_failed: 0};
const topModel = pm.slice().sort((a, b) => (b.calls || 0) - (a.calls || 0))[0];
const paceNote = alarm.kind === 'plan_pace'
? `${escapeHtml(alarm.headline || '')}`
: 'no plan pressure';
+ // Second qualifier line for the Decisions tile. Percent covered only when
+ // there are routed decisions to divide by; the failed count takes the warn
+ // colour above a 10% failure rate (same threshold as the Signal card).
+ let outcomeLine = 'no client reports in 7 days';
+ if (dout.client_reports > 0) {
+ const share = dout.decisions > 0
+ ? ` (${(dout.client_reports / dout.decisions * 100).toFixed(1)}%)`
+ : '';
+ const failedFg = dout.client_failed / dout.client_reports > 0.10
+ ? 'warn-fg'
+ : 'ok-fg';
+ outcomeLine = `${num(dout.client_reports)} client reports${share}: `
+ + `${num(dout.client_ok)} ok, `
+ + `${num(dout.client_failed)} failed`;
+ }
+
el.innerHTML = [
tile('/admin/quota', 'Quota', usd(spend.total_usd),
`spent this period ${paceNote}`,
spark(hist.cost_per_bucket, '#fbbf24')),
- tile('/admin/decisions', 'Decisions', num(decisions),
- `verified in 7 days ${num(good)} ok, ${num(bad)} failed`,
+ tile('/admin/decisions', 'Decisions', num(dout.decisions),
+ `routed in 7 days ${outcomeLine}`,
spark(hist.decisions_per_bucket, '#93c5fd')),
tile('/admin/models', 'Models', num(pm.length),
topModel
@@ -1009,9 +1019,16 @@ function bindHomeViews() {
*
* DISMISS CANNOT MEAN DELETE. These are recomputed from live state on every
* poll, so the only honest dismissal is "hide this until the condition
- * changes". A dismissal is keyed on the warning's TEXT: when the underlying
- * numbers move the text moves with them, the key stops matching, and the
- * warning comes back on its own. Nothing has to expire it.
+ * changes". A dismissal is keyed on the warning's SEVERITY plus its text
+ * with the digits normalized away: the underlying numbers move on nearly
+ * every poll ("3 eligible" -> "50 eligible"), and a key that tracked the
+ * raw count would resurface the warning each time the number moved. The
+ * severity stays in the key so normalizing cannot fuse different warnings
+ * -- "0 eligible" is high-severity and must stay distinct from "3 eligible"
+ * (medium) even though both normalize to "N eligible". Store entries written
+ * by older implementations are raw warning text; loadDismissed migrates them
+ * through the current pipeline once and rewrites the store only when the
+ * migration actually changed something.
*
* Per browser, in localStorage, which is the right scope for "I have seen
* this" -- it is a reading state, not a fact about the router.
@@ -1020,16 +1037,22 @@ const DISMISS_KEY = '6krrt.dismissedWarnings';
function loadDismissed() {
try {
- const set = new Set(JSON.parse(localStorage.getItem(DISMISS_KEY) || '[]'));
+ const raw = JSON.parse(localStorage.getItem(DISMISS_KEY) || '[]');
// One-time carry-over from the previous, index-keyed implementation.
// Changing the storage key without this would silently resurface every
- // warning the operator had already dismissed.
+ // warning the operator had already dismissed. Unmappable carry-over
+ // entries never match a live key and are pruned on the next render.
const old = localStorage.getItem('dismissedWarnings');
if (old) {
- JSON.parse(old).forEach(w => set.add(w));
- localStorage.removeItem('dismissedWarnings');
- localStorage.setItem(DISMISS_KEY, JSON.stringify([...set]));
+ raw.push(...JSON.parse(old));
+ localStorage.removeItem('dismissedWarnings'); // strip the legacy key
}
+ const set = migrateDismissedKeys(raw);
+ // Persist only when migration changed the store: a clean read must not
+ // rewrite identical JSON on every load. The old-key check forces a save
+ // after the carry-over so the merged set survives this removal.
+ const same = set.size === raw.length && raw.every(k => set.has(k));
+ if (!same || old) saveDismissed(set);
return set;
} catch (_) { return new Set(); } // private window, or corrupt value
}
@@ -1047,10 +1070,57 @@ function warningTarget(text) {
return [null, null];
}
+/* WARN_SEVERITY:BEGIN */
function warningSeverity(text) {
if (/\d+(\.\d+)?x (sustainable |plan )?pace|0 eligible|cannot|refus/i.test(text)) return 'high';
return 'medium';
}
+/* WARN_SEVERITY:END */
+
+/* DISMISS_UNITS:BEGIN */
+/* Digit normalization is what makes a dismissal survive a count move: any
+ run of digits (integer or decimal) becomes N, so "3 eligible" and "50
+ eligible" share one key while every other word still counts. */
+function normalizeDigits(text) {
+ return String(text).replace(/\d+(?:\.\d+)?/g, 'N');
+}
+
+/* The storage key for one warning: severity + digit-normalized, escaped
+ text. dismissKey output is what gets stored AND what gets compared, so
+ both sides of every comparison run through this one pipeline. */
+function dismissKey(text, severity) {
+ return `${severity}:${escapeHtml(normalizeDigits(text))}`;
+}
+
+/* Store entries written by older implementations are raw warning text.
+ Map them through the current key pipeline. Entries already in the new
+ format (they open with a known severity prefix) pass through untouched,
+ so re-migrating an already-migrated store cannot mangle it. */
+function migrateDismissedKeys(rawKeys) {
+ return new Set((rawKeys || []).map(k => {
+ const t = String(k);
+ return /^(high|medium):/.test(t) ? t : dismissKey(t, warningSeverity(t));
+ }));
+}
+
+/* Anything dismissed whose key no longer appears among the live warnings
+ has been resolved; drop the key so the store cannot grow forever with
+ stale entries. Mutates ``dismissed`` in place; returns true if anything
+ was dropped so the caller knows to persist. */
+function pruneDismissed(warnings, dismissed) {
+ const live = new Set(warnings.map(w => dismissKey(w, warningSeverity(w))));
+ let pruned = false;
+ for (const k of [...dismissed]) {
+ if (!live.has(k)) { dismissed.delete(k); pruned = true; }
+ }
+ return pruned;
+}
+
+/* The open list: warnings whose key the operator has not dismissed. */
+function openWarnings(warnings, dismissed) {
+ return warnings.filter(w => !dismissed.has(dismissKey(w, warningSeverity(w))));
+}
+/* DISMISS_UNITS:END */
let _allWarnings = [];
@@ -1058,16 +1128,9 @@ function renderWarnings(warnings) {
_allWarnings = warnings || [];
const dismissed = loadDismissed();
- // Anything dismissed whose text no longer appears has been resolved; drop
- // the key so the store cannot grow forever with stale entries.
- const live = new Set(_allWarnings);
- let pruned = false;
- for (const k of [...dismissed]) {
- if (!live.has(k)) { dismissed.delete(k); pruned = true; }
- }
- if (pruned) saveDismissed(dismissed);
+ if (pruneDismissed(_allWarnings, dismissed)) saveDismissed(dismissed);
- const open = _allWarnings.filter(w => !dismissed.has(w));
+ const open = openWarnings(_allWarnings, dismissed);
const bell = document.getElementById('warnings-dropdown-wrap');
const count = document.getElementById('warn-count');
const menu = document.getElementById('warnings-menu');
@@ -1090,7 +1153,7 @@ function renderWarnings(warnings) {
diff --git a/admin/frontend/navbar.js b/admin/frontend/navbar.js
index 8b7c03a..e6e1a1c 100644
--- a/admin/frontend/navbar.js
+++ b/admin/frontend/navbar.js
@@ -8,6 +8,7 @@
*
* Pages opt in with:
* in the navbar right cluster
+ *
where the shared NAV_LINKS render
*
* and call navbarSse(state) from their own EventSource handlers.
*/
@@ -17,6 +18,42 @@
var API = '/admin/api/';
+ /* ── Nav links ───────────────────────────────────────────────────────────
+ * The seven nav entries used to be hardcoded into every page's navbar and
+ * drifted -- the file this comment lives in exists because that strip
+ * already proved it does not survive duplication. Each page now ships an
+ * empty
and the DOMContentLoaded handler below fills
+ * it from this one list.
+ *
+ * The markers around the next two blocks are load-bearing for
+ * tests/test_admin_js_units.py: that harness slices the blocks out of
+ * this file and runs them through node, so each block must stay
+ * syntactically complete on its own. */
+
+ /* NAV_LINKS:BEGIN */
+ var NAV_LINKS = [
+ { href: '/admin/models', label: 'Models' },
+ { href: '/admin/profiles', label: 'Profiles' },
+ { href: '/admin/proficiency', label: 'Proficiency' },
+ { href: '/admin/decisions', label: 'Decisions' },
+ { href: '/admin/quota', label: 'Quota' },
+ { href: '/admin/controls', label: 'Controls' },
+ { href: '/admin/providers', label: 'Providers' },
+ ];
+ /* NAV_LINKS:END */
+
+ /* ACTIVE_NAV_HREF:BEGIN */
+ function activeNavHref(pathname, links) {
+ // Exact match only. Every href starts with /admin/, so anything looser
+ // than full equality lights up on the landing page too, and a
+ // near-miss like /admin/model (singular) deserves no highlight.
+ for (var i = 0; i < links.length; i++) {
+ if (links[i].href === pathname) return links[i].href;
+ }
+ return null;
+ }
+ /* ACTIVE_NAV_HREF:END */
+
/* ── SSE status dot ──────────────────────────────────────────────────────
* Was a text badge reading "live" / "connecting…" / "reconnecting…". The
* text is the least informative part -- the colour already carries it -- and
@@ -230,6 +267,17 @@
if (!document.hidden) checkFrontend();
});
+ // Nav links render before the nav-widgets block so a page that ships
+ // the list mount but not the widget cluster still gets its nav.
+ var navMount = document.getElementById('nav-links');
+ if (navMount) {
+ var activeHref = activeNavHref(window.location.pathname, NAV_LINKS);
+ navMount.innerHTML = NAV_LINKS.map(function (l) {
+ var activeCls = l.href === activeHref ? ' active' : '';
+ return '
diff --git a/src/admin.py b/src/admin.py
index 874d89e..9f38b9f 100644
--- a/src/admin.py
+++ b/src/admin.py
@@ -475,6 +475,20 @@ _FLEX_VALUES = frozenset(v.value for v in FlexPreference)
_PROFILE_KNOB = "active_profile"
_PROFILE_PATH: tuple[str, ...] = ("routing", "default_profile")
+# The dotted config.yaml path behind each runtime knob, DERIVED from the same
+# registries that drive the POST handlers -- never hand-copied, so a knob
+# added to any table below is automatically labelled. GET /admin/api/runtime
+# ships one label per knob so the controls page can pair each runtime row
+# with its persisted twin by config key alone, without re-deriving this
+# mapping in JavaScript.
+_RUNTIME_KNOB_PATHS: dict[str, tuple[str, ...]] = {
+ **_BOOL_KNOBS,
+ **{knob: spec[0] for knob, spec in _FLOAT_KNOBS.items()},
+ **{knob: spec[0] for knob, spec in _INT_KNOBS.items()},
+ _FLEX_KNOB: _FLEX_PATH,
+ _PROFILE_KNOB: _PROFILE_PATH,
+}
+
# --- persisted config allowlist -------------------------------------------------
# Dotted config.yaml paths an operator is allowed to edit. Everything else —
# classifier/verification/local_vision URLs and model names, api_key_env,
@@ -2129,6 +2143,7 @@ def build_router(
"recent_decisions": metrics.recent_decisions(conn),
"per_model": metrics.per_model(conn),
"verdict_mix": metrics.verdict_mix(conn),
+ "decision_outcomes": metrics.decision_outcome_summary(conn),
"top_proficiency": metrics.top_proficiency(conn, "coding_general"),
"local_energy": metrics.local_energy_summary(conn, cfg),
"pinch": metrics.pinch_summary(conn, cfg),
@@ -2362,11 +2377,21 @@ def build_router(
@router.get("/api/runtime")
def admin_runtime_state() -> dict:
- """Persisted (config.yaml) vs runtime (in-memory) value of each knob."""
+ """Persisted (config.yaml) vs runtime (in-memory) value of each knob.
+
+ Each item also carries ``config_key``, the dotted config.yaml path
+ behind the knob (``_RUNTIME_KNOB_PATHS``), so the controls page can
+ pair the runtime half of a row with its persisted half without
+ re-deriving the knob-name -> config-path mapping in JavaScript.
+ """
persisted = _runtime_state(load_config("config/config.yaml"))
runtime = _runtime_state(cfg)
return {
- key: {"persisted": persisted[key], "runtime": runtime[key]}
+ key: {
+ "persisted": persisted[key],
+ "runtime": runtime[key],
+ "config_key": ".".join(_RUNTIME_KNOB_PATHS[key]),
+ }
for key in persisted
}
diff --git a/src/metrics.py b/src/metrics.py
index fbccdf2..f216b8c 100644
--- a/src/metrics.py
+++ b/src/metrics.py
@@ -3117,6 +3117,45 @@ def verdict_mix(
return {row["verdict"]: row["n"] for row in rows}
+def decision_outcome_summary(
+ conn: sqlite3.Connection,
+ since_days: int = 7,
+) -> dict:
+ """Route decisions and client-reported outcomes in the last *since_days*.
+
+ ``decisions`` counts ``route_decisions`` rows (requests the router routed);
+ the ``client_*`` counts come only from ``verifications`` rows with
+ ``kind = 'client_outcome'`` -- ground truth about the client's experience,
+ not the internal verification verdicts that :func:`verdict_mix` aggregates.
+ """
+ decisions = conn.execute(
+ """
+ SELECT COUNT(*) n
+ FROM route_decisions
+ WHERE julianday(observed_at) > julianday('now', '-' || ? || ' days')
+ """,
+ (str(since_days),),
+ ).fetchone()["n"]
+ reports = conn.execute(
+ """
+ SELECT
+ COUNT(*) client_reports,
+ SUM(CASE WHEN verdict = 'succeeded' THEN 1 ELSE 0 END) client_ok,
+ SUM(CASE WHEN verdict = 'failed' THEN 1 ELSE 0 END) client_failed
+ FROM verifications
+ WHERE kind = 'client_outcome'
+ AND julianday(observed_at) > julianday('now', '-' || ? || ' days')
+ """,
+ (str(since_days),),
+ ).fetchone()
+ return {
+ "decisions": decisions,
+ "client_reports": reports["client_reports"],
+ "client_ok": reports["client_ok"] or 0,
+ "client_failed": reports["client_failed"] or 0,
+ }
+
+
def top_proficiency(
conn: sqlite3.Connection,
category: str,
diff --git a/tests/test_admin_frontend.py b/tests/test_admin_frontend.py
index 362df09..bd59f2b 100644
--- a/tests/test_admin_frontend.py
+++ b/tests/test_admin_frontend.py
@@ -216,11 +216,17 @@ def test_admin_profiles_returns_html_with_profiles_marker(admin_client):
def test_admin_pages_include_profiles_nav_link(admin_client):
- """Every served admin page body contains the Profiles nav link markup."""
+ """Every served admin page body contains the mount the shared nav renders into.
+
+ The nav links are built client-side from navbar.js's NAV_LINKS (the
+ link list's completeness is owned by tests/test_admin_nav_is_complete.py),
+ so served HTML ships the empty
the script fills
+ rather than the Profiles anchor itself.
+ """
for path in ["/admin/", "/admin/models", "/admin/profiles", "/admin/decisions", "/admin/controls"]:
resp = admin_client.get(path)
assert resp.status_code == 200
- assert ">Profiles<" in resp.text, f"Profiles nav link missing on {path}"
+ assert 'id="nav-links"' in resp.text, f"nav-links mount missing on {path}"
def test_quota_page_returns_html_with_quota_marker(admin_client):
@@ -253,7 +259,10 @@ def test_the_home_page_carries_no_quota_chip():
quietly return with the next navbar edit.
"""
html = (ROOT / "admin" / "frontend" / "index.html").read_text()
- assert 'href="/admin/quota"' in html, "the Quota tile still has to link there"
+ # The href attribute is built client-side now: the nav renders from
+ # navbar.js and the board tiles from tile('/admin/quota', ...). The
+ # static trace of both is the pathname itself.
+ assert "/admin/quota" in html, "the Quota tile still has to link there"
assert "renderQuotaChip" not in html
assert "quota-chip-link" not in html
@@ -563,3 +572,138 @@ def test_the_home_page_no_longer_renders_a_verdict_bar():
assert "VERDICT_OFF_SCALE" not in html
assert "renderVerdict" not in html
assert "failing, last 7 days" in html, "the fact itself still has to be on the page"
+
+
+def _classifier_mode_select_html() -> str:
+ """The classifier card's mode block, from the static markup."""
+ html = (ROOT / "admin" / "frontend" / "controls.html").read_text()
+ select = re.search(r"]*id=\"classifier-mode-select\".*?", html, re.DOTALL)
+ assert select, "classifier-mode-select block not found in controls.html"
+ return select.group(0)
+
+
+def test_classifier_mode_select_starts_disabled():
+ """The mode select ships disabled and waits for the config fetch.
+
+ A live select during load would let a change fire onClassifierModeChange
+ against an empty _classifierConfigData; loadClassifierConfig is the only
+ thing allowed to unlock the card.
+ """
+ html = (ROOT / "admin" / "frontend" / "controls.html").read_text()
+ tag = re.search(r"]*id=\"classifier-mode-select\"[^>]*>", html)
+ assert tag, "classifier-mode-select open tag not found in controls.html"
+ assert "disabled" in tag.group(0)
+
+
+def test_classifier_mode_select_leads_with_a_loading_placeholder():
+ """The first option is an unconfirmed placeholder, not a real mode.
+
+ With a real mode preselected, a disabled select would still display a
+ claim about the config before it has been fetched; 'loading...' shows
+ nothing has been read yet.
+ """
+ block = _classifier_mode_select_html()
+ first = re.search(r"