diff --git a/admin/frontend/controls.html b/admin/frontend/controls.html index 51108f8..31d5cf1 100644 --- a/admin/frontend/controls.html +++ b/admin/frontend/controls.html @@ -129,6 +129,16 @@ header.navbar{padding-top:2px!important;padding-bottom:2px!important} .setting-row+.setting-row{border-top:1px solid rgba(255,255,255,.05)} .setting-row:hover{background:rgba(255,255,255,.025)} .setting-row.is-dirty{border-left-color:#fbbf24;background:rgba(245,158,11,.06)} +/* The knob table keeps .setting-row on its s -- four JS sites select + #config-list .setting-row[data-key] -- but the rule above is a div-grid + and destroys table layout, so inside this table the row is table-native. + Scoped so any future div list keeps the grid grammar. */ +.knobs-table .setting-row{display:table-row;min-height:0;padding:0;border-radius:0;border-left:2px solid transparent} +.knobs-table .setting-row.is-dirty{border-left-color:#fbbf24;background:rgba(245,158,11,.06)} +/* A config key wraps instead of truncating: the knob cell is the row's + identity, and a clipped path hides which scope the row belongs to. */ +.knobs-table .setting-key{white-space:normal;overflow:visible;text-overflow:clip} +.knobs-table .setting-key .scope{display:inline} .setting-key{ font-size:.82rem;line-height:1.25; overflow:hidden;text-overflow:ellipsis;white-space:nowrap; @@ -183,15 +193,7 @@ header.navbar{padding-top:2px!important;padding-bottom:2px!important} @@ -261,7 +263,9 @@ header.navbar{padding-top:2px!important;padding-bottom:2px!important}
- + @@ -279,7 +283,7 @@ header.navbar{padding-top:2px!important;padding-bottom:2px!important}
- +
@@ -378,34 +382,34 @@ header.navbar{padding-top:2px!important;padding-bottom:2px!important} - -
-
-

Runtime Knobs

-
-

Live on the next request — in-memory only, reverts on restart, no config.yaml write.

-
-
-
-
- -
+ +
-

Persisted Config

+

Knobs

+ + + + + + + + + + + + +
KnobLivePersistedLayer
Loading config…
+

Live on the next request — in-memory only, reverts on restart, no config.yaml write.

Applied on restart — allowlisted keys, written to config/config.local.yaml (machine-local overlay; config.yaml stays clean).

-
-
Loading config…
-
@@ -676,64 +680,210 @@ const NUMBER_BOUNDS = { session_cache_staleness_seconds: { min: 5, max: 7200, step: 1 }, }; -function settingRow({ key, meta = '', control, dirtyAttrs = '' }) { - return `
- ${keyHtml(key)} - ${meta} - ${control} -
`; +/* One knob table replaces the old runtime-list/config-list div columns. + Runtime rows and persisted rows pair by the dotted config key the + /admin/api/runtime payload carries in `config_key` -- the join lives here, + once, instead of being re-derived from the knob registries in JS. */ +let _runtimeState = null; // /admin/api/runtime payload +let _configState = null; // /admin/api/config payload + +/* MERGE_KNOB_ROWS:BEGIN */ +function normalizeKnobValue(v) { + // Numbers compare numerically (0.1 vs "0.10" is one value), booleans stay + // booleans, and null/absent is "no information", which never disagrees. + if (v === null || v === undefined) return null; + if (typeof v === 'boolean') return v; + const n = Number(v); + return Number.isFinite(n) ? n : String(v); +} + +function knobValuesDrift(a, b) { + const na = normalizeKnobValue(a); + const nb = normalizeKnobValue(b); + if (na === null || nb === null) return false; + return na !== nb; +} + +function mergeKnobRows(runtimeItems, persistedItems) { + // Rows keyed by dotted config key = the UNION of the two payloads' keys. + // Persisted keys first, in the payload's order (the API returns allowlist + // order), then runtime-only knobs in the runtime payload's order. A + // one-sided knob gets a row with the other column empty. + const runtimeByConfigKey = {}; + for (const [knob, item] of Object.entries(runtimeItems || {})) { + if (!item || !item.config_key) continue; + runtimeByConfigKey[item.config_key] = { ...item, knob }; + } + const rows = {}; + for (const key of Object.keys(persistedItems || {})) { + rows[key] = { + configKey: key, + runtimeItem: runtimeByConfigKey[key] || null, + persistedItem: persistedItems[key] || null, + }; + } + for (const [configKey, item] of Object.entries(runtimeByConfigKey)) { + if (rows[configKey]) continue; + rows[configKey] = { configKey, runtimeItem: item, persistedItem: null }; + } + // Drift reads ONLY the runtime payload: its `persisted` field is the merged + // base+overlay value from load_config, so it says what a restart would + // load. The persisted column's input can hold an unsaved dirty edit, which + // is NOT what a restart would load, so it never feeds this. Runtime-only + // knobs (no persisted column) drift the same way -- the restart reverts + // them just the same. + for (const row of Object.values(rows)) { + row.drift = row.runtimeItem !== null + && knobValuesDrift(row.runtimeItem.runtime, row.runtimeItem.persisted); + } + return rows; +} +/* MERGE_KNOB_ROWS:END */ + +// The restart signal, in the knob cell where the row's identity is read: +// what the file would load vs what is running now, both resolved. The +// persisted column may show a raw/blank YAML value while this tooltip shows +// the resolved one -- that distinction is deliberate. +function driftBadge(rt) { + const persistedStr = knobDisplayValue(rt.persisted); + const runtimeStr = knobDisplayValue(rt.runtime); + return `reverts on restart`; +} + +function runtimeControlHtml(knob, knobState) { + const persisted = knobState.persisted; + const runtime = knobState.runtime; + const runtimeStr = knobDisplayValue(runtime); + if (typeof persisted === 'boolean') { + return `
+ +
`; + } + if (ENUM_VALUES[knob]) { + return enumSelect(knob, runtime, { onchangeExpr: `toggleKnob('${knob}', this.value)` }); + } + if (typeof persisted === 'number') { + // Blank posts null, NOT 0. `Number('')` is 0 in JavaScript and 0 is a + // real, costly setting on both numeric knobs, so clearing a field must + // never be read as choosing it. What null MEANS is the server's call. + const b = NUMBER_BOUNDS[knob] || {}; + const attrs = [ + b.min !== undefined ? `min="${b.min}"` : '', + b.max !== undefined ? `max="${b.max}"` : '', + b.step !== undefined ? `step="${b.step}"` : '', + b.placeholder ? `placeholder="${escapeHtml(b.placeholder)}"` : '', + ].filter(Boolean).join(' '); + const shown = (runtime === null || runtime === undefined) ? '' : String(runtime); + return ``; + } + if (typeof persisted === 'string') { + return ``; + } + return `${escapeHtml(runtimeStr)}`; +} + +function persistedControlHtml(key, entry, dirtyValue) { + // `dirtyValue` is the user's current input on a re-render that would + // otherwise reset it (loadControls re-runs after every toggle and ~1.5s + // after a save); undefined means render from the payload value. + const dirty = dirtyValue !== undefined && dirtyValue !== null; + const val = dirty + ? (typeof entry.value === 'boolean' ? dirtyValue === 'true' : dirtyValue) + : entry.value; + let control; + if (typeof val === 'boolean') { + // A switch, matching the Live column -- the bare checkbox here was the + // one control on the page that didn't look like the others. + control = `
+ +
`; + } else if (key === 'routing.default_profile') { + control = profileSelect(val); + } else if (ENUM_VALUES[key]) { + control = enumSelect(key, val, { dataAttr: 'data-config-input' }); + } else { + control = ``; + } + // Units the config file expresses but the key name doesn't, plus the + // knob's consequence note, stay beside the persisted control. + const hints = [ + UNITS[key] ? escapeHtml(UNITS[key]) : '', + noteBadge(key), + ].filter(Boolean).join(' '); + return `${control}${hints ? `${hints}` : ''}`; +} + +function knobRowHtml(row) { + const rt = row.runtimeItem; + const entry = row.persistedItem; + const keyCell = row.drift + ? `${keyHtml(row.configKey)} ${driftBadge(rt)}` + : keyHtml(row.configKey); + const liveCell = rt + ? runtimeControlHtml(rt.knob, rt) + : '-'; + let persistedCell = '-'; + let layerCell = ''; + let dirtyAttrs = ''; + if (entry) { + persistedCell = persistedControlHtml(row.configKey, entry, row.dirtyValue); + // Provenance is the layer column: where the effective value came from. + let layer = ''; + if (entry.source === 'overlay') { + layer = 'overlay'; + } else if (entry.source === 'base') { + layer = 'base'; + } + layerCell = `${layer}`; + dirtyAttrs = ` data-key="${escapeHtml(row.configKey)}" data-orig="${escapeHtml(String(entry.value === null ? '' : entry.value))}"`; + } + return ` + ${keyCell} + ${liveCell} + ${persistedCell} + ${layerCell} + `; +} + +// Before a re-render: the current input of every dirty persisted row, so an +// in-flight unsaved edit survives the refetch instead of silently reverting. +function captureDirtyPersistedInputs() { + const saved = {}; + document.querySelectorAll('#config-list .setting-row[data-key]').forEach(row => { + const input = row.querySelector('[data-config-input]'); + if (!input) return; + const current = input.type === 'checkbox' ? String(input.checked) : input.value; + if (current !== row.getAttribute('data-orig')) { + saved[row.getAttribute('data-key')] = current; + } + }); + return saved; +} + +// One render path over both payloads. Either may be null (a failed fetch, +// or the runtime fetch completing before the config one): rows render from +// whatever is available, and the whole table re-renders idempotently. +function renderKnobsTable() { + const list = document.getElementById('config-list'); + const dirtyInputs = captureDirtyPersistedInputs(); + const rows = mergeKnobRows(_runtimeState, _configState); + const keys = Object.keys(rows); + if (!keys.length) { + list.innerHTML = 'No knobs to show'; + return; + } + list.innerHTML = keys.map(key => { + const row = rows[key]; + row.dirtyValue = row.persistedItem ? dirtyInputs[key] : undefined; + return knobRowHtml(row); + }).join(''); + updateDefaultProfileHint(); + markDirty(); } function renderRuntime(state) { - const el = document.getElementById('runtime-list'); - const knobs = state || {}; - const html = Object.keys(knobs).map(key => { - const knob = knobs[key]; - const persisted = knob.persisted; - const runtime = knob.runtime; - const isDiff = JSON.stringify(persisted) !== JSON.stringify(runtime); - const runtimeStr = knobDisplayValue(runtime); - - let control; - if (typeof persisted === 'boolean') { - control = `
- -
`; - } else if (ENUM_VALUES[key]) { - control = enumSelect(key, runtime, { onchangeExpr: `toggleKnob('${key}', this.value)` }); - } else if (typeof persisted === 'number') { - // Blank posts null, NOT 0. `Number('')` is 0 in JavaScript and 0 is a - // real, costly setting on both numeric knobs -- the maximum penalty on - // the challenger dial, and an out-of-range not-quite-off on the session - // cache window -- so clearing a field must never be read as choosing it. - // What null MEANS is the server's call: neutral for the dial, a refusal - // naming session_cache_enabled for the window. - const b = NUMBER_BOUNDS[key] || {}; - const attrs = [ - b.min !== undefined ? `min="${b.min}"` : '', - b.max !== undefined ? `max="${b.max}"` : '', - b.step !== undefined ? `step="${b.step}"` : '', - b.placeholder ? `placeholder="${escapeHtml(b.placeholder)}"` : '', - ].filter(Boolean).join(' '); - const shown = (runtime === null || runtime === undefined) ? '' : String(runtime); - control = ``; - } else if (typeof persisted === 'string') { - control = ``; - } else { - control = `${escapeHtml(runtimeStr)}`; - } - - // The control already shows the live value, so the only facts worth a - // second column are disagreement with the file and a consequence the - // switch cannot state for itself. - const diffBadge = isDiff - ? `file: ${escapeHtml(knobDisplayValue(persisted))}` - : ''; - const meta = [noteBadge(key), diffBadge].filter(Boolean).join(' '); - - return settingRow({ key, meta, control }); - }).join(''); - el.innerHTML = html || '
No runtime knobs
'; + _runtimeState = state; + renderKnobsTable(); } /* A knob POST that keeps the server's `detail`. The generic apiFetch swallows @@ -819,50 +969,8 @@ async function setLocalCompute(enabled) { let _profilesCache = null; function renderConfig(config) { - const list = document.getElementById('config-list'); - if (!config || !Object.keys(config).length) { - list.innerHTML = '
No config data
'; - return; - } - const html = Object.entries(config).map(([key, raw]) => { - const entry = raw || {}; - const val = entry.value; - const source = entry.source; - const isBool = typeof val === 'boolean'; - let control; - if (isBool) { - // A switch, matching Runtime Knobs — the bare checkbox here was the one - // control on the page that didn't look like the others. - control = `
- -
`; - } else if (key === 'routing.default_profile') { - control = profileSelect(val); - } else if (ENUM_VALUES[key]) { - control = enumSelect(key, val, { dataAttr: 'data-config-input' }); - } else { - control = ``; - } - - // Provenance badge is legitimate meta: it explains where the effective - // value came from without restating the value the control already shows. - let meta = [UNITS[key] ? escapeHtml(UNITS[key]) : '', noteBadge(key)].filter(Boolean).join(' '); - if (source === 'overlay') { - meta += `${meta ? ' ' : ''}overlay`; - } else if (source === 'base') { - meta += `${meta ? ' ' : ''}base`; - } - - return settingRow({ - key, - meta, - control, - dirtyAttrs: ` data-key="${escapeHtml(key)}" data-orig="${escapeHtml(String(val === null ? '' : val))}"`, - }); - }).join(''); - list.innerHTML = html; - updateDefaultProfileHint(); - markDirty(); + _configState = config; + renderKnobsTable(); } function profileSelect(currentValue) { @@ -1111,9 +1219,23 @@ function renderClassifierCandidateCategories(data) { async function loadClassifierConfig() { const data = await apiFetch(`${API}api/classifier-config`); - if (!data) return; + if (!data) { + /* Stays disabled; only the placeholder label flips, the option itself + is kept (a later success reuses the same select). */ + document.getElementById('classifier-mode-select').disabled = true; + document.getElementById('classifier-save-btn').disabled = true; + const placeholder = document.querySelector('#classifier-mode-select option[value=""]'); + if (placeholder) placeholder.textContent = 'unavailable'; + showClassifierConfigError('classifier config unavailable; controls disabled'); + return; + } _classifierConfigData = data; + /* saveClassifierConfig re-awaits this after every successful save, so the + placeholder may already be gone by the second run -- guard, don't crash. */ + document.querySelector('#classifier-mode-select option[value=""]')?.remove(); document.getElementById('classifier-mode-select').value = data.mode; + document.getElementById('classifier-mode-select').disabled = false; + document.getElementById('classifier-save-btn').disabled = false; document.getElementById('classifier-mode-fields').innerHTML = classifierModeFieldsHtml(data.mode, data); renderClassifierResolvedPrimary(data); renderClassifierCandidateCategories(data); diff --git a/admin/frontend/decisions.html b/admin/frontend/decisions.html index 00da1a0..2b3671b 100644 --- a/admin/frontend/decisions.html +++ b/admin/frontend/decisions.html @@ -180,15 +180,7 @@ header.navbar{padding-top:2px!important;padding-bottom:2px!important}
@@ -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 = '' + - [...(_knownKinds)].sort().map(k => ``).join(''); - catSel.innerHTML = '' + - [...(_knownCategories)].sort().map(c => ``).join(''); - profSel.innerHTML = '' + - [...(_knownProfiles)].sort().map(p => ``).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 => ``).join(''); + kindSel.innerHTML = '' + unioned([...(_knownKinds)], kindVal); + catSel.innerHTML = '' + unioned([...(_knownCategories)], catVal); + profSel.innerHTML = '' + 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(); } diff --git a/admin/frontend/index.html b/admin/frontend/index.html index ee92105..50e0b50 100644 --- a/admin/frontend/index.html +++ b/admin/frontend/index.html @@ -351,15 +351,7 @@ header.navbar{padding-top:2px!important;padding-bottom:2px!important} @@ -587,27 +579,45 @@ function renderBoard() { const q = _snap.quota || {}; const spend = q.spend || {}; const alarm = q.alarm || {}; - const vm = _snap.verdict_mix || {}; const pm = (_snap.per_model || []).filter(m => m.provider !== 'ollama-local'); const pinch = _snap.pinch || {}; const prof = _snap.top_proficiency || []; const accounts = (q.accounts || []).length; const hist = _homeHistory || {}; - const good = (vm.ok || 0) + (vm.succeeded || 0); - const bad = (vm.failed || 0) + (vm.malformed || 0); - const decisions = Object.values(vm).reduce((a, b) => a + b, 0); + // Decisions tile: real routed decisions plus client-reported outcomes. + // The `|| {...}` guard matters because a stale tab can still receive a + // snapshot payload that predates this field -- without it, a missing + // object throws and blanks the whole board. + const dout = _snap.decision_outcomes + || {decisions: 0, client_reports: 0, client_ok: 0, client_failed: 0}; const topModel = pm.slice().sort((a, b) => (b.calls || 0) - (a.calls || 0))[0]; const paceNote = alarm.kind === 'plan_pace' ? `${escapeHtml(alarm.headline || '')}` : 'no plan pressure'; + // Second qualifier line for the Decisions tile. Percent covered only when + // there are routed decisions to divide by; the failed count takes the warn + // colour above a 10% failure rate (same threshold as the Signal card). + let outcomeLine = 'no client reports in 7 days'; + if (dout.client_reports > 0) { + const share = dout.decisions > 0 + ? ` (${(dout.client_reports / dout.decisions * 100).toFixed(1)}%)` + : ''; + const failedFg = dout.client_failed / dout.client_reports > 0.10 + ? 'warn-fg' + : 'ok-fg'; + outcomeLine = `${num(dout.client_reports)} client reports${share}: ` + + `${num(dout.client_ok)} ok, ` + + `${num(dout.client_failed)} failed`; + } + el.innerHTML = [ tile('/admin/quota', 'Quota', usd(spend.total_usd), `spent this period
${paceNote}`, spark(hist.cost_per_bucket, '#fbbf24')), - tile('/admin/decisions', 'Decisions', num(decisions), - `verified in 7 days
${num(good)} ok, ${num(bad)} failed`, + tile('/admin/decisions', 'Decisions', num(dout.decisions), + `routed in 7 days
${outcomeLine}`, spark(hist.decisions_per_bucket, '#93c5fd')), tile('/admin/models', 'Models', num(pm.length), topModel @@ -1009,9 +1019,16 @@ function bindHomeViews() { * * DISMISS CANNOT MEAN DELETE. These are recomputed from live state on every * poll, so the only honest dismissal is "hide this until the condition - * changes". A dismissal is keyed on the warning's TEXT: when the underlying - * numbers move the text moves with them, the key stops matching, and the - * warning comes back on its own. Nothing has to expire it. + * changes". A dismissal is keyed on the warning's SEVERITY plus its text + * with the digits normalized away: the underlying numbers move on nearly + * every poll ("3 eligible" -> "50 eligible"), and a key that tracked the + * raw count would resurface the warning each time the number moved. The + * severity stays in the key so normalizing cannot fuse different warnings + * -- "0 eligible" is high-severity and must stay distinct from "3 eligible" + * (medium) even though both normalize to "N eligible". Store entries written + * by older implementations are raw warning text; loadDismissed migrates them + * through the current pipeline once and rewrites the store only when the + * migration actually changed something. * * Per browser, in localStorage, which is the right scope for "I have seen * this" -- it is a reading state, not a fact about the router. @@ -1020,16 +1037,22 @@ const DISMISS_KEY = '6krrt.dismissedWarnings'; function loadDismissed() { try { - const set = new Set(JSON.parse(localStorage.getItem(DISMISS_KEY) || '[]')); + const raw = JSON.parse(localStorage.getItem(DISMISS_KEY) || '[]'); // One-time carry-over from the previous, index-keyed implementation. // Changing the storage key without this would silently resurface every - // warning the operator had already dismissed. + // warning the operator had already dismissed. Unmappable carry-over + // entries never match a live key and are pruned on the next render. const old = localStorage.getItem('dismissedWarnings'); if (old) { - JSON.parse(old).forEach(w => set.add(w)); - localStorage.removeItem('dismissedWarnings'); - localStorage.setItem(DISMISS_KEY, JSON.stringify([...set])); + raw.push(...JSON.parse(old)); + localStorage.removeItem('dismissedWarnings'); // strip the legacy key } + const set = migrateDismissedKeys(raw); + // Persist only when migration changed the store: a clean read must not + // rewrite identical JSON on every load. The old-key check forces a save + // after the carry-over so the merged set survives this removal. + const same = set.size === raw.length && raw.every(k => set.has(k)); + if (!same || old) saveDismissed(set); return set; } catch (_) { return new Set(); } // private window, or corrupt value } @@ -1047,10 +1070,57 @@ function warningTarget(text) { return [null, null]; } +/* WARN_SEVERITY:BEGIN */ function warningSeverity(text) { if (/\d+(\.\d+)?x (sustainable |plan )?pace|0 eligible|cannot|refus/i.test(text)) return 'high'; return 'medium'; } +/* WARN_SEVERITY:END */ + +/* DISMISS_UNITS:BEGIN */ +/* Digit normalization is what makes a dismissal survive a count move: any + run of digits (integer or decimal) becomes N, so "3 eligible" and "50 + eligible" share one key while every other word still counts. */ +function normalizeDigits(text) { + return String(text).replace(/\d+(?:\.\d+)?/g, 'N'); +} + +/* The storage key for one warning: severity + digit-normalized, escaped + text. dismissKey output is what gets stored AND what gets compared, so + both sides of every comparison run through this one pipeline. */ +function dismissKey(text, severity) { + return `${severity}:${escapeHtml(normalizeDigits(text))}`; +} + +/* Store entries written by older implementations are raw warning text. + Map them through the current key pipeline. Entries already in the new + format (they open with a known severity prefix) pass through untouched, + so re-migrating an already-migrated store cannot mangle it. */ +function migrateDismissedKeys(rawKeys) { + return new Set((rawKeys || []).map(k => { + const t = String(k); + return /^(high|medium):/.test(t) ? t : dismissKey(t, warningSeverity(t)); + })); +} + +/* Anything dismissed whose key no longer appears among the live warnings + has been resolved; drop the key so the store cannot grow forever with + stale entries. Mutates ``dismissed`` in place; returns true if anything + was dropped so the caller knows to persist. */ +function pruneDismissed(warnings, dismissed) { + const live = new Set(warnings.map(w => dismissKey(w, warningSeverity(w)))); + let pruned = false; + for (const k of [...dismissed]) { + if (!live.has(k)) { dismissed.delete(k); pruned = true; } + } + return pruned; +} + +/* The open list: warnings whose key the operator has not dismissed. */ +function openWarnings(warnings, dismissed) { + return warnings.filter(w => !dismissed.has(dismissKey(w, warningSeverity(w)))); +} +/* DISMISS_UNITS:END */ let _allWarnings = []; @@ -1058,16 +1128,9 @@ function renderWarnings(warnings) { _allWarnings = warnings || []; const dismissed = loadDismissed(); - // Anything dismissed whose text no longer appears has been resolved; drop - // the key so the store cannot grow forever with stale entries. - const live = new Set(_allWarnings); - let pruned = false; - for (const k of [...dismissed]) { - if (!live.has(k)) { dismissed.delete(k); pruned = true; } - } - if (pruned) saveDismissed(dismissed); + if (pruneDismissed(_allWarnings, dismissed)) saveDismissed(dismissed); - const open = _allWarnings.filter(w => !dismissed.has(w)); + const open = openWarnings(_allWarnings, dismissed); const bell = document.getElementById('warnings-dropdown-wrap'); const count = document.getElementById('warn-count'); const menu = document.getElementById('warnings-menu'); @@ -1090,7 +1153,7 @@ function renderWarnings(warnings) {
${escapeHtml(w)} ${href ? `
open ${name} →
` : ''}
- + `; }).join('') : `
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); diff --git a/admin/frontend/models.html b/admin/frontend/models.html index 0838dc5..6100b86 100644 --- a/admin/frontend/models.html +++ b/admin/frontend/models.html @@ -192,15 +192,7 @@ header.navbar{padding-top:2px!important;padding-bottom:2px!important}
diff --git a/admin/frontend/navbar.js b/admin/frontend/navbar.js index 8b7c03a..e6e1a1c 100644 --- a/admin/frontend/navbar.js +++ b/admin/frontend/navbar.js @@ -8,6 +8,7 @@ * * Pages opt in with: * in the navbar right cluster + * where the shared NAV_LINKS render * * and call navbarSse(state) from their own EventSource handlers. */ @@ -17,6 +18,42 @@ var API = '/admin/api/'; + /* ── Nav links ─────────────────────────────────────────────────────────── + * The seven nav entries used to be hardcoded into every page's navbar and + * drifted -- the file this comment lives in exists because that strip + * already proved it does not survive duplication. Each page now ships an + * empty