Skip to content

feat(dash): group the 17 dashboard tabs into 5 question-based groups - #171

Merged
OsherElhadad merged 3 commits into
mainfrom
feat/nav-groups-0901
Sep 1, 2026
Merged

feat(dash): group the 17 dashboard tabs into 5 question-based groups#171
OsherElhadad merged 3 commits into
mainfrom
feat/nav-groups-0901

Conversation

@OsherElhadad

@OsherElhadad OsherElhadad commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Seventeen co-equal top-level tabs was a flat list pretending to be a hierarchy: every viewer scanned all seventeen labels to find one, and the manager got no signal about which three mattered. This is two levels and five groups, chunked by the question the viewer arrived with.

Group Views, in order
Overview (one view, no sub-nav)
Savings usage · campaigns · benchmarks
Behaviour components · tools (Inventory) · keepalive · kvcache
Traffic sessions · requests
Admin config · strategies · tenants · setup · settings · archive · feedback

Group order is the manager→operator gradient: a manager stops after Savings, an operator carries on, nobody is denied anything. Each group's first tab is the manager-legible one.

Accessibility — the headline

Measured over 34 tab×theme combinations per viewport, origin/main vs this branch, both sides fully populated:

1440 before → after 390 before → after
Tab stops 1334 → 792 1346 → 803
role="tabpanel" 0 → 17 0 → 17
Contrast failures 0/216/11204 → 0/221/11526 0/216/11168 → 0/222/11540
Missing focus rings 0 → 0 0 → 0
Nav elements <44×44 34 → 42 † 34 → 0
Body scrolls X 0 → 0 8 → 8, identical set
Chrome height (Admin) 3 rows → 2 228px → 217px

Seventeen role="tab" buttons were seventeen Tab stops, so reaching the filter bar by keyboard took seventeen presses. Now it is a roving tabindex per level with arrows/Home/End, and manual activation — automatic is the pattern's default, but every tab fires a data fetch and scrubbing across Admin's seven would issue seven queries nobody asked for.

Deliberate, not an oversight. The 44px floor is applied only below 900px, so the desktop topbar stays one 41px row on a pointer-fine surface. The two-level nav adds buttons at 1440, all 28px tall, which is why that one number rises.

Contrast denominators are stated because 0/0/0 is a scan that found nothing, not a pass.

