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

218 lines
11 KiB
Markdown

# 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.
## 2. G1: Nav links hardcoded in eight files
**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.