Files
6krrt/plans/cockpit-quick-wins.md
adlee-was-taken 69c969d104 plans: declare a valid Status on the six plans this branch adds
tests/test_plans_declare_status.py requires 'Status: <done|planned|in
progress|parked|reference> -- <reason>' in the first 8 lines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N9biTbFC63yDfYfUsZmhgd
2026-09-26 21:46:15 -04:00

11 KiB

Cockpit quick wins: six small admin-portal fixes

Status: done -- shipped in PR #101 Date: 2026-09-23 Source: plans/cockpit-brainstorm.md section 5 (items R2, R5, E3, C1, G1, G3). Each item below was observed on the live portal or checked read-only against the live router.db on 2026-09-23.

Ground rules (all items)

  • Worktree, not the main checkout. Create a worktree from main HEAD (c87e675 or later) on branch feat/cockpit-quick-wins. The main checkout at /home/alee/Sources/6krrt has UNCOMMITTED edits from other work, including admin/frontend/controls.html (a confidence_threshold -> confidence_min rename around lines 1026 and 1150) and tests/test_admin_frontend.py. Do not touch, stash, or commit them. C1 edits a different region of controls.html; keep it that way so the later merge is clean.
  • Commit by explicit path. Never git add -A or git add ..
  • Never touch port 8080 (production). For visual checks, run a throwaway instance on 8081 from the worktree, with a temp copy of the DB, never the live router.db for writes.
  • Never edit config/config.yaml or config/config.local.yaml.
  • ASCII only in new code, comments, and UI strings. No middle-dot separators (U+00B7); relate facts with layout, or with : or a rephrase.
  • No paragraphs of prose in the UI. One line per hint at most.
  • One commit per item, in the order listed. pytest (offline) and ruff check stay green after each commit.
  • For every UI change, take a screenshot on 8081 and check the changed area zoomed in, not just the item's checklist.

1. R5: Classifier card shows a wrong value on first paint

Problem. admin/frontend/controls.html:264-268: the #classifier-mode-select <select> renders with local_llm selected by default (first <option>). Until loadClassifierConfig (sets .value at ~line 1116) returns, the card claims local_llm when the live mode is local_encoder.

Fix. Render the select disabled, with a leading <option value="" selected>loading...</option>, and the Save button disabled. On load: remove the placeholder option, set the value, enable both. On fetch failure: keep them disabled and show an inline one-line error.

Test. In tests/test_admin_frontend.py: assert that the static HTML of the select has a selected placeholder and the disabled attribute, and that no real mode option is selected in the static markup.

Problem. Every page (index.html, models.html, profiles.html, proficiency.html, decisions.html, quota.html, controls.html, providers.html) hardcodes the same <ul> of seven nav-item links, and sets active by hand. navbar.js (the header comment) already records that adding Quota was an eight-file edit.

Fix. Define the link list once in navbar.js ([{href, label}], same order as today: Models, Profiles, Proficiency, Decisions, Quota, Controls, Providers). Render it into a placeholder (e.g. <ul class="navbar-nav" id="nav-links"></ul>) on each page. Mark active by matching location.pathname, with /admin and /admin/ mapping to Home (no link active). Remove the hardcoded <li>s from all eight pages.

Keep the rendered markup identical: same classes (nav-item, nav-link, active), same hrefs, same order. Pages must still render their nav when navbar.js is loaded, and it is already loaded on every page.

Test. One test that parses all eight HTML files and asserts none contains a hardcoded class="nav-link" href="/admin/... list. One test asserting that the navbar.js link list has exactly the seven hrefs above.

3. E3: Decisions filters are not in the URL

Problem. admin/frontend/decisions.html never reads or writes URLSearchParams. No other page can link to "decisions for this model" or "this session", and a filtered view cannot be shared or reloaded.

Fix.

  • On load, after the filter <select>s are populated (~lines 405-420), read kind, category, profile, tier, q (search) and size from location.search and apply them. If a value is not among a select's options, add it as an option rather than dropping it (a link may name a category that has no rows in the loaded window).
  • On any filter change (resetPageAndRender, the Clear button, page size), write the current filters with history.replaceState. Omit empty values. Don't push history entries.
  • q also accepts a model id or a session id, since search already matches both (search at ~line 433).

Test. A static test that decisions.html reads URLSearchParams and calls history.replaceState. On 8081, open /admin/decisions?category=diff_checking&q=deepseek, confirm the filters and the rows match, and screenshot it.

4. R2: The Decisions tile counts verifications and mixes diagnostics with ground truth

Problem. admin/frontend/index.html:597-610: the Home "Decisions" tile sums _snap.verdict_mix, which is metrics.verdict_mix() and counts verifications rows by verdict. Live DB, last 7 days:

verdict kind n
unverifiable structural 2,660
succeeded client_outcome 96
failed client_outcome 82
ok local_llm 29
ok structural 7
malformed local_llm / structural 4 / 7
truncated structural 2

