feat(admin): cockpit quick wins, six admin portal fixes (R5 G1 E3 R2 G3 C1) #101
@@ -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 — 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…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="text-muted settings-hint">Live on the next request — in-memory only, reverts on restart, no <code>config.yaml</code> write.</p>
|
||||
<p class="text-muted settings-hint">Applied on restart — allowlisted keys, written to <code>config/config.local.yaml</code> (machine-local overlay; <code>config.yaml</code> stays clean).</p>
|
||||
<div class="settings-list" id="config-list">
|
||||
<div class="text-muted fst-italic small px-2 py-1">Loading config…</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -676,39 +680,93 @@ 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 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);
|
||||
function knobValuesDrift(a, b) {
|
||||
const na = normalizeKnobValue(a);
|
||||
const nb = normalizeKnobValue(b);
|
||||
if (na === null || nb === null) return false;
|
||||
return na !== nb;
|
||||
}
|
||||
|
||||
let control;
|
||||
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') {
|
||||
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)">
|
||||
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>`;
|
||||
} else if (ENUM_VALUES[key]) {
|
||||
control = enumSelect(key, runtime, { onchangeExpr: `toggleKnob('${key}', this.value)` });
|
||||
} else if (typeof persisted === 'number') {
|
||||
}
|
||||
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 -- 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] || {};
|
||||
// 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}"` : '',
|
||||
@@ -716,24 +774,116 @@ function renderRuntime(state) {
|
||||
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>`;
|
||||
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>`;
|
||||
}
|
||||
|
||||
// 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(' ');
|
||||
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>` : ''}`;
|
||||
}
|
||||
|
||||
return settingRow({ key, meta, control });
|
||||
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('');
|
||||
el.innerHTML = html || '<div class="text-muted fst-italic small px-2 py-1">No runtime knobs</div>';
|
||||
updateDefaultProfileHint();
|
||||
markDirty();
|
||||
}
|
||||
|
||||
function renderRuntime(state) {
|
||||
_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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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} →</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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = '' +
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
29
src/admin.py
29
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
460
tests/test_admin_js_units.py
Normal file
460
tests/test_admin_js_units.py
Normal 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()});"
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user