# 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` ``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.