And the Admin group's zeros are thinner than they look. All seven Admin tabs audited at 23-75 visible text nodes, five of them under 50 — tenants at 27, which is its empty state. @impl-tabs's cookie-carrying harness renders 27 rows there. So 0 contrast failures and 0 missing focus rings are true of every page this harness can reach, but the Admin group was measured near-empty on both sides of the comparison: it is unaudited, not clean. Supporting that reading: the first two genuine contrast failures ever found on this product (feedback, strategies, in #173) surfaced the moment the harness could carry a session. The nav numbers above — tab stops, tabpanels, touch targets — are structural and unaffected; the colour and focus zeros for Admin need a re-run behind auth.

The hash contract

Canonical form is #/<group>/<view>, collapsed to #/overview for the one group that is also a view.

The rule: the last path segment is the view; anything before it is a group hint, ignored whenever the view is known. That makes the one-level #usage this dashboard has always written and the new #/savings/usage the same link, and it is decidable without a lookahead because no view name contains a slash. #/admin/campaigns (stale group) resolves and rewrites to #/savings/campaigns.

Two of those one-level shapes are written by the serverdash/kvcache.go:510-511 builds #requests?req=<id> and #sessions?diff=<escaped id> for every KV-cache row, and dash/uikvcache_test.go forbids the UI from building them. So the one-segment branch is not a shim to tidy away later; it is half the traffic. No Go change was needed.

dash/navhash.test.mjs runs the real resolver out of app.js in a function scope over a ~12-line stub DOM — no second implementation, no proxy, no port, no HTTP, no 5s response cache, 0.2s. 10 tests: 17 bare views + canonical round-trip · all 14 filter dimensions individually and together · from/to relative and absolute, to omitted while now, junk→0 not NaN · range=<ms> over 6 durations including the documented 86400000now-1d · sort/dir written only on components · req|diff|acct exclusivity · the two server-authored shapes including sess%2Fwith%20spacesess/with space · #/group, #/group/typo, two-segment without a leading slash · 9 junk hashes → #/overview · nav/loader/section agreement. The tab table is parsed from index.html plus the three mountTab({...}) call sites, so a tab moved between groups moves the test with it.

go test ./dash/ -run NavHash runs it under node and skips loudly naming what went unverified when node is absent; TestTheNavHashContractIsPinned holds either way.

How much to trust the numbers above

I threw out a set of figures that favoured this branch. A re-run reported bodyScrollsX 8 → 5 and clipped 14 → 7. Both were fake:

kvcache|dark|390    2038 visible text nodes  ->    43
kvcache|light|390   2038                     ->    43
tools|dark|1440     1164                     ->    16
tools|light|1440    1164                     ->  1156   <- same build, populated

Same build, same viewport, different theme, opposite outcomes → a race, not a defect. shots.mjs has no per-tab ready selector: it waits for [data-view] (the nav, which exists immediately) at :227/:244 and then a fixed 1400 ms, and these are the two heaviest queries on a 16,444-request corpus. Those views "stopped overflowing" by being empty. The rows quoted above are off real content on both sides — verified by text-node count, since a skeleton reads as ~15 nodes and a rendered table as 1156+. perf.mjs is unaffected; it has a real ready selector per tab. The table above is from the run where kvcache is 2038→2030 and tools 1164→1156 in all four combos.

clipped is struck entirely. span.comp-name.trunc is the only element that ever appeared in it across five runs, and that truncation is deliberate and non-lossy (style.css:598-602 text-overflow: ellipsis, tools.js:957/:1069 carry title: t.name). The metric carries no signal on this corpus. Thanks to @impl-perf for catching it before anyone "fixed" working code.

The eight combos still scrolling at 390px are unchanged by this branch

Identical set before and after — keepalive, kvcache, strategies, tools, each in both themes. Not caused here and not fixed here: all four are addressed in #169 (perf/dash-load-0901, which reports keepalive +753px, strategies +183px, kvcache +16px → zero). tools also carries pre-existing mcp__* name truncation routed to impl-inventory. The 1440 → 0 result above is a clean one and should not be read as covering the mobile situation.

Also here

  • mountTab({ group, after, view, label }) replaces three copies of "reach into .tabs and insertBefore a named sibling" in tools.js, kvcache.js and campaigns.js. One place knows the nav's DOM shape. The tab button exists before the view body is built, so lazy-loading those bodies stays a change to the caller and not to the nav. The three are deliberately absent from GROUPS in app.js — hardcoding them would restore the coupling self-mounting exists to avoid.
  • Locked, not hidden. On a single-tenant local proxy the manager-gated tabs without data-local-ok render aria-disabled with one line under the tab row ("Strategies and Tenants need a manager sign-in.") instead of vanishing. Only nine of seventeen tabs were otherwise reachable there, so hiding the rest made the product look half its size and read as a broken build. Visible tabs go 9 → 12. data-account tabs stay silently hidden: a signed-out viewer has no use for a tab they cannot enable from here. A group with nothing reachable hides its group button.
  • One sticky box for the chrome. Three separately-stuck elements each needed the previous one's height as a magic top (.filters was top: 41px), which a second nav level breaks and nothing kept honest. .chrome sticks instead. That makes it a containing block for absolutely-positioned descendants, so: enumerated all five position: absolute rules, established only .filters details.more > .more-body is inside it, then measured the popover against its own <summary> on both builds — identical to the pixel at both widths. Below 620px style.css:371-373 makes it static (not a popover at all on a phone); at 1440 details.more { position: relative } stops the chain. There is no width at which .chrome is its containing block.
  • Six bare tables wrapped in the existing .tblwrap idiom: loadMachines, loadTokens, loadAudit, loadTenants, loadVariants, loadArchive. A table with no scroll container is only visibly broken once its content is wide enough, so "none overflows today" is a claim about the data, not the code — tools audited clean at 14,876 requests and failed at 16,444. The tabindex="0" is not decoration (axe scrollable-region-focusable).
  • Docs: a ## Navigation section in docs/dashboard.md with the group table, the hash rule and the rewrite cases; docs/dashboard.md:895 and docs/dashboard-kvcache-page.md:22 updated.

Known gap, on purpose

A static "every appendChild(tbl) is wrapped" check would fail on this branch today, which is why it is not here. Three of the nine sites — toggleBenchTasks (4028), loadKAArmed (8533), loadKABehaviour (8623) — sit inside functions impl-tabs is rewriting, so they go with that work rather than conflicting with it. The check becomes addable once those land.

Benchmarks moves under Savings — and can now say why it is empty

Benchmarks moves under Savings as evidence for the Overview claim, which makes it more prominent. It could not previously say why it was empty: dash/api.go's ?refresh=1 returned {ingested_runs, ingested_tasks} and the click handler discarded the body, so a wrong --dashboard-bench-dirs and a correct scan of an empty directory rendered identically. I was going to file that as a follow-up needing a new payload field.

No longer needed — @impl-tabs took both halves on fix/tab-verdicts-and-denominators-0901: ?refresh=1 now also returns dirs, and the handler distinguishes all three outcomes (scanned N dirs / no flag configured / scanned but no run directory found). The empty state's instruction, which named the non-existent --dash-bench-dirs, is fixed there too. So this PR does not promote a tab that cannot explain itself — but that fix lands in their PR, not this one, and both branches are independent. Nothing here depends on it.

Verification

CGO_ENABLED=1 go build ./...   BUILD ok
CGO_ENABLED=1 go vet ./...     VET ok
go test -count=1 ./dash/       ok  85.113s
go test -count=1 ./schema/ ./config/ ./apply/    ok (rebased base)
node --test dash/navhash.test.mjs               # tests 10  # pass 10  # fail 0

Live in Chromium, 29 hash cases resolving with exactly one visible view each, 17 tabpanels, 1+1 tab stops. Both proxies verified by pid+exe before measuring (/healthz returns 200 from a sibling's proxy if your own bind failed), and --dashboard-retention 0 --dashboard-max-bytes 0 on both with grep -c pruned = 0 — corpus 16,444 requests / 53,627 tool_declarations, unchanged end to end.

Tests updated, none deleted: dash/uikvcache_test.go asserted the literals 'data-view': 'kvcache' / id: 'view-kvcache', which now live in the mountTab call — rewritten to assert mountTab({, view: 'kvcache', group: 'behaviour' and that the file no longer contains $('.tabs')/insertBefore(tab, which is stronger than what it replaced. dash/api_test.go gained the four group testids and tab-note; all six original tab-* literals kept, and one tab per line preserved for the gating check at :371.

Module seams for the app.js split

app.js carries a comment naming them: shell+router (GROUPS through applyURL, exporting only go/mountTab/syncNav, depending only on loaders/DIMS/state/DOM helpers), overview+usage+components, sessions+requests+drawer, and admin.

Osher-Elhadad added 3 commits September 1, 2026 14:05
Seventeen co-equal top-level tabs was a flat list pretending to be a
hierarchy: every viewer scanned all seventeen labels to find one, and the
manager got no signal about which three mattered. The nav is now two levels
and five groups, chunked by the question the viewer arrived with — Overview,
Savings, Behaviour, Traffic, Admin — so a manager can stop after Savings and
an operator can carry on, on one nav rather than two products.

The URL carries both levels. The canonical hash is `#/<group>/<view>`, and the
rule is that the LAST path segment is the view: anything before it is a group
hint, ignored whenever the view is known. That makes the one-level `#usage`
this dashboard has always written and the new `#/savings/usage` the same link,
and it is decidable without a lookahead because no view name contains a slash.
Two of those one-level shapes are written by the server — dash/kvcache.go
builds `#requests?req=<id>` and `#sessions?diff=<escaped id>` for every row of
the KV-cache table, and the UI is forbidden from building them — so the
one-segment branch is not a shim to be tidied away later.

The whole contract is a table in dash/navhash.test.mjs, run against the real
resolver in app.js rather than against a Go re-implementation of it: seventeen
bare view names, all fourteen filter dimensions, from/to, sort/dir, the three
drawer keys, legacy `range=<ms>`, the two server-authored shapes, and every
rewrite case. `go test ./dash/ -run NavHash` runs it under node when node is
present and says loudly what went unverified when it is not;
TestTheNavHashContractIsPinned holds either way.

Three things fixed while rebuilding this markup:

- ONE TAB STOP PER LEVEL. Seventeen role=tab buttons were seventeen Tab stops,
  so reaching the filter bar by keyboard took seventeen presses. Now it is a
  roving tabindex with the arrows moving inside each tablist, and manual
  activation — automatic would fire seven queries scrubbing across Admin.
  Measured over 34 tab x theme combinations: 39 stops per page down to 23.
- REAL TABPANELS. There were none; all seventeen view sections now carry
  role=tabpanel and aria-labelledby, and each tab its aria-controls.
- 44px TOUCH TARGETS. Nav tabs were 28px tall at every width and nothing about
  them reflowed. Nav elements under 44x44 at 390px: 34 to 0.

Contrast failures and missing focus rings stay at 0 across all 68 audited
combinations, and no view gains horizontal body scroll.

The three self-mounting views now go through one mountTab({group, after, view,
label}) in app.js instead of each reaching into `.tabs` and insertBefore-ing a
named sibling. The tab exists before the view body is built, so lazy-loading
those bodies later is a change to the caller and not to the nav.

On a single-tenant local proxy the manager-gated tabs without data-local-ok are
now disabled with a one-line reason rather than hidden: only nine of the
seventeen tabs are otherwise reachable there, so hiding the rest made the
product look half its size and read as a broken build. data-account tabs stay
silently hidden — a signed-out viewer has no use for a tab they cannot enable
from here.

The chrome is one sticky box now. It was three separately-stuck elements each
needing the previous one's height as a magic `top` offset (.filters was
`top: 41px`), which a second nav level breaks and which nothing kept honest.

Signed-off-by: Osher-Elhadad <Osher.Elhadad@ibm.com>
Five groups wrapping to two 44px rows plus Admin's seven tabs on another two
came to 176px of nav, which is more vertical chrome on a phone than the
seventeen 28px tabs it replaced. Level 1 already carried `overflow-x: auto`
below 900px and `flex-wrap: wrap` was quietly defeating it, so it is one
scrolling row now: the group names are a fixed set of five a reader learns,
which is what makes a scroller acceptable there and not on the second level,
where a hidden tab is a tab nobody knows exists.

Total chrome on the worst group (Admin) at 390px: 228px before, 217px after.
syncNav scrolls the open group into view, since a selected tab half off the
edge is the failure mode a scroller introduces.

Re-audited at 390px across all 17 tabs x both themes: 0 contrast failures,
0 missing focus rings (a scrolling flex row can clip a box-shadow ring, and
does not here), 0 nav elements under 44x44, and the four views that scrolled
horizontally before still are exactly those four.

Signed-off-by: Osher-Elhadad <Osher.Elhadad@ibm.com>
Six appendChild(tbl) sites had no scroll wrapper: loadMachines, loadTokens,
loadAudit, loadTenants, loadVariants and loadArchive. None overflows any tab
on today's corpus, so this is a latent-defect list rather than a bug list —
overflow here depends on the data, and one long MCP tool name or one wide
model id is the difference. The BRIEF's hard constraint is that the page body
must never scroll sideways.

.tblwrap and its tabindex="0" are the existing idiom (style.css:561, eight
call sites already); the tabindex is not decoration — a scroll region a
keyboard user cannot focus is axe scrollable-region-focusable, as app.js:3178
says at the one site that explains it.

Three of the nine sites impl-perf enumerated are deliberately not here:
toggleBenchTasks (4028), loadKAArmed (8533) and loadKABehaviour (8623) sit
inside functions impl-tabs is rewriting, so they go with that work rather than
conflicting with it. A static check that every appendChild(tbl) is wrapped is
worth adding once all of them have landed; it would fail on this branch today.

Signed-off-by: Osher-Elhadad <Osher.Elhadad@ibm.com>
@OsherElhadad
OsherElhadad merged commit 5084c85 into main Sep 1, 2026
6 checks passed
@github-project-automation github-project-automation Bot moved this from New/ToDo to Done in Rossoctl Issue Prioritization Sep 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants