Skip to content

feat(webui): make Activity read as a status page - #1929

Merged
inureyes merged 12 commits into
mainfrom
feature/issue-1916-activity-status-page
Sep 25, 2026
Merged

inureyes merged 12 commits into
mainfrom
feature/issue-1916-activity-status-page

Conversation

@inureyes

@inureyes inureyes commented Sep 19, 2026 •

Copy link
Copy Markdown
Member

Part of #1910. Closes #1916.

Activity was prose and repeated "N/A" lines, and idle slots claimed an unknown context denominator: the occupancy bar was gated on prompt_tokens, which the server leaves null for a slot without a task. The page is now a status page on the shared components, with every visible string from the catalog in both locales.

What changed

  • Header: the model picker (ready models first, verbatim display_name, still the only combobox on the route) sits beside Refresh and Export in the PageHeader actions; the last snapshot time joins the description. The history toggle and its lazy history-*.js chunk are gone.
  • Tiles: always four StatCards: Active requests, Total completed requests, Total completion tokens, Queued requests. A null or missing value reads "unknown", never 0. A measured tile's hint is "Observed at {time}". A null tile gets a localized reason hint mapped from the server's closed reason set. Before the first sample, four decorative skeleton tiles sit under one localized status.
  • Unavailable count: a Badge inside the disclosure summary replaces the sentence; clicking it opens the disclosure. The content (memory and timing notes, every measurement with its localized scope and its raw server reason, the history list) is built only while the disclosure is open. An unrecognized scope falls back to the "Unknown" label.
  • Slots: a DataTable in a named, focusable scroll region (more than eight rows scroll inside it). The table uses a fixed layout with percentage column shares and a container query for narrow columns (smaller cell padding, compact badge), so it fits a 390 px column in any font; headers are Slot, State, Context, Decoded, Cached. Occupancy rules follow the issue in order, so an idle slot with a known context reads 0 / 40,960 tokens, and "unknown" appears only for a null or zero denominator or a processing slot without a count. Slot reasons are catalog labels; an unrecognized one keeps its raw text behind a disclosure.
  • Operations: one line per row: catalog kind and state, target name ("Unknown model" when the catalog lacks it), relative age from the render's now, download progress, and cancel. A failed operation shows a localized reason for its error code (13 known codes plus a generic fallback); the raw code, the operation id, the model id and the absolute time appear only inside Operation details. error.message is never shown.
  • Sparkline: one <path> of zero-length round-capped segments in the Active requests tile, shown once two samples with a value exist. Its right edge is the latest runtime sample, so a trailing run of null samples (for example after an unload) stays blank instead of sliding old dots to "now".
  • Strings and formatting: metricValue(metric, locale), relativeTime(iso, now, locale) and the slot counts use cached Intl formatters and catalog unit templates (singular and plural in English). reasons.test.ts reads src/server/webui/runtime.rs, so server wording drift fails a test instead of silently falling back. Unused keys are deleted from strings.ts and strings.json.
  • Adapter and docs: the StatCard adapter forwards loading (with the package's English "Loading" skeleton statuses made decorative) and sparkline. docs/webui/activity.md, ux-contract.md and ui-common.md describe the idle 0 / D exception, "unknown" instead of N/A, the sparkline and the error-reason mapping.

Deviations from the issue body

  • Tile hints are localized ("Observed at {time}" or a mapped reason) instead of the raw metric.reason, and slot reasons, scopes and error codes are mapped to catalog labels. The epic contract says raw reason strings and enum names are not user copy; the raw text stays available inside the details disclosures.
  • A failed operation's banner shows a localized reason instead of the bare error.code, for the same contract reason; the code itself moved into Operation details.
  • Existing metric label keys are kept ("Total completed requests"); the order follows the issue.
  • SmoothHeight keeps its in-flight semantics (pinned by ui-common.md and the components spec) rather than "active when the list length changes".
  • The retention tooltip sits on a help IconButton beside the count, because the count is the live region and is not focusable.
  • Numbers use locale grouping, so idle rows read 0 / 40,960 tokens (Korean 0 / 40,960 토큰).

Evidence

Test first, on 835a552: pnpm exec vitest run src/features/activity/activity.test.tsx -t "idle slot" failed with AssertionError: idle rows reading "0 / 40,960 tokens": expected +0 to be 4. It passes after the change. The review fixes were also test first: the sparkline-anchor test failed before its fix ("expected 117 to be less than 102"), the error-code tests (en, ko, unknown code) failed before theirs, and the zero-denominator and unknown-scope tests were checked by reverting the guard and watching them fail.

document.documentElement.scrollHeight at 1440x900 on the mock (four idle slots, one operation, loaded-model chip): 1,548 before, 900 after. 900 is the viewport; the page content ends at 789 px. The issue's real-binary baseline was 1,584.

Per-poll work

The page re-renders on every 2 s runtime poll. No polling, timers, intervals, fetches or document listeners were added, and webui/scripts/activity-performance.mjs is unchanged (its selectors still match: one combobox, options named by display_name).

  • Before: 4 tiles, one <section> per slot, 19 StatCards inside the closed measurements disclosure, the operations list with a catalog.find per row.
  • After: 4 tiles with a one-path sparkline, the slot table rows, nothing inside closed disclosures, the operations list with a memoized name index. Model options, the name index and history points are memoized on the catalog and history identities, so a runtime tick does not re-sort the catalog.

Chromium CDP per runtime update on the mock, three runs each:

Activity elements Mutation records Script Style Layout
Before 289 19 3.2 to 3.45 ms 0.48 to 0.59 ms 0.07 to 0.08 ms
After 160 23 2.66 to 2.92 ms 0.59 to 0.96 ms 0.24 to 0.28 ms

Total main-thread time per poll is about the same (3.8 to 4.1 ms before, 3.5 to 4.2 ms after); the extra ~0.2 ms layout comes from the tile observation times and the sparkline changing each poll. The GB10 WebUI installed artifact job starts the server without --slots, so it never renders the slot table. At a synthetic worst case (256 slots, 200 operations) the slot table costs about 0.7 ms more layout per update than the old sections; that path is not exercised in CI.

Gates

All pass on the final head: pnpm --dir webui run typecheck, lint, unit (35 node:test, 567 Vitest), check:theme-selectors, python3 scripts/webui/build_bundle.py and --verify, scripts/ci/check_webui_contract.py, scripts/ci/check_binary_assets.py, scripts/ci/check_cross_repo_refs.py. Playwright Chromium, one spec per run: activity 8, ui-common-components 14, shell 14, browser 29, theme 23, ui-common-shell 12, models 8, settings 3, chat 2, performance 4. csp.spec.ts needs the real server and was not run. The first CI runs failed the 390 px slot-table overflow assertion on Linux (wider fallback font, 31 px over); the fixed layout above was verified with Verdana forced through CSSOM (0 overflow, 57 px slack in English) and the CI WebUI bundle job now passes.

After merging main (7cc49fc, #1928) the branch still passes typecheck, lint, check:theme-selectors, unit (35 node:test, 608 Vitest), build_bundle.py --verify, the contract and binary-asset checks, and Playwright activity 8, shell 14, ui-common-components 14, settings 5.

Real-binary acceptance

Run against mlxcel-server built from ca1c4df (--profile test-fast --features metal,accelerate, embedded bundle) with --settings --props --metrics --slots, qwen3-0.6b-4bit loaded and counters starting at zero, driven by headless Chromium through the bundled WebUI. The merge of main touches no Activity source.

  • Counters after one streaming chat: pass. Before the chat all four tiles read 0. One chat sent from the WebUI Chat page (max_tokens 32, streamed) left the tiles at Total completed requests "1 request" and Total completion tokens "5 tokens"; /ui-api/v1/runtime agrees (1 and 5).
  • Slot occupancy: pass. Before the chat all four idle rows read 0 / 40,960 tokens with empty decoded and cached cells. After it, slot 0 reads 24 / 40,960 tokens with 5 decoded and 0 cached, and slots 1 to 3 read 0 / 40,960 tokens. No "N/A" and no "unknown" occupancy anywhere; effective parallelism and shared pool context read "unknown".
  • Disclosures and identifiers: pass. The "12 unavailable" badge (14 before the first decode sample) sits inside the "All measurements and sources" summary; clicking it opens the disclosure with 19 measurements, their raw server reasons and the history list. The model_load operation row reads "Model load, qwen3-0.6b-4bit, Succeeded, 3 minutes ago"; no op_ or mdl_ id is in the page text until Operation details is opened, which then shows both.
  • Korean: pass. The only Latin token in the rendered Activity text is the model name qwen3-0.6b-4bit; tiles read 1건 and 5 토큰, idle rows 0 / 40,960 토큰, the operation row "모델 로드, 완료, 3분 전". Raw English reasons appear only inside the open disclosure.
  • Layout: pass. At 1440x900 scrollHeight equals the 900 px viewport in English and Korean (the issue's real-binary before number was 1,584). At 390x844 there is no horizontal page scroll and the slot table does not scroll sideways, in both locales. The loaded model is the first option in the picker.

The GB10 Activity performance gate (WebUI installed artifact) ran on this PR for the first time at a0cccb35. The first run reported investigate with median decode degradation 2.21% (one visible, baseline CV 2.20%, paired range -5.5% to +5.8%) and 2.50% (two visible, CV 2.37%, range -4.3% to +4.8%). Both ranges cross zero and the medians sit at about 1x the baseline CV, the #1949 noise signature. Re-run of the same commit on a quiet host (no other jobs, load average about 1.4) passed: 0.45% (CV 0.86%) and -2.72% (CV 4.49%), both within-target.

Hardening

  • relativeTime never reads a past event as future. updated_at is server time and the render's now is client time, so a browser clock behind the server showed recent operations as "in 3 seconds". Each step now truncates instead of rounding, so 23.6 hours reads "23 hours ago" rather than "yesterday".
  • A measured tile without an observation time says "No observation time" instead of "Observed at No observation time".
  • A primary metric absent from the snapshot says the server did not report it (the existing activity.reason.counter_unavailable string) instead of pointing at the measurements disclosure, which does not list it.
  • Each fix has a test that fails with the fix reverted. After merging main (6900e3c4, feat(webui): redesign the model library as a dense list with row actions #1930), the only source conflict was docs/webui/ui-common.md (main's DataTable and Badge rows, with this PR's Activity sentences kept); the bundle was regenerated. Typecheck, lint, unit (35 node + 696 Vitest), theme selectors, build_bundle.py --verify, contract and binary-asset checks, and Playwright activity/ui-common-components/models/shell/chat/settings (60) pass.

Left as LOW: the slot scroll region stays a tab stop without overflow (kept for axe at any width); Operations re-sorts per render and design-system/format.ts builds an Intl.NumberFormat per call (both predate this PR); the download repo_id stays visible by design.

Activity was prose and repeated "N/A" lines, 1,548 px tall on the 1440x900 mock, and idle slots claimed an unknown denominator because the bar was gated on prompt_tokens, which the server leaves null for a slot without a task.

- Header: the model picker (ready models first, verbatim display names) sits beside Refresh and Export, and the last snapshot time moves into the description. The page-wide history toggle and its lazy chunk are gone.
- Tiles: always four StatCards (active, completed, completion tokens, queued). Null reads "unknown" with a localized reason hint; a measured tile's hint is its observation time. Loading tiles show decorative skeletons under one localized status until the first sample.
- Slots: a DataTable in a named, focusable scroll region. An idle slot with a known context reads "0 / D tokens"; unknown appears only without a denominator or for a processing slot without a count.
- Disclosure: an unavailable-count badge in the summary replaces the old sentence, and its content (every measurement, raw reason, history list) is built only while open.
- Operations: one line per row with catalog kind and state labels, relative age, and identifiers only inside Operation details.
- Copy: units, scopes and the known server reasons come from the catalog; a vitest reads src/server/webui/runtime.rs so server wording drift fails a test.

The StatCard adapter now forwards loading and sparkline. Typecheck, lint, unit and the Chromium specs pass; the page now ends at 789 px.

Refs #1916
Output of python3 scripts/webui/build_bundle.py for the previous commit; --verify reproduces the same digest. The history chunk is gone because Activity no longer lazy-loads it.

Refs #1916
@inureyes inureyes added status:review Under review type:enhancement New features, capabilities, or significant additions priority:medium Medium priority area:architecture Architecture and code structure changes labels Sep 19, 2026
The active-request sparkline anchored its five-minute window to the last sample that had a measurement. activePoints drops samples whose active_requests is null, so when the newest runtime samples carry no measurement (for example after the model is unloaded and the server reports "model provider is not loaded"), the older dots slid to the right edge and read as current. The chart before this redesign anchored to the latest runtime sample, valid or not.

Sparkline now takes an explicit end, the receive time of the latest runtime sample, which ActivityPage already computes as latestRuntimeAt and passes through RuntimeView as historyEnd. The sparkline renders only when there are two points and that end is known.

A new runtime.test.tsx case builds 150 samples 2 s apart whose last 30 are missing. Before the fix the last dot sat at x=117 of a 120-wide plot ("expected 117 to be less than 102"); after it the dot sits near x=94 and the case passes. typecheck, lint, check:theme-selectors and the unit suite (563 tests in 56 files) pass.

Refs #1916
Rebuild src/webui/assets with scripts/webui/build_bundle.py so the embedded WebUI carries the Activity sparkline fix that anchors the chart to the latest runtime sample; the index and code-highlight chunks change hash and the manifest and index.html follow them.
A failed operation's banner printed the raw ErrorCode enum word (for example stale_revision) as page copy in both locales. The epic #1910 contract keeps enum names and raw identifiers out of user copy except inside an explicit details disclosure, and this was the last enum still on screen.

The banner now reads a localized reason per ErrorCode through an exhaustive Record<ErrorCode, StringKey>, falls back to a generic reason for a code newer than the build, and keeps the no-code message for a null error. The raw code moves into the row's Operation details disclosure. The server's error message is still never rendered. New activity.error.* and activity.details.error_code strings are mirrored in the strings fixture, and docs/webui/activity.md states the rule.

Before the fix, the rewritten Activity test failed in both locales because the collapsed text ended in "stale_revision" instead of the localized reason; after it, all 15 Activity tests pass. typecheck, lint, unit (565 tests) and check:theme-selectors pass.

Refs #1916
Rebuild src/webui/assets with scripts/webui/build_bundle.py so the embedded WebUI carries the localized failed-operation error reasons on Activity; the entry and code-highlight chunks were renamed by their new content hashes and the manifest and index.html follow them.
@inureyes

Copy link
Copy Markdown
Member Author

Implementation Review Summary

Intent

Recompose Activity into a dense status page on the shared components, read idle slots as 0 / D tokens, and keep identifiers, enum words and raw server English out of visible copy.

Findings Addressed

  • The sparkline anchored its right edge to the last sample that had a value, so a trailing run of null samples (for example after an unload) slid old dots to the edge where they read as current. It now anchors to the latest runtime sample; a new test fails at x=117 of 120 without the fix. (HIGH, 98c36a8)
  • A failed operation's banner printed the raw ErrorCode word (stale_revision, conflict) in both locales. It now shows a localized reason per code (exhaustive Record<ErrorCode, StringKey>, generic fallback for an unknown code); the raw code moved into Operation details and error.message is still never shown. This departs from the brief's "error.code only" in favour of the epic contract. (HIGH, 4040123)

Remaining Items

  • A measured tile whose measured_at is null would read "Observed at No observation time"; the server always sets it for measured values. (LOW)
  • relativeTime compares server updated_at with the client clock, so clock skew can read "in 3 seconds". (LOW)
  • The slot scroll region is a tab stop even when nothing overflows. (LOW)

Verification

  • All stated requirements implemented (real-binary acceptance still pending with the orchestrator)
  • No placeholder/mock code remaining
  • Integrated into project code flow (index.tsx)
  • Project conventions followed
  • Existing modules reused where applicable
  • No unintended structural changes
  • Tests pass: typecheck, lint, unit (565 Vitest, 35 node:test), theme selectors, bundle --verify, contract, binary assets; Playwright Chromium activity 8, ui-common-components 14, shell 14, browser 29, theme 23, ui-common-shell 12, models 8, settings 3, chat 2, performance 4. scrollHeight at 1440x900 on the mock stays 900 (content ends at 789).

@inureyes

Copy link
Copy Markdown
Member Author

Security and performance review (b2a474d)

No CRITICAL or HIGH findings, so no fix commits.

Security

  • Server strings (reasons, repo ids, operation ids, error codes) render as React text only. No dangerouslySetInnerHTML or innerHTML, t() interpolates in one pass, and the sparkline d is built from numbers.
  • CSP: no inline style or <style>. The lazy history chunk is gone.
  • error.message is never rendered. Operation and model ids appear only inside Operation details, and closed disclosures render nothing.
  • diagnostics() is unchanged and its allowlist test passes.

Performance

No timers, intervals, fetches or document listeners were added. Catalog options, the name index and history points are memoized, the slot columns are memoized, and the Intl formatters are cached. Temporary CDP probe on the mock, per runtime update, baseline 835a552 vs this branch:

Case Script ms Layout ms Elements
4 slots, 1 op 3.61 / 2.98 0.33 / 0.39 293 / 160
256 slots, 200 ops, 200 catalog 15.31 / 15.09 0.47 / 1.17 4243 / 5635

The CI Activity performance harness starts the server without --slots, so it never renders the table.

Remaining (LOW, not fixed)

  • With 256 slots, idle rows now carry a StatusBadge and a ProgressBar, which adds about 0.7 ms of layout per update (slots.tsx:24).
  • Operations re-sorts and recounts on every render (operations.tsx:75), and formatBytes builds a new Intl.NumberFormat on every call (design-system/format.ts:17). Both predate this PR.
  • Two server strings are visible by design: the download repo_id (operations.tsx:67) and an unrecognized unit (format.ts:47). The export excludes both.
  • SCOPE_KEYS[metric.scope] has no fallback (runtime.tsx:53). Schema validation covers it today.

Gates

typecheck, lint, unit (565 Vitest, 35 node:test), check:theme-selectors, build_bundle.py --verify and Playwright activity.spec.ts (8/8, port 4916) all pass.

The measurement disclosure indexed SCOPE_KEYS[metric.scope] directly, so a scope value outside the current closed union (schema validation keeps it closed today, but a future server scope would arrive before the schema and this code catch up) rendered the literal word "undefined" instead of localized copy. Add a scopeKey() helper with a runtime fallback to activity.scope.unknown and cover it with a unit test that casts scope to an unrecognized value. Also add the two occupancy-rule and context-rule test cases the review found untested: a zero (not just null) request_context_tokens now has a dedicated regression test, verified to fail without the `<= 0` guard in slots.tsx.

Validation:
- pnpm --dir webui run typecheck, lint, unit (567 Vitest, 35 node:test), check:theme-selectors: all pass.
- python3 scripts/webui/build_bundle.py && --verify: pass, bundle regenerated for the runtime.tsx change.
- scripts/ci/check_webui_contract.py and check_binary_assets.py: pass.
- Playwright chromium on port 4916: tests/activity.spec.ts (8/8), tests/ui-common-components.spec.ts (14/14).

Refs #1916
@inureyes inureyes added status:done Completed and removed status:review Under review labels Sep 19, 2026
CI's Linux runner failed the 390 px keyboard and layout spec: the slot table scrolled sideways by 31 px because the auto table layout sized columns from their widest words, and the Linux fallback sans-serif is wider than the macOS system face. On macOS the table had 3 px to spare.

The slot table now uses a fixed layout at 100 percent width with per-column shares, so its width never follows the text. A container query below 48rem gives the state badge more room, tightens the inline cell padding, uses a smaller head font and a compact badge. Header copy is shorter (Context, Decoded, Cached; Korean in step, fixture in sync). The CSS is scoped to the Activity slot table.

Measured at 390 px with four idle slots, forcing Verdana through CSSOM for the wide case: overflow is 0 in en and ko with both fonts, and the unbroken min-content table needs 275 px (en default), 301 px (en Verdana) and about 176 px (ko) of the 358 px column. activity, shell and ui-common-components specs, typecheck, lint, unit and the theme gate pass.

Refs #1916
Output of python3 scripts/webui/build_bundle.py for the slot table fix; --verify reproduces digest 0507ec7a.

Refs #1916
…tivity-status-page

# Conflicts:
#	docs/webui/ui-common.md
#	src/webui/assets/assets/code-highlight-CVUuwqcC.js
#	src/webui/assets/assets/code-highlight-Dk2RxJiZ.js
#	src/webui/assets/assets/code-highlight-RVRncNAw.js
#	src/webui/assets/assets/history-yShpES-e.js
#	src/webui/assets/assets/index-SCLyOIB0.js
#	src/webui/assets/assets/index-uBcwUASe.css
#	src/webui/assets/index.html
#	src/webui/assets/mlxcel-webui-manifest.json
- relativeTime clamps to the past: updated_at is server time and the render's now is client time, so a browser clock behind the server read recent operations as "in 3 seconds". Each step truncates instead of rounding, so 23.6 hours reads "23 hours ago" rather than "yesterday".
- A measured tile without an observation time says so instead of "Observed at No observation time".
- A primary metric absent from the snapshot says the server did not report it, instead of pointing at the measurements disclosure, which does not list it.

Refs #1916
Refs #1929
…tivity-status-page

# Conflicts:
#	docs/webui/ui-common.md
#	src/webui/assets/assets/code-highlight-HW2W_ZnA.js
#	src/webui/assets/assets/code-highlight-NhHf6Eg4.js
#	src/webui/assets/assets/code-highlight-RVRncNAw.js
#	src/webui/assets/assets/history-ClynwMho.js
#	src/webui/assets/assets/index-DnT-nVPF.css
#	src/webui/assets/assets/index-Wq1p5Z_k.css
#	src/webui/assets/assets/index-XNR5k3S1.css
#	src/webui/assets/assets/index-h-4zIuYd.js
#	src/webui/assets/index.html
#	src/webui/assets/mlxcel-webui-manifest.json
@inureyes
inureyes merged commit 7830baf into main Sep 25, 2026
38 of 39 checks passed
@inureyes
inureyes deleted the feature/issue-1916-activity-status-page branch September 25, 2026 03:24
inureyes added a commit that referenced this pull request Sep 25, 2026
…#1975)

## Summary

Closes the eleven LOW items left open by the #1930 and #1929 hardening, in one PR as the issue asks.

- Models: the page before the inspector drawer is inert below 1100 px; row labels and tooltips isolate the display name; a row action whose row re-sorts onto another page turns the pager there and focuses the follow-up control (a failed action's one-shot return never turns the page); search, filters and sort survive a route round trip via `library-view.ts`, scoped to the server instance they were set against and cleared on logout; row callback refs are stable; consuming an inspector request notifies subscribers.
- Drawer: a Tab propagates to descendants unless the adapter wraps it, the panel has no stop, or it starts on the package's stale first/last element.
- Activity: the slot box is a region and tab stop only while it overflows (shared `useOverflowRegion`); Operations sorts only on operations/locale change; formatters are cached per locale and precision; the plural form follows the rounded count; zero context reads unknown; a re-selected model waits for its first sample instead of showing old tiles under the stale banner.
- Gallery: a loading StatCard sample, placed after the lifecycle samples because `browser.spec.ts` pairs the progress samples with their next sibling; `csp.spec.ts` asserts its skeleton.

## Changes during review

- The pager-follow first treated focus on `<body>` as still waiting, so in Safari and macOS Firefox (where clicking Next does not focus it) it turned the page back after the user paged away. The intent now remembers the row's page: a re-sort is followed, a user page change ends it.
- The security review found the library view outliving logout and a server-instance change (the next session inherited the previous search; a remembered source or task could hide every row on a new catalog). It is now instance-scoped and reset on logout.
- A browser spec now checks that the page behind the drawer is inert in Chromium and that focus returns to Inspect after Close.
- A bilingual technical report is committed under `TECHNICAL_REPORTS/`.

Existing tests that assumed the old behavior were updated: slot-region and tab-order expectations, row-label locators (built from `isolate()`), and Activity fixtures now seed one history sample.

## Test plan

- [x] Every new test fails against the main sources and passes here (the CSP skeleton assertion by inspection: main's gallery renders no StatCard)
- [x] typecheck, lint, full unit (35 node + 710 Vitest), default Playwright suite (124, includes models, activity, shell)
- [x] `build_bundle.py --verify`, `check_binary_assets.py`, `check_webui_contract.py`
- [x] CSP spec against a real secured server: `mlxcel-server` built from `dc1845d3` (`--release --features cuda`, GB10, embedded bundle, `--api-key-file --webui`), served `style-src 'self'` without `unsafe-inline`; both themes pass with the skeleton assertion

Closes #1974
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:architecture Architecture and code structure changes priority:medium Medium priority status:done Completed type:enhancement New features, capabilities, or significant additions

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(webui): make Activity read as a status page, with consistent slot occupancy and honest counters

1 participant