feat(admin): cockpit quick wins, six admin portal fixes (R5 G1 E3 R2 G3 C1) #101

Merged
alee merged 6 commits from feat/cockpit-quick-wins into main 2026-09-26 07:09:47 +00:00
20 changed files with 1484 additions and 263 deletions

View File

@@ -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 <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)}
/* 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}
</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">
<li class="nav-item"><a class="nav-link" href="/admin/models">Models</a></li>
<li class="nav-item"><a class="nav-link" href="/admin/profiles">Profiles</a></li>
<li class="nav-item"><a class="nav-link" href="/admin/proficiency">Proficiency</a></li>
<li class="nav-item"><a class="nav-link" href="/admin/decisions">Decisions</a></li>
<li class="nav-item"><a class="nav-link" href="/admin/quota">Quota</a></li>
<li class="nav-item active"><a class="nav-link" href="/admin/controls">Controls</a></li>
<li class="nav-item"><a class="nav-link" href="/admin/providers">Providers</a></li>
</ul>
<ul class="navbar-nav" id="nav-links"></ul>
</div>
</div>
</div>
@@ -261,7 +263,9 @@ header.navbar{padding-top:2px!important;padding-bottom:2px!important}
<div class="row g-3 align-items-end">
<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()">
<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>
@@ -279,7 +283,7 @@ header.navbar{padding-top:2px!important;padding-bottom:2px!important}
<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" onclick="saveClassifierConfig()">Save</button>
<button class="btn btn-primary btn-sm" id="classifier-save-btn" onclick="saveClassifierConfig()" disabled>Save</button>
</div>
</div>
</div>
@@ -378,34 +382,34 @@ header.navbar{padding-top:2px!important;padding-bottom:2px!important}
</div>
</div>
<!-- Row 2: the two settings panels side by side. Same row grammar,
same control column, so they read as one table split in half:
left is what the process is running now, right is what the file
says on next start. -->
<div class="col-xl-6">
<div class="card">
<div class="card-header"><h3 class="card-title"><span class="me-2" data-icon="settings"></span>Runtime Knobs</h3></div>
<div class="card-body settings-body">
<p class="text-muted settings-hint">Live on the next request &mdash; in-memory only, reverts on restart, no <code>config.yaml</code> write.</p>
<div class="settings-list" id="runtime-list"></div>
</div>
</div>
</div>
<div class="col-xl-6">
<!-- 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="database"></span>Persisted Config</h3>
<h3 class="card-title"><span class="me-2" data-icon="settings"></span>Knobs</h3>
<div class="card-actions d-flex align-items-center gap-2">
<span id="config-save-status" class="text-muted" style="font-size:.72rem"></span>
<button class="btn btn-primary btn-sm" id="config-save-btn" onclick="saveAllConfig()" disabled>Save</button>
</div>
</div>
<div class="card-body settings-body">
<table class="table table-vcenter card-table knobs-table">
<thead>
<tr>
<th title="Dotted config.yaml path. Pairs the live and persisted halves of the same knob.">Knob</th>
<th title="Live on the next request - in-memory only, reverts on restart, no config.yaml write.">Live</th>
<th title="Applied on restart - allowlisted keys, written to config/config.local.yaml (machine-local overlay; config.yaml stays clean).">Persisted</th>
<th title="Where the persisted value came from: config.yaml (base) or config.local.yaml (overlay).">Layer</th>
</tr>
</thead>
<tbody id="config-list">
<tr><td colspan="4" class="text-muted fst-italic small px-2 py-1">Loading config&hellip;</td></tr>
</tbody>
</table>
<p class="text-muted settings-hint">Live on the next request &mdash; in-memory only, reverts on restart, no <code>config.yaml</code> write.</p>
<p class="text-muted settings-hint">Applied on restart &mdash; allowlisted keys, written to <code>config/config.local.yaml</code> (machine-local overlay; <code>config.yaml</code> stays clean).</p>
<div class="settings-list" id="config-list">
<div class="text-muted fst-italic small px-2 py-1">Loading config&hellip;</div>
</div>
</div>
</div>
</div>
@@ -676,64 +680,210 @@ const NUMBER_BOUNDS = {
session_cache_staleness_seconds: { min: 5, max: 7200, step: 1 },
};
function settingRow({ key, meta = '', control, dirtyAttrs = '' }) {
return `<div class="setting-row"${dirtyAttrs}>
<span class="setting-key" title="${escapeHtml(key)}">${keyHtml(key)}</span>
<span class="setting-meta">${meta}</span>
<span class="setting-control">${control}</span>
</div>`;
/* 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) {
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 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 = '<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>`;
dirtyAttrs = ` data-key="${escapeHtml(row.configKey)}" data-orig="${escapeHtml(String(entry.value === null ? '' : entry.value))}"`;
}
return `<tr class="setting-row"${dirtyAttrs}>
<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;
}
list.innerHTML = keys.map(key => {
const row = rows[key];
row.dirtyValue = row.persistedItem ? dirtyInputs[key] : undefined;
return knobRowHtml(row);
}).join('');
updateDefaultProfileHint();
markDirty();
}
function renderRuntime(state) {
const el = document.getElementById('runtime-list');
const knobs = state || {};
const html = Object.keys(knobs).map(key => {
const knob = knobs[key];
const persisted = knob.persisted;
const runtime = knob.runtime;
const isDiff = JSON.stringify(persisted) !== JSON.stringify(runtime);
const runtimeStr = knobDisplayValue(runtime);
let control;
if (typeof persisted === 'boolean') {
control = `<div class="form-check form-switch">
<input class="form-check-input toggle-input" type="checkbox" data-knob="${key}" ${runtime ? 'checked' : ''} onchange="toggleKnob('${key}', this.checked)">
</div>`;
} 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 = `<input class="form-control form-control-sm toggle-input" type="number" ${attrs} data-knob="${key}" value="${escapeHtml(shown)}" onchange="toggleKnob('${key}', this.value.trim() === '' ? null : Number(this.value))">`;
} else if (typeof persisted === 'string') {
control = `<input class="form-control form-control-sm toggle-input" type="text" data-knob="${key}" value="${escapeHtml(runtimeStr)}" onchange="toggleKnob('${key}', this.value)">`;
} else {
control = `<span class="text-muted small">${escapeHtml(runtimeStr)}</span>`;
}
// 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
? `<span class="badge bg-warning-subtle text-warning" title="config.yaml says ${escapeHtml(knobDisplayValue(persisted))}; running with ${escapeHtml(runtimeStr)}">file: ${escapeHtml(knobDisplayValue(persisted))}</span>`
: '';
const meta = [noteBadge(key), diffBadge].filter(Boolean).join(' ');
return settingRow({ key, meta, control });
}).join('');
el.innerHTML = html || '<div class="text-muted fst-italic small px-2 py-1">No runtime knobs</div>';
_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 = '<div class="text-muted fst-italic small px-2 py-1">No config data</div>';
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 = `<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>`;
}
// 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 ? ' ' : ''}<span class="badge bg-purple" title="set in config.local.yaml">overlay</span>`;
} else if (source === 'base') {
meta += `${meta ? ' ' : ''}<span class="badge bg-secondary" title="from config.yaml">base</span>`;
}
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);

View File

@@ -180,15 +180,7 @@ header.navbar{padding-top:2px!important;padding-bottom:2px!important}
</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">
<li class="nav-item"><a class="nav-link" href="/admin/models">Models</a></li>
<li class="nav-item"><a class="nav-link" href="/admin/profiles">Profiles</a></li>
<li class="nav-item"><a class="nav-link" href="/admin/proficiency">Proficiency</a></li>
<li class="nav-item active"><a class="nav-link" href="/admin/decisions">Decisions</a></li>
<li class="nav-item"><a class="nav-link" href="/admin/quota">Quota</a></li>
<li class="nav-item"><a class="nav-link" href="/admin/controls">Controls</a></li>
<li class="nav-item"><a class="nav-link" href="/admin/providers">Providers</a></li>
</ul>
<ul class="navbar-nav" id="nav-links"></ul>
</div>
</div>
</div>
@@ -401,6 +393,123 @@ function addDecision(dec) {
FILTERS
═══════════════════════════════════════ */
/* RESOLVE_URL_FILTER_VALUES:BEGIN */
// Pure helper: given the six raw URL parameter values and each control's
// current options, decide what to apply. Returns the value to set per
// control (null = leave the control untouched), which select options must
// be added first (the caller escapeHtml's them via textContent), and
// whether an unknown size fell back to the default. No DOM access - the
// caller applies the result. size is the exception: it is validated
// against the page-size options and an unknown value falls back to 100
// instead of being added as an option.
function resolveUrlFilterValues(rawParams, optionSets) {
const out = {
values: { kind: null, category: null, profile: null, tier: null, search: null, size: null },
addOptions: [], // {id, value} - the option must exist before .value sticks
sizeFallback: false,
};
const selects = [
['kind', 'f-kind'],
['category', 'f-category'],
['profile', 'f-profile'],
['tier', 'f-tier'],
];
for (const [key, id] of selects) {
const raw = String(rawParams[key] || '').trim();
if (!raw) continue;
out.values[key] = raw;
const existing = optionSets[key] || [];
if (!existing.some(v => String(v) === raw)) {
out.addOptions.push({ id: id, value: raw });
}
}
const rawSearch = String(rawParams.q || '').trim();
if (rawSearch) out.values.search = rawSearch;
const rawSize = String(rawParams.size || '').trim();
if (rawSize) {
const sizeOptions = (optionSets.size || []).map(v => String(v));
if (sizeOptions.includes(rawSize)) {
out.values.size = rawSize;
} else {
out.values.size = '100';
out.sizeFallback = true;
}
}
return out;
}
/* RESOLVE_URL_FILTER_VALUES:END */
// Read ?kind=&category=&profile=&tier=&q=&size= into the controls BEFORE the
// first render. An unknown select value is added as an escaped option so a
// link naming a category with no loaded rows still shows its filter (the
// spec accepts a bogus ?tier=4 as a phantom option with zero rows too).
// Applying size also sets the module _pageSize - pagination math reads
// _pageSize, not the select's value - and resets the page to 1.
function applyUrlFilters() {
const params = new URLSearchParams(location.search);
const optionsOf = (id) => [...document.getElementById(id).options].map(o => o.value);
const resolved = resolveUrlFilterValues(
{
kind: params.get('kind') || '',
category: params.get('category') || '',
profile: params.get('profile') || '',
tier: params.get('tier') || '',
q: params.get('q') || '',
size: params.get('size') || '',
},
{
kind: optionsOf('f-kind'),
category: optionsOf('f-category'),
profile: optionsOf('f-profile'),
tier: optionsOf('f-tier'),
size: optionsOf('f-page-size'),
}
);
for (const add of resolved.addOptions) {
const opt = document.createElement('option');
opt.value = add.value;
opt.textContent = add.value; // textContent escapes like escapeHtml
document.getElementById(add.id).appendChild(opt);
}
const ids = { kind: 'f-kind', category: 'f-category', profile: 'f-profile', tier: 'f-tier' };
for (const key of Object.keys(ids)) {
if (resolved.values[key] != null) document.getElementById(ids[key]).value = resolved.values[key];
}
if (resolved.values.search != null) document.getElementById('f-search').value = resolved.values.search;
if (resolved.values.size != null) {
document.getElementById('f-page-size').value = resolved.values.size;
_pageSize = Number(resolved.values.size) || 100;
_currentPage = 1;
}
}
// The URL is written from exactly one place: writeFiltersToUrl(). Filter
// changes reach it through resetPageAndRender(), chosen over per-call-site
// hooks because every filter handler already routes there; init calls it
// once after applying URL params to normalize the URL (e.g. a garbage
// ?size=999 is rewritten away). replaceState only - the filter log is not
// navigation history, so the history stack is never extended. Empty values
// and defaults (selects "", search "", size 100) are omitted so Clear
// resets to the bare path.
function writeFiltersToUrl() {
const val = (id) => String(document.getElementById(id).value || '').trim();
const kind = val('f-kind');
const category = val('f-category');
const profile = val('f-profile');
const tier = val('f-tier');
const q = val('f-search');
const size = val('f-page-size');
const params = new URLSearchParams();
if (kind) params.set('kind', kind);
if (category) params.set('category', category);
if (profile) params.set('profile', profile);
if (tier) params.set('tier', tier);
if (q) params.set('q', q);
if (size && size !== '100') params.set('size', size);
const qs = params.toString();
history.replaceState(null, '', qs ? '?' + qs : location.pathname);
}
function refreshFilterOptions(rows) {
const kindSel = document.getElementById('f-kind');
const catSel = document.getElementById('f-category');
@@ -413,12 +522,15 @@ function refreshFilterOptions(rows) {
}
if (!changed) return;
const kindVal = kindSel.value, catVal = catSel.value, profVal = profSel.value;
kindSel.innerHTML = '<option value="">All kinds</option>' +
[...(_knownKinds)].sort().map(k => `<option value="${escapeHtml(k)}">${escapeHtml(k)}</option>`).join('');
catSel.innerHTML = '<option value="">All categories</option>' +
[...(_knownCategories)].sort().map(c => `<option value="${escapeHtml(c)}">${escapeHtml(c)}</option>`).join('');
profSel.innerHTML = '<option value="">All profiles</option>' +
[...(_knownProfiles)].sort().map(p => `<option value="${escapeHtml(p)}">${escapeHtml(p)}</option>`).join('');
// Union the still-set value (typically URL-applied) into the rebuilt
// option list - otherwise the rebuild silently drops it and the select
// resets to "" because .value no longer matches any option.
const unioned = (values, kept) =>
[...new Set(kept ? [...values, kept] : [...values])].sort()
.map(v => `<option value="${escapeHtml(v)}">${escapeHtml(v)}</option>`).join('');
kindSel.innerHTML = '<option value="">All kinds</option>' + unioned([...(_knownKinds)], kindVal);
catSel.innerHTML = '<option value="">All categories</option>' + unioned([...(_knownCategories)], catVal);
profSel.innerHTML = '<option value="">All profiles</option>' + unioned([...(_knownProfiles)], profVal);
kindSel.value = kindVal;
catSel.value = catVal;
profSel.value = profVal;
@@ -470,7 +582,10 @@ function matchesFilters(d, f) {
}
function resetPageAndRender() {
// writeFiltersToUrl is called HERE rather than at each handler: every
// filter change (selects, search, page size, Clear) routes through this.
_currentPage = 1;
writeFiltersToUrl();
renderTable();
}
@@ -484,7 +599,9 @@ function setupFilters() {
document.getElementById('f-profile').value = '';
document.getElementById('f-tier').value = '';
document.getElementById('f-search').value = '';
resetPageAndRender();
document.getElementById('f-page-size').value = '100';
_pageSize = 100;
resetPageAndRender(); // rewrites the URL back to the bare path
});
}
@@ -604,6 +721,8 @@ async function loadDecisions() {
function init() {
setupFilters();
setupPagination();
applyUrlFilters(); // URL -> controls before the first render
writeFiltersToUrl(); // normalize once (e.g. a garbage ?size=999 is rewritten away)
loadDecisions();
connectSSE();
}

View File

@@ -351,15 +351,7 @@ header.navbar{padding-top:2px!important;padding-bottom:2px!important}
</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">
<li class="nav-item"><a class="nav-link" href="/admin/models">Models</a></li>
<li class="nav-item"><a class="nav-link" href="/admin/profiles">Profiles</a></li>
<li class="nav-item"><a class="nav-link" href="/admin/proficiency">Proficiency</a></li>
<li class="nav-item"><a class="nav-link" href="/admin/decisions">Decisions</a></li>
<li class="nav-item"><a class="nav-link" href="/admin/quota">Quota</a></li>
<li class="nav-item"><a class="nav-link" href="/admin/controls">Controls</a></li>
<li class="nav-item"><a class="nav-link" href="/admin/providers">Providers</a></li>
</ul>
<ul class="navbar-nav" id="nav-links"></ul>
</div>
</div>
</div>
@@ -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'
? `<span class="warn-fg">${escapeHtml(alarm.headline || '')}</span>`
: '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}: `
+ `<span class="ok-fg">${num(dout.client_ok)} ok</span>, `
+ `<span class="${failedFg}">${num(dout.client_failed)} failed</span>`;
}
el.innerHTML = [
tile('/admin/quota', 'Quota', usd(spend.total_usd),
`spent this period<br>${paceNote}`,
spark(hist.cost_per_bucket, '#fbbf24')),
tile('/admin/decisions', 'Decisions', num(decisions),
`verified in 7 days<br><span class="ok-fg">${num(good)} ok</span>, ${num(bad)} failed`,
tile('/admin/decisions', 'Decisions', num(dout.decisions),
`routed in 7 days<br>${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) {
<div class="warn-txt">${escapeHtml(w)}
${href ? `<div><a class="warn-link" href="${href}">open ${name} &rarr;</a></div>` : ''}
</div>
<button type="button" class="warn-btn" data-dismiss="${escapeHtml(w)}">dismiss</button>
<button type="button" class="warn-btn" data-dismiss="${escapeHtml(dismissKey(w, warningSeverity(w)))}">dismiss</button>
</div>`;
}).join('')
: `<div class="warn-empty">Nothing needs attention.${
@@ -1125,7 +1188,7 @@ function bindWarnings() {
const set = loadDismissed();
if (one) set.add(one.getAttribute('data-dismiss'));
if (all) _allWarnings.forEach(w => set.add(w));
if (all) _allWarnings.forEach(w => set.add(dismissKey(w, warningSeverity(w))));
if (restore) set.clear();
saveDismissed(set);
renderWarnings(_allWarnings);

View File

@@ -192,15 +192,7 @@ header.navbar{padding-top:2px!important;padding-bottom:2px!important}
</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">
<li class="nav-item active"><a class="nav-link" href="/admin/models">Models</a></li>
<li class="nav-item"><a class="nav-link" href="/admin/profiles">Profiles</a></li>
<li class="nav-item"><a class="nav-link" href="/admin/proficiency">Proficiency</a></li>
<li class="nav-item"><a class="nav-link" href="/admin/decisions">Decisions</a></li>
<li class="nav-item"><a class="nav-link" href="/admin/quota">Quota</a></li>
<li class="nav-item"><a class="nav-link" href="/admin/controls">Controls</a></li>
<li class="nav-item"><a class="nav-link" href="/admin/providers">Providers</a></li>
</ul>
<ul class="navbar-nav" id="nav-links"></ul>
</div>
</div>
</div>

View File

@@ -8,6 +8,7 @@
*
* Pages opt in with:
* <span id="nav-widgets"></span> in the navbar right cluster
* <ul id="nav-links"></ul> where the shared NAV_LINKS render
* <script src="navbar.js"></script>
* 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 <ul id="nav-links"> 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 '<li class="nav-item' + activeCls + '"><a class="nav-link" href="' + l.href + '">' + l.label + '</a></li>';
}).join('');
}
var host = document.getElementById('nav-widgets');
if (!host) return;
host.innerHTML = '' +

View File

@@ -169,15 +169,7 @@ table.matrix td{padding:0}
</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">
<li class="nav-item"><a class="nav-link" href="/admin/models">Models</a></li>
<li class="nav-item"><a class="nav-link" href="/admin/profiles">Profiles</a></li>
<li class="nav-item active"><a class="nav-link" href="/admin/proficiency">Proficiency</a></li>
<li class="nav-item"><a class="nav-link" href="/admin/decisions">Decisions</a></li>
<li class="nav-item"><a class="nav-link" href="/admin/quota">Quota</a></li>
<li class="nav-item"><a class="nav-link" href="/admin/controls">Controls</a></li>
<li class="nav-item"><a class="nav-link" href="/admin/providers">Providers</a></li>
</ul>
<ul class="navbar-nav" id="nav-links"></ul>
</div>
</div>
</div>

View File

@@ -248,15 +248,7 @@ header.navbar{padding-top:2px!important;padding-bottom:2px!important}
</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">
<li class="nav-item"><a class="nav-link" href="/admin/models">Models</a></li>
<li class="nav-item active"><a class="nav-link" href="/admin/profiles">Profiles</a></li>
<li class="nav-item"><a class="nav-link" href="/admin/proficiency">Proficiency</a></li>
<li class="nav-item"><a class="nav-link" href="/admin/decisions">Decisions</a></li>
<li class="nav-item"><a class="nav-link" href="/admin/quota">Quota</a></li>
<li class="nav-item"><a class="nav-link" href="/admin/controls">Controls</a></li>
<li class="nav-item"><a class="nav-link" href="/admin/providers">Providers</a></li>
</ul>
<ul class="navbar-nav" id="nav-links"></ul>
</div>
</div>
</div>

View File

@@ -199,15 +199,7 @@ header.navbar{padding-top:2px!important;padding-bottom:2px!important}
</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">
<li class="nav-item"><a class="nav-link" href="/admin/models">Models</a></li>
<li class="nav-item"><a class="nav-link" href="/admin/profiles">Profiles</a></li>
<li class="nav-item"><a class="nav-link" href="/admin/proficiency">Proficiency</a></li>
<li class="nav-item"><a class="nav-link" href="/admin/decisions">Decisions</a></li>
<li class="nav-item"><a class="nav-link" href="/admin/quota">Quota</a></li>
<li class="nav-item"><a class="nav-link" href="/admin/controls">Controls</a></li>
<li class="nav-item active"><a class="nav-link" href="/admin/providers">Providers</a></li>
</ul>
<ul class="navbar-nav" id="nav-links"></ul>
</div>
</div>
</div>

View File

@@ -281,15 +281,7 @@ header.navbar{padding-top:2px!important;padding-bottom:2px!important}
</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">
<li class="nav-item"><a class="nav-link" href="/admin/models">Models</a></li>
<li class="nav-item"><a class="nav-link" href="/admin/profiles">Profiles</a></li>
<li class="nav-item"><a class="nav-link" href="/admin/proficiency">Proficiency</a></li>
<li class="nav-item"><a class="nav-link" href="/admin/decisions">Decisions</a></li>
<li class="nav-item active"><a class="nav-link" href="/admin/quota">Quota</a></li>
<li class="nav-item"><a class="nav-link" href="/admin/controls">Controls</a></li>
<li class="nav-item"><a class="nav-link" href="/admin/providers">Providers</a></li>
</ul>
<ul class="navbar-nav" id="nav-links"></ul>
</div>
</div>
</div>

View File

@@ -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
}

View File

@@ -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,

View File

@@ -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 <ul id="nav-links"> 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 <select> block, from the static markup."""
html = (ROOT / "admin" / "frontend" / "controls.html").read_text()
select = re.search(r"<select [^>]*id=\"classifier-mode-select\".*?</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"<select [^>]*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"<option[^>]*>", block)
assert first, "the select has no options"
assert 'value=""' in first.group(0)
assert "selected" in first.group(0)
assert ">loading...<" in block
def test_classifier_mode_select_preselects_no_real_mode():
"""selected belongs to the placeholder alone in the static markup.
A static selected on local_llm/cloud_llm/local_encoder would flash that
mode before the config resolves and would survive a failed fetch as a
false reading.
"""
block = _classifier_mode_select_html()
for mode in ("local_llm", "cloud_llm", "local_encoder"):
option = re.search(rf"<option value=\"{mode}\"[^>]*>", block)
assert option, f"option for {mode} missing from the static markup"
assert "selected" not in option.group(0), f"{mode} carries selected"
assert block.count("selected") == 1, "selected must appear only on the placeholder"
def test_classifier_save_button_starts_disabled():
"""Save unlocks together with the select, never before.
It reads collectClassifierConfigBody(), which reads the select's value --
against the placeholder that is an empty mode payload, so a clickable
Save during load would post garbage.
"""
html = (ROOT / "admin" / "frontend" / "controls.html").read_text()
tag = re.search(r"<button [^>]*id=\"classifier-save-btn\"[^>]*>", html)
assert tag, "classifier-save-btn open tag not found in controls.html"
assert "disabled" in tag.group(0)
assert "onclick=\"saveClassifierConfig()\"" in tag.group(0)
def test_classifier_config_load_guards_the_placeholder_removal():
"""The success path must survive running more than once.
saveClassifierConfig re-awaits loadClassifierConfig after every
successful save, so the second run arrives with the placeholder already
gone: the removal is optional-chained. The failure branch keeps both
controls disabled, flips only the placeholder label, and reports through
the card's existing error helper.
"""
html = (ROOT / "admin" / "frontend" / "controls.html").read_text()
match = re.search(r"^async function loadClassifierConfig\(.*?^\}", html, re.DOTALL | re.MULTILINE)
assert match, "loadClassifierConfig() not found in controls.html"
body = match.group(0)
assert "option[value=\"\"]')?.remove()" in body
assert body.index("?.remove()") < body.index(".value = data.mode")
assert "document.getElementById('classifier-mode-select').disabled = false" in body
assert "document.getElementById('classifier-save-btn').disabled = false" in body
assert "placeholder.textContent = 'unavailable'" in body
assert (
"showClassifierConfigError('classifier config unavailable; controls disabled')"
in body
)
def test_decisions_filters_read_and_write_the_url():
"""E3: the decisions filter state lives in the URL.
The page reads kind/category/profile/tier/q/size from the query string
before the first render and writes changes back with replaceState -
never pushState, the filter log is not navigation history.
"""
html = (ROOT / "admin" / "frontend" / "decisions.html").read_text()
assert "new URLSearchParams(location.search)" in html
assert "history.replaceState(" in html
assert "pushState" not in html
init_match = re.search(r"^function init\(\)\s*\{.*?^\}", html, re.DOTALL | re.MULTILINE)
assert init_match, "init() not found in decisions.html"
body = init_match.group(0)
assert body.index("applyUrlFilters()") < body.index("loadDecisions()")
writer_match = re.search(r"^function writeFiltersToUrl\(\)\s*\{.*?^\}", html, re.DOTALL | re.MULTILINE)
assert writer_match, "writeFiltersToUrl() not found in decisions.html"
writer = writer_match.group(0)
assert writer.index("history.replaceState(") > writer.index("new URLSearchParams()")
assert "size !== '100'" in writer
def test_render_board_reads_decision_outcomes_and_render_live_keeps_verdict_mix():
"""R2: the Decisions tile reads decision_outcomes; the Signal card does not.
Scope is per function, never whole-file: renderLive legitimately keeps
its ``_snap.verdict_mix`` read, so a whole-file "no verdict_mix" assertion
would be wrong. The board must consume decision_outcomes through the
guarded local (a stale tab can receive a snapshot without the field, and
an unguarded property read throws and blanks the whole board), and the
old "verified in 7 days" diagnostic framing must be gone.
"""
html = (ROOT / "admin" / "frontend" / "index.html").read_text()
board = _function_body(html, "renderBoard")
assert "decision_outcomes" in board
assert "verdict_mix" not in board
assert "const dout = _snap.decision_outcomes" in board
assert "verified in 7 days" not in board
# The share travels inside the template: leading space, no trailing
# space. The old " ${share}: " pattern rendered a double space before
# the colon ("reports (6.6%) :") and a stranded space when omitted.
assert " ${share}: " not in board
live = _function_body(html, "renderLive")
assert "verdict_mix" in live

View File

@@ -154,6 +154,7 @@ def test_admin_snapshot_has_all_top_level_keys(seeded_client):
"recent_decisions",
"per_model",
"verdict_mix",
"decision_outcomes",
"top_proficiency",
"local_energy",
"health",

View File

@@ -0,0 +1,460 @@
"""Shared harness for running admin JS units through node.
Why execute the file instead of re-deriving its behaviour in Python: a
Python re-implementation of the JS is a second copy waiting to drift from
the code it claims to test -- the exact failure mode navbar.js was
extracted to end. These tests slice the marker-delimited blocks out of
``admin/frontend/navbar.js`` and feed the real source to ``node -e``, so
a regression is caught where it lives.
Every test that shells out to node is skipped when node is absent, so
the suite stays green on hosts without it; where node IS installed the
contract is exercised for real. Items later in the cockpit plan append
further marker-based node tests to the helpers below.
"""
from __future__ import annotations
import re
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parent.parent
NAVBAR_JS = ROOT / "admin" / "frontend" / "navbar.js"
NAV_LINKS_BEGIN = "/* NAV_LINKS:BEGIN */"
NAV_LINKS_END = "/* NAV_LINKS:END */"
ACTIVE_NAV_HREF_BEGIN = "/* ACTIVE_NAV_HREF:BEGIN */"
ACTIVE_NAV_HREF_END = "/* ACTIVE_NAV_HREF:END */"
skip_without_node = pytest.mark.skipif(
shutil.which("node") is None, reason="node not installed"
)
def _extract_between(path: Path, begin_marker: str, end_marker: str) -> str:
"""The text of ``path`` between two marker comments, markers excluded.
Both markers must be present and in order; anything else is a broken
extraction (the markers are load-bearing for this harness), not a
test failure to report softly.
"""
text = path.read_text(encoding="utf-8")
begin = text.find(begin_marker)
end = text.find(end_marker)
assert begin != -1, f"{path.name}: marker {begin_marker!r} not found"
assert end > begin, f"{path.name}: marker {end_marker!r} absent or before begin"
return text[begin + len(begin_marker) : end]
def _run_node(js_source: str) -> subprocess.CompletedProcess[str]:
"""Run ``js_source`` through ``node -e``; fail loudly on a nonzero exit."""
result = subprocess.run(
["node", "-e", js_source],
capture_output=True,
text=True,
check=False,
timeout=30,
)
assert result.returncode == 0, (
f"node exited {result.returncode}\nstdout: {result.stdout}\nstderr: {result.stderr}"
)
return result
def _navbar_units_source() -> str:
"""NAV_LINKS + activeNavHref from navbar.js, in file order, runnable as-is."""
return (
_extract_between(NAVBAR_JS, NAV_LINKS_BEGIN, NAV_LINKS_END)
+ "\n"
+ _extract_between(NAVBAR_JS, ACTIVE_NAV_HREF_BEGIN, ACTIVE_NAV_HREF_END)
)
def _assert_against_navbar_units(assertion_js: str) -> subprocess.CompletedProcess[str]:
"""Assert ``assertion_js`` against the real NAV_LINKS/activeNavHref source."""
return _run_node(
"const assert = require('assert');\n" + _navbar_units_source() + "\n" + assertion_js
)
@skip_without_node
def test_active_nav_href_lights_up_exactly_the_page_it_is_on():
"""Every served page but the landing page is in NAV_LINKS, so each one's
own pathname resolves to its own href."""
assertions = [
"assert.strictEqual(activeNavHref('/admin/models', NAV_LINKS), '/admin/models');",
"assert.strictEqual(activeNavHref('/admin/profiles', NAV_LINKS), '/admin/profiles');",
"assert.strictEqual(activeNavHref('/admin/proficiency', NAV_LINKS), '/admin/proficiency');",
"assert.strictEqual(activeNavHref('/admin/decisions', NAV_LINKS), '/admin/decisions');",
"assert.strictEqual(activeNavHref('/admin/quota', NAV_LINKS), '/admin/quota');",
"assert.strictEqual(activeNavHref('/admin/controls', NAV_LINKS), '/admin/controls');",
"assert.strictEqual(activeNavHref('/admin/providers', NAV_LINKS), '/admin/providers');",
]
_assert_against_navbar_units("\n".join(assertions))
@skip_without_node
@pytest.mark.parametrize(
"pathname",
[
"/admin", # prefix of everything, equal to nothing
"/admin/", # the landing page has no nav entry -- the logo is its door
"/admin/model", # near-miss singular
"/models", # missing the /admin prefix entirely
"/definitely-not-a-page",
"",
],
)
def test_active_nav_href_is_an_exact_match_or_null(pathname):
"""No prefix, no substring: a pathname that is not literally one of the
rendered hrefs highlights nothing, even though every href starts with
/admin/."""
_assert_against_navbar_units(
f"assert.strictEqual(activeNavHref({pathname!r}, NAV_LINKS), null);"
)
@skip_without_node
def test_active_nav_href_takes_its_links_from_the_argument():
"""activeNavHref is pure: it reads the links it is handed, nothing global."""
_assert_against_navbar_units(
"assert.strictEqual(activeNavHref('/x', [{ href: '/x', label: 'X' }]), '/x');\n"
"assert.strictEqual(activeNavHref('/x', []), null);\n"
"assert.strictEqual(activeNavHref('/y', [{ href: '/x', label: 'X' }]), null);"
)
DECISIONS_HTML = ROOT / "admin" / "frontend" / "decisions.html"
RESOLVE_URL_FILTER_VALUES_BEGIN = "/* RESOLVE_URL_FILTER_VALUES:BEGIN */"
RESOLVE_URL_FILTER_VALUES_END = "/* RESOLVE_URL_FILTER_VALUES:END */"
def _assert_against_decisions_units(assertion_js: str) -> subprocess.CompletedProcess[str]:
"""Assert ``assertion_js`` against the real resolveUrlFilterValues source."""
return _run_node(
"const assert = require('assert');\n"
+ _extract_between(
DECISIONS_HTML, RESOLVE_URL_FILTER_VALUES_BEGIN, RESOLVE_URL_FILTER_VALUES_END
)
+ "\n"
+ assertion_js
)
@skip_without_node
def test_resolve_url_filter_values_known_size_passes_through():
"""A size within the page-size options is applied as-is."""
_assert_against_decisions_units(
"const r = resolveUrlFilterValues(\n"
" { size: '250' },\n"
" { kind: [], category: [], profile: [], tier: [], size: ['50', '100', '250'] },\n"
");\n"
"assert.strictEqual(r.values.size, '250');\n"
"assert.strictEqual(r.sizeFallback, false);\n"
"assert.deepStrictEqual(r.addOptions, []);\n"
"assert.strictEqual(r.values.kind, null);"
)
@skip_without_node
def test_resolve_url_filter_values_unknown_size_falls_back_and_is_not_added():
"""size=999 falls back to the default 100 and is never added as an option."""
_assert_against_decisions_units(
"const r = resolveUrlFilterValues(\n"
" { size: '999' },\n"
" { kind: [], category: [], profile: [], tier: [], size: ['50', '100', '250'] },\n"
");\n"
"assert.strictEqual(r.values.size, '100');\n"
"assert.strictEqual(r.sizeFallback, true);\n"
"assert.deepStrictEqual(r.addOptions, []);"
)
@skip_without_node
def test_resolve_url_filter_values_unknown_category_is_added_as_an_option():
"""An unknown category is flagged add-as-option; tier follows the same rule."""
_assert_against_decisions_units(
"const r = resolveUrlFilterValues(\n"
" { category: 'diff_checking' },\n"
" { category: ['routing', 'triage'], tier: ['1', '2', '3'] },\n"
");\n"
"assert.strictEqual(r.values.category, 'diff_checking');\n"
"assert.deepStrictEqual(\n"
" r.addOptions, [{ id: 'f-category', value: 'diff_checking' }]\n"
");\n"
"const t = resolveUrlFilterValues(\n"
" { tier: '4' },\n"
" { category: ['routing'], tier: ['1', '2', '3'] },\n"
");\n"
"assert.deepStrictEqual(t.addOptions, [{ id: 'f-tier', value: '4' }]);\n"
"assert.strictEqual(t.values.tier, '4');\n"
"assert.strictEqual(t.values.category, null);"
)
@skip_without_node
def test_resolve_url_filter_values_empty_or_absent_params_leave_controls_untouched():
"""Empty and absent params produce null values everywhere - no control moves."""
_assert_against_decisions_units(
"const untouched = { kind: null, category: null, profile: null, tier: null, search: null, size: null };\n"
"const empty = resolveUrlFilterValues(\n"
" { kind: '', category: '', profile: '', tier: '', q: '', size: '' },\n"
" { kind: ['a'], size: ['50', '100', '250'] },\n"
");\n"
"assert.deepStrictEqual(empty.values, untouched);\n"
"assert.deepStrictEqual(empty.addOptions, []);\n"
"assert.strictEqual(empty.sizeFallback, false);\n"
"const absent = resolveUrlFilterValues({}, {});\n"
"assert.deepStrictEqual(absent.values, untouched);\n"
"assert.deepStrictEqual(absent.addOptions, []);\n"
"assert.strictEqual(absent.sizeFallback, false);"
)
INDEX_HTML = ROOT / "admin" / "frontend" / "index.html"
WARN_SEVERITY_BEGIN = "/* WARN_SEVERITY:BEGIN */"
WARN_SEVERITY_END = "/* WARN_SEVERITY:END */"
DISMISS_UNITS_BEGIN = "/* DISMISS_UNITS:BEGIN */"
DISMISS_UNITS_END = "/* DISMISS_UNITS:END */"
def _assert_against_dismiss_units(assertion_js: str) -> subprocess.CompletedProcess[str]:
"""Assert ``assertion_js`` against the real dismissal key units.
The units (normalizeDigits, dismissKey, migrateDismissedKeys,
pruneDismissed, openWarnings) live in index.html between the
DISMISS_UNITS markers and call two neighbours: warningSeverity (its own
marker pair) and escapeHtml (a one-liner in the UTILITIES section,
pulled by regex). Feeding all three together reproduces the page's
runtime environment without a DOM.
"""
text = INDEX_HTML.read_text(encoding="utf-8")
escape = re.search(r"function escapeHtml\(s\) \{.*?\n\}", text, re.DOTALL)
assert escape, "index.html: escapeHtml source not found"
return _run_node(
"const assert = require('assert');\n"
+ escape.group(0)
+ "\n"
+ _extract_between(INDEX_HTML, WARN_SEVERITY_BEGIN, WARN_SEVERITY_END)
+ "\n"
+ _extract_between(INDEX_HTML, DISMISS_UNITS_BEGIN, DISMISS_UNITS_END)
+ "\n"
+ assertion_js
)
@skip_without_node
def test_dismiss_key_includes_severity():
"""The severity is the key's first component, ahead of the normalized,
escaped text."""
_assert_against_dismiss_units(
"assert.strictEqual(\n"
" dismissKey('plan pace 1.2x sustainable', 'high'),\n"
" 'high:plan pace Nx sustainable'\n"
");\n"
"assert.strictEqual(dismissKey('classifier degraded', 'medium'), 'medium:classifier degraded');"
)
@skip_without_node
def test_dismiss_key_differs_by_severity():
"""Severity-keying is what keeps digit normalization from fusing
different warnings: '0 eligible' is high and '3 eligible' is medium even
though both normalize to 'N eligible'."""
_assert_against_dismiss_units(
"assert.strictEqual(dismissKey('0 eligible models', 'high'), 'high:N eligible models');\n"
"assert.notStrictEqual(\n"
" dismissKey('0 eligible models', 'high'),\n"
" dismissKey('0 eligible models', 'medium')\n"
");"
)
@skip_without_node
def test_migrate_digit_normalizes_to_one_key():
"""A count that moved between polls must not move the stored key: the
3-count and 50-count forms of one warning migrate to the same
digit-normalized key, and an entry already in the new format passes
through untouched so re-migration cannot mangle the store."""
_assert_against_dismiss_units(
"const three = migrateDismissedKeys(['3/52 routable models have no proficiency data']);\n"
"const fifty = migrateDismissedKeys(['50/52 routable models have no proficiency data']);\n"
"assert.deepStrictEqual([...three], [...fifty]);\n"
"assert.strictEqual([...three].length, 1);\n"
"assert.strictEqual(\n"
" [...three][0],\n"
" 'medium:N/N routable models have no proficiency data'\n"
");\n"
"assert.deepStrictEqual(\n"
" [...migrateDismissedKeys(['medium:N/N routable models have no proficiency data'])],\n"
" ['medium:N/N routable models have no proficiency data']\n"
");"
)
@skip_without_node
def test_same_warning_different_count_both_suppressed():
"""Dismissing one count of a warning suppresses the others: once the
3-count version is dismissed, the 50-count re-scan is already hidden,
and the key stays live so pruning does not drop it."""
_assert_against_dismiss_units(
"const dismissed = migrateDismissedKeys(['3/52 routable models have no proficiency data']);\n"
"assert.strictEqual(openWarnings(['3/52 routable models have no proficiency data'], dismissed).length, 0);\n"
"assert.strictEqual(openWarnings(['50/52 routable models have no proficiency data'], dismissed).length, 0);\n"
"assert.strictEqual(\n"
" pruneDismissed(['50/52 routable models have no proficiency data'], dismissed),\n"
" false\n"
");"
)
@skip_without_node
def test_stale_dismissal_pruned_and_new_warnings_open():
"""A dismissed warning whose text is gone has been resolved: the stale
key is pruned on the next render, nothing stays dismissed, and all 50
fresh warnings are open."""
_assert_against_dismiss_units(
"const dismissed = migrateDismissedKeys(['3 eligible models']);\n"
"const fresh = Array.from({ length: 50 }, (_, i) =>\n"
" 'issue ' + String.fromCharCode(65 + Math.floor(i / 26))\n"
" + String.fromCharCode(97 + (i % 26)));\n"
"assert.strictEqual(pruneDismissed(fresh, dismissed), true);\n"
"assert.strictEqual(dismissed.size, 0);\n"
"assert.strictEqual(openWarnings(fresh, dismissed).length, 50);"
)
CONTROLS_HTML = ROOT / "admin" / "frontend" / "controls.html"
MERGE_KNOB_ROWS_BEGIN = "/* MERGE_KNOB_ROWS:BEGIN */"
MERGE_KNOB_ROWS_END = "/* MERGE_KNOB_ROWS:END */"
def _assert_against_merge_units(assertion_js: str) -> subprocess.CompletedProcess[str]:
"""Assert ``assertion_js`` against the real mergeKnobRows source.
The units (normalizeKnobValue, knobValuesDrift, mergeKnobRows) live in
controls.html between the MERGE_KNOB_ROWS markers and are self-contained:
they take the two API payloads as arguments and touch no DOM, so the
extraction runs as-is under node.
"""
return _run_node(
"const assert = require('assert');\n"
+ _extract_between(CONTROLS_HTML, MERGE_KNOB_ROWS_BEGIN, MERGE_KNOB_ROWS_END)
+ "\n"
+ assertion_js
)
RUNTIME_BOTH_AND_ONLY = (
"{\n"
" log_route_decisions: { persisted: true, runtime: false, config_key: 'logging.log_route_decisions' },\n"
" circuit_breaker_enabled: { persisted: true, runtime: true, config_key: 'circuit_breaker.enabled' },\n"
"}"
)
PERSISTED_BOTH_AND_ONLY = (
"{\n"
" 'logging.level': { value: 'info', source: 'base' },\n"
" 'circuit_breaker.enabled': { value: true, source: 'base' },\n"
" 'objective.quality_tolerance': { value: 0.7, source: 'base' },\n"
"}"
)
@skip_without_node
def test_merge_knob_rows_row_set_is_the_union_of_both_key_sets():
"""Runtime rows pair by the config_key the API carries; persisted rows by
their own key. The row set is the union: a one-sided knob still gets a
row, with the other column empty."""
_assert_against_merge_units(
f"const rows = mergeKnobRows({RUNTIME_BOTH_AND_ONLY}, {PERSISTED_BOTH_AND_ONLY});\n"
"assert.deepStrictEqual(Object.keys(rows).sort(), [\n"
" 'circuit_breaker.enabled',\n"
" 'logging.level',\n"
" 'logging.log_route_decisions',\n"
" 'objective.quality_tolerance',\n"
"]);\n"
"assert.strictEqual(rows['logging.log_route_decisions'].persistedItem, null);\n"
"assert.strictEqual(rows['logging.log_route_decisions'].runtimeItem.knob, 'log_route_decisions');\n"
"assert.strictEqual(rows['objective.quality_tolerance'].runtimeItem, null);\n"
"assert.strictEqual(rows['objective.quality_tolerance'].persistedItem.value, 0.7);"
)
@skip_without_node
def test_merge_knob_rows_one_sided_inputs_yield_one_sided_rows():
"""Only one payload present (a failed fetch, or the runtime fetch landing
first): rows render from what is available, never from nothing."""
_assert_against_merge_units(
"const runtimeOnly = mergeKnobRows(\n"
" { k: { persisted: true, runtime: false, config_key: 'a.enabled' } },\n"
" null\n"
");\n"
"assert.deepStrictEqual(Object.keys(runtimeOnly), ['a.enabled']);\n"
"assert.strictEqual(runtimeOnly['a.enabled'].persistedItem, null);\n"
"const persistedOnly = mergeKnobRows(null, { 'a.enabled': { value: true, source: 'base' } });\n"
"assert.deepStrictEqual(Object.keys(persistedOnly), ['a.enabled']);\n"
"assert.strictEqual(persistedOnly['a.enabled'].runtimeItem, null);\n"
"assert.deepStrictEqual(mergeKnobRows(null, null), {});"
)
@skip_without_node
def test_merge_knob_rows_drift_rule_follows_the_runtime_payload_only():
"""A row WITH a runtime item drifts when its runtime value differs from
the payload's persisted value -- both-sides rows and runtime-only rows
alike, because a restart reverts them both. A persisted-only row never
drifts: no live value exists."""
_assert_against_merge_units(
f"const rows = mergeKnobRows({RUNTIME_BOTH_AND_ONLY}, {PERSISTED_BOTH_AND_ONLY});\n"
"assert.strictEqual(rows['logging.log_route_decisions'].drift, true);\n"
"assert.strictEqual(rows['circuit_breaker.enabled'].drift, false);\n"
"assert.strictEqual(rows['objective.quality_tolerance'].drift, false);"
)
@skip_without_node
def test_merge_knob_rows_ignores_the_persisted_columns_input_value():
"""Drift is computed from the runtime payload alone. A persisted column
holding an unsaved dirty edit ('debug') must not read as drift when the
router is running exactly what a restart would load ('info')."""
_assert_against_merge_units(
"const rows = mergeKnobRows(\n"
" { k: { persisted: 'info', runtime: 'info', config_key: 'logging.level' } },\n"
" { 'logging.level': { value: 'info', source: 'base' } }\n"
");\n"
"assert.strictEqual(rows['logging.level'].drift, false);"
)
@skip_without_node
@pytest.mark.parametrize(
"persisted,runtime,expect_drift",
[
("0.10", 0.1, False), # numbers compare numerically
(0.1, "0.10", False),
(2, 2.0, False),
(True, False, True),
(False, True, True),
("info", "debug", True),
(None, 5, False), # null/absent on either side is not drift
(5, None, False),
(None, None, False),
],
)
def test_merge_knob_rows_value_normalization(persisted, runtime, expect_drift):
"""Booleans as booleans, numbers numerically, null/absent never drifting."""
def js(v: object) -> str:
# Python repr is not JavaScript: True/False/None have other names.
return {True: "true", False: "false", None: "null"}.get(v, repr(v))
_assert_against_merge_units(
"const rows = mergeKnobRows(\n"
f" {{ k: {{ persisted: {js(persisted)}, runtime: {js(runtime)}, config_key: 'a.knob' }} }},\n"
" {}\n"
");\n"
f"assert.strictEqual(rows['a.knob'].drift, {str(expect_drift).lower()});"
)

View File

@@ -68,13 +68,17 @@ No database, no network, no config file is read or written.
from __future__ import annotations
import enum
import sqlite3
import types
import typing
from pathlib import Path
from pydantic import BaseModel
from starlette.testclient import TestClient
import admin
from config import RouterConfig
import dispatcher
from config import RouterConfig, load_config
# Clause 2 of the scope rule. A leaf with one of these names configures WHERE
# the router points (endpoint, model id, credential env var, filesystem path,
@@ -495,3 +499,82 @@ def test_admin_registries_point_at_real_config_knobs():
"admin registry path(s) that are not scalar fields of RouterConfig:\n "
+ "\n ".join(phantom)
)
# --- Component 6: one knob table -----------------------------------------------
# The two tests below step outside the pure-reflection contract documented
# above: the first drives the real app (still offline -- a temp SQLite file,
# no network, no port), the second reads admin/frontend/controls.html as
# text. Both exist because the merged knob table pairs runtime rows with
# persisted rows by a config key the API labels and the frontend source
# selects on, and neither half of that contract is visible to reflection.
ROOT = Path(__file__).resolve().parent.parent
SCHEMA_SQL = (ROOT / "config" / "schema.sql").read_text()
CONTROLS_HTML = ROOT / "admin" / "frontend" / "controls.html"
def test_runtime_api_labels_every_knob_with_a_real_config_key(tmp_path, monkeypatch):
"""GET /admin/api/runtime labels every knob with a resolvable config path.
The controls page pairs each runtime row with its persisted twin by
config_key alone, so a missing or mistyped label renders one-sided rows
that cannot be fixed from the UI. Walking getattr down a loaded
RouterConfig checks each label against the model itself, independently
of the registry that produced it.
"""
conn = sqlite3.connect(str(tmp_path / "test.db"))
conn.row_factory = sqlite3.Row
conn.executescript(SCHEMA_SQL)
conn.close()
monkeypatch.setattr(dispatcher.cfg.database, "path", str(tmp_path / "test.db"))
monkeypatch.setenv("NEURALWATT_API_KEY", "test-key")
with TestClient(dispatcher.app) as client:
resp = client.get("/admin/api/runtime")
assert resp.status_code == 200
body = resp.json()
assert body
cfg = load_config(str(ROOT / "config" / "config.yaml"))
for knob, item in body.items():
assert set(item) == {"persisted", "runtime", "config_key"}, knob
# An AttributeError here names the broken label directly.
obj: typing.Any = cfg
for part in item["config_key"].split("."):
obj = getattr(obj, part)
keys = [item["config_key"] for item in body.values()]
assert len(set(keys)) == len(keys), (
"runtime knobs sharing one config_key would fuse into one table row: "
+ ", ".join(sorted(k for k in keys if keys.count(k) > 1))
)
assert set(body) == set(admin._RUNTIME_KNOB_PATHS)
def test_controls_page_keeps_the_table_selectors_its_js_depends_on():
"""controls.html still serves the strings the dependent JS sites select.
markDirty and the save collector re-query
``#config-list .setting-row[data-key]``, the default-profile hint looks
its row up by exact data-key, init() attaches delegated listeners by the
list's id, and the profile select is found via data-profile-select. A
rename in any of these silently kills dirty tracking, saving and the
hint. The tbody fallback must stay table-shaped -- a bare <div> inside
a <tbody> is invalid markup the browser will hoist out of the table.
"""
html = CONTROLS_HTML.read_text(encoding="utf-8")
assert 'id="config-list"' in html
for selector in (
'#config-list .setting-row[data-key="routing.default_profile"]',
"#config-list .setting-row[data-key]",
"getElementById('config-list')",
):
assert selector in html, selector
# The persisted row template still marks rows for the collectors.
assert 'class="setting-row"' in html
assert " data-key=" in html
assert " data-orig=" in html
assert "data-config-input" in html
assert "data-profile-select" in html
assert '<tr><td colspan="4"' in html

View File

@@ -9,6 +9,17 @@ more here than care.
The page list is derived from the routes the router actually serves, so a
new admin page fails this until it is linked from the others, rather than
quietly launching unreachable.
The links render client-side now: navbar.js builds them from its
NAV_LINKS array into the empty <ul id="nav-links"> every page ships, so
statically parsing each page for anchors would report every link missing
on every page. The invariant the old parse guarded -- a complete list,
present everywhere -- moved with the list itself: it is defined exactly
once (in navbar.js, as complete as the route table) and every page carries
the mount that renders it. Hardcoded <li class="nav-item"> entries are
how the pages drifted originally, so their return fails this too. The
active-page highlighting lives in tests/test_admin_js_units.py, which
runs navbar.js's real activeNavHref through node.
"""
from __future__ import annotations
@@ -19,6 +30,7 @@ from pathlib import Path
import pytest
FRONTEND = Path(__file__).resolve().parent.parent / "admin" / "frontend"
NAVBAR_JS = FRONTEND / "navbar.js"
# href -> file that serves it. Kept explicit rather than globbed so that
# adding a page is a deliberate two-line edit here, not an accident.
@@ -33,42 +45,56 @@ PAGES = {
"/admin/providers": "providers.html",
}
# The nav renders every page except the landing page, which is reached by
# the logo -- a second door to the same place, in the row where width is
# scarcest. Order is the order the bar displays.
NAV_HREFS = list(PAGES)[1:]
NAV_LINKS_BEGIN = "/* NAV_LINKS:BEGIN */"
NAV_LINKS_END = "/* NAV_LINKS:END */"
NAV_RE = re.compile(r'<header class="navbar.*?</header>', re.DOTALL)
def _nav_hrefs(filename: str) -> set[str]:
html = (FRONTEND / filename).read_text(encoding="utf-8")
nav = NAV_RE.search(html)
assert nav, f"{filename}: no <header class=\"navbar\"> block found"
return set(re.findall(r'href="(/admin[^"]*)"', nav.group(0)))
def _nav_hrefs() -> list[str]:
"""The hrefs navbar.js renders, in file order, read from NAV_LINKS."""
js = NAVBAR_JS.read_text(encoding="utf-8")
begin = js.find(NAV_LINKS_BEGIN)
end = js.find(NAV_LINKS_END)
assert begin != -1, "navbar.js: NAV_LINKS block not found"
assert end > begin, "navbar.js: NAV_LINKS:END absent or before begin"
return re.findall(r"href:\s*'([^']+)'", js[begin:end])
@pytest.mark.parametrize("filename", sorted(set(PAGES.values())))
def test_every_page_links_to_every_page(filename):
missing = set(PAGES) - _nav_hrefs(filename)
assert not missing, (
f"{filename} nav is missing {sorted(missing)} -- a page you cannot "
f"reach from here"
def test_navbar_js_owns_the_complete_link_list():
"""The one list is as complete as the route table, in bar order.
A page added to PAGES without a NAV_LINKS entry fails here while it
is still a two-line fix, instead of launching silently unreachable.
"""
assert _nav_hrefs() == NAV_HREFS, (
f"NAV_LINKS drifted from the served pages; expected {NAV_HREFS}"
)
@pytest.mark.parametrize("filename", sorted(set(PAGES.values())))
def test_no_nav_link_points_at_a_page_that_does_not_exist(filename):
"""The other direction: a typo'd href is a dead link, not a missing one."""
unknown = {h for h in _nav_hrefs(filename) if h not in PAGES}
assert not unknown, f"{filename} nav links to unknown {sorted(unknown)}"
@pytest.mark.parametrize("filename", sorted(PAGES.values()))
def test_every_page_ships_the_mount_and_the_script(filename):
"""Each page carries the empty #nav-links ul and the script that fills it.
@pytest.mark.parametrize("filename", sorted(set(PAGES.values())))
def test_each_page_marks_itself_active(filename):
"""A nav that never highlights leaves you unsure which page you are on."""
Old failure shape, new shape: instead of parsing anchors (they only
exist after navbar.js runs in the browser), completeness per page is
the mount plus the script tag -- and the absence of any hardcoded
nav-item <li>, because those are what drifted across eight copies.
"""
html = (FRONTEND / filename).read_text(encoding="utf-8")
nav = NAV_RE.search(html).group(0)
if filename == "index.html":
# The landing page is reached by the logo, not by a nav entry -- a
# second door to the same place, in the row where width is scarcest.
# So there is nothing for it to mark, and nothing may claim active.
assert 'nav-link" href="/admin/"' not in nav, "the landing page has no nav entry"
assert 'class="nav-item active"' not in nav
return
assert 'class="nav-item active"' in nav, f"{filename}: no active nav item"
nav = NAV_RE.search(html)
assert nav, f'{filename}: no <header class="navbar"> block found'
assert '<ul class="navbar-nav" id="nav-links"></ul>' in nav.group(0), (
f"{filename}: the empty #nav-links mount is missing from the navbar"
)
assert '<script src="navbar.js"></script>' in html, (
f"{filename}: navbar.js is not included"
)
assert '<li class="nav-item' not in nav.group(0), (
f"{filename}: hardcoded nav entries are back -- render them from navbar.js"
)

View File

@@ -264,13 +264,18 @@ def test_recent_client_outcomes_respects_its_limit(tmp_path):
def test_page_is_served_and_reachable_from_every_other_page(client):
# Reachability moved client-side with the nav: each page ships the
# empty #nav-links mount and navbar.js renders the list into it, so
# the every-page-links-everywhere invariant is owned by
# tests/test_admin_nav_is_complete.py (navbar.js's NAV_LINKS). Here
# the page contract is just that the mount exists.
c, _cfg = client
resp = c.get("/admin/proficiency")
assert resp.status_code == 200
assert resp.headers["content-type"].startswith("text/html")
for name in ("index", "models", "profiles", "decisions", "controls"):
page = (ROOT / "admin" / "frontend" / f"{name}.html").read_text()
assert "/admin/proficiency" in page, f"no Proficiency nav link on {name}.html"
assert 'id="nav-links"' in page, f"no nav-links mount on {name}.html"
def test_page_distinguishes_measured_from_inherited(client):

View File

@@ -93,7 +93,7 @@ def test_runtime_GET_reports_every_knob(seeded_client):
"default_flex_preference",
):
assert knob in body
assert set(body[knob]) == {"persisted", "runtime"}
assert set(body[knob]) == {"persisted", "runtime", "config_key"}
# The runtime value mirrors the live dispatcher.cfg for the boolean knobs;
# the persisted value mirrors config.yaml. circuit_breaker_enabled is a
@@ -105,10 +105,12 @@ def test_runtime_GET_reports_every_knob(seeded_client):
assert body["circuit_breaker_enabled"] == {
"persisted": CFG.circuit_breaker.enabled,
"runtime": dispatcher.cfg.circuit_breaker.enabled,
"config_key": "circuit_breaker.enabled",
}
assert body["default_flex_preference"] == {
"persisted": CFG.routing.default_flex_preference.value,
"runtime": dispatcher.cfg.routing.default_flex_preference.value,
"config_key": "routing.default_flex_preference",
}
@@ -504,7 +506,11 @@ def test_post_non_boolean_for_bool_knob_returns_422(seeded_client):
def test_active_profile_is_reported_with_persisted_and_runtime(seeded_client):
resp = seeded_client.get("/admin/api/runtime")
assert resp.status_code == 200
assert set(resp.json()["active_profile"]) == {"persisted", "runtime"}
assert set(resp.json()["active_profile"]) == {
"persisted",
"runtime",
"config_key",
}
def test_active_profile_switch_changes_what_auto_resolves_to(

View File

@@ -28,6 +28,7 @@ EXHAUSTIVE_KEYS = (
"recent_decisions",
"per_model",
"verdict_mix",
"decision_outcomes",
"top_proficiency",
"local_energy",
"health",
@@ -191,3 +192,9 @@ def test_admin_snapshot_empty_db_still_returns_all_keys(tmp_path, monkeypatch):
assert data["per_model"] == []
assert data["recent_decisions"] == []
assert data["top_proficiency"] == []
assert data["decision_outcomes"] == {
"decisions": 0,
"client_reports": 0,
"client_ok": 0,
"client_failed": 0,
}

View File

@@ -39,6 +39,7 @@ from metrics import (
conversation_adoption,
cumulative_spend_series,
cumulative_spend_warnings,
decision_outcome_summary,
demand_ceiling_warnings,
local_energy_summary,
per_model,
@@ -2780,3 +2781,115 @@ def test_log_local_energy_persists_session_key(tmp_path):
conn.close()
assert row is not None
assert row["session_key"] == "c:conv-42"
# --- decision_outcome_summary (admin Decisions tile) ---------------------------
def _insert_client_outcome(
conn: sqlite3.Connection,
verdict: str,
observed_at: str,
) -> None:
conn.execute(
"INSERT INTO verifications (model_id, provider, kind, verdict, "
"observed_at) VALUES ('cheap', 'neuralwatt', 'client_outcome', ?, ?)",
(verdict, observed_at),
)
def _insert_routed_decision(conn: sqlite3.Connection, observed_at: str) -> None:
conn.execute(
"""
INSERT INTO route_decisions (
observed_at, kind, task_category, task_tier, required_context_tokens,
confidence, classifier_ms, classification_source, latency_tolerance,
candidates_considered, selected_model, selected_provider,
runner_up_models, est_cost_usd, est_proficiency,
session_key, tools, images, json_mode, streamed
) VALUES (?, 'route', 'coding_general', 2, 100, 0.95, 200,
'classifier', 'interactive', 5, 'cheap', 'neuralwatt',
NULL, 0.001, 0.9, 'abc123', 0, 0, 0, 0)
""",
(observed_at,),
)
def test_decision_outcome_summary_counts_only_client_outcome_kinds(tmp_path):
"""client_* counts verifications kind='client_outcome' ONLY.
structural and local_llm verdicts are diagnostics, not ground truth:
a structural 'ok' must never raise client_ok, and neither diagnostic
kind may raise client_reports.
"""
conn = _make_db(tmp_path)
_seed_models(conn)
now = _now().isoformat()
for kind, verdict in (
("structural", "ok"),
("structural", "failed"),
("local_llm", "ok"),
("local_llm", "malformed"),
("client_outcome", "succeeded"),
("client_outcome", "succeeded"),
("client_outcome", "failed"),
):
conn.execute(
"INSERT INTO verifications (model_id, provider, kind, verdict, "
"observed_at) VALUES ('cheap', 'neuralwatt', ?, ?, ?)",
(kind, verdict, now),
)
conn.commit()
summary = decision_outcome_summary(conn)
assert summary["client_reports"] == 3
assert summary["client_ok"] == 2
assert summary["client_failed"] == 1
def test_decision_outcome_summary_decisions_come_from_route_decisions(tmp_path):
"""decisions counts route_decisions rows, independent of verifications."""
conn = _make_db(tmp_path)
_seed_models(conn)
now = _now().isoformat()
for _ in range(4):
_insert_routed_decision(conn, now)
_insert_client_outcome(conn, "succeeded", now)
conn.commit()
summary = decision_outcome_summary(conn)
assert summary["decisions"] == 4
assert summary["client_reports"] == 1
assert summary["client_ok"] == 1
assert summary["client_failed"] == 0
def test_decision_outcome_summary_excludes_out_of_window_rows(tmp_path):
"""A 10-day-old decision and a 10-day-old client report both miss the
default 7-day window."""
conn = _make_db(tmp_path)
_seed_models(conn)
now = _now().isoformat()
old = (datetime.now(timezone.utc) - timedelta(days=10)).isoformat()
_insert_routed_decision(conn, old)
_insert_routed_decision(conn, now)
_insert_client_outcome(conn, "failed", old)
_insert_client_outcome(conn, "succeeded", now)
conn.commit()
summary = decision_outcome_summary(conn)
assert summary["decisions"] == 1
assert summary["client_reports"] == 1
assert summary["client_ok"] == 1
assert summary["client_failed"] == 0
def test_decision_outcome_summary_empty_db_is_all_zeros(tmp_path):
"""An empty DB yields a dict of zeros -- not None, not missing keys."""
conn = _make_db(tmp_path)
assert decision_outcome_summary(conn) == {
"decisions": 0,
"client_reports": 0,
"client_ok": 0,
"client_failed": 0,
}