The tile shows "2,887 verified in 7 days: 132 ok, 93 failed". Actual route decisions in 7 days: 2,678. "ok" adds client succeeded to structural and local_llm ok. "failed" adds client failed to malformed. Per CLAUDE.md, structural and local_llm verdicts are diagnostics only; /outcome (kind = 'client_outcome') is the only ground truth.

Fix.

  • Backend: in src/metrics.py, add decision_outcome_summary(conn, since_days=7) returning {decisions, client_reports, client_ok, client_failed}: decisions = route_decisions rows with observed_at inside the window; client_* = verifications rows with kind = 'client_outcome' inside the window (succeeded = ok, failed = failed). Include it in the admin snapshot (src/admin.py near line 2131) as decision_outcomes. Leave verdict_mix unchanged; the TUI uses it.
  • Frontend: the tile headline becomes decisions. Qualifier lines:
    • routed in 7 days
    • 178 client reports (6.6%): 96 ok, 82 failed (coverage = client_reports / decisions; the failed count uses the warn colour when the fail rate among reports is above 10%, matching the existing Signal quality card threshold).
    • With zero reports: no client reports in 7 days.
  • Drop the word "verified".
  • Use metrics.py's existing window idiom: julianday(observed_at) > julianday('now', '-' || ? || ' days').

Test. tests/: a metrics unit test with a seeded in-memory DB that includes structural, local_llm and client_outcome rows, asserting only client_outcome rows are counted and that decisions comes from route_decisions. A frontend static test that the tile no longer reads verdict_mix.

5. G3: Warning dismissals don't stick on rate warnings

Problem. admin/frontend/index.html:1010-1040: a dismissal is keyed on the warning's exact text. That's deliberate ("when the underlying numbers move the text moves with them... the warning comes back"). But warnings that embed a live rate or count ("3.2x pace", "n=4") change text on nearly every poll, so a dismissal never holds.

Fix (interim, until structured warnings exist). Key each dismissal on digit-normalized text + severity: replace every run of digits (and decimal points inside a number) with #, then append | + warningSeverity(text). The warning comes back when its wording or its severity changes, not when a digit moves. This is the same normalization rejection_warnings already uses for grouping (CLAUDE.md, the reactive detector section).

  • Migrate the existing stored keys on load: normalize each and dedupe, same pattern as the existing one-time carry-over from dismissedWarnings.
  • Update the block comment to state the new rule and why it changed.

Test. A static or JS-extracted unit test: two texts differing only in digits produce the same key; the same text at different severity produces a different key.

6. C1: One knob table instead of two columns

Problem. admin/frontend/controls.html: "Runtime Knobs" (#runtime-list, renderRuntime ~line 687) and "Persisted Config" (#config-list, renderConfig ~line 821) list largely the same knobs in two side-by-side cards, in different orders, and persisted labels truncate (objective.incumbent_cache_pri...). Checking whether a runtime value has drifted from its persisted value means matching rows by eye.

Fix.

  • One card, Knobs, full width, as a table: knob | live | persisted | layer | with the columns meaning:
    • knob: the dotted config key, never truncated (wrap if needed).
    • live: the runtime control, if the knob has one, else empty.
    • persisted: the persisted-config control, if the key is in the persisted allowlist, else empty.
    • layer: the existing base / overlay badge.
    • A drift badge in the row when live differs from persisted ("reverts on restart").
  • Pair rows through the runtime-to-config mapping that already exists in src/admin.py (_BOOL_KNOBS at ~line 348, plus the non-bool runtime knob tables beside it). Expose that mapping to the frontend: add the dotted config key to each item in the GET /admin/api/runtime response, rather than duplicating the table in JS.
  • Keep the existing save semantics exactly: runtime controls POST to api/runtime/{knob} immediately, as today; persisted controls stay dirty until the Save button, as today. Keep the existing hint badges (for example "enables the reuse window").
  • Keep the help lines ("Live on the next request...", "Applied on restart...") as one line each, placed as column-header tooltips or a single line under the table.
  • Don't touch the classifier-card region (lines ~1000-1160); another branch edits it.

Test. test_admin_knob_coverage.py must still pass unchanged. Add a test that every runtime knob in the API response carries a config key, and that the key resolves to a real RouterConfig path. Visual check on 8081: set one runtime knob so it differs from persisted (on the 8081 instance only), then confirm the drift badge shows. Screenshot the whole table and a zoomed crop of one drifted row.


Done means

  • Six commits on feat/cockpit-quick-wins, one per item, in the order above.
  • pytest and ruff check green on the worktree.
  • Screenshots from 8081 for items 1, 3, 4 and 6.
  • The 8081 instance stopped, by the PID you started.
  • A short report listing each commit, the tests added, and anything deferred, with the reason.