Skip to content

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

Description

@inureyes

Part of #1910.

Problem / Background

The Activity page is a stack of prose and repeated "N/A" lines rather than a status page. Observed 2026-09-17 against a release build of main 0ef0a1a4 after one streaming chat on qwen3-0.6b-4bit (mlxcel-server --webui --models-dir models/mlx --api-key-file <f> --no-models-autoload --settings --props --metrics --slots, Playwright at 1440x900): the page is 1,584 px tall and 254 words for four idle slots and one operation. Three defects sit on top of the layout problem:

  • Slot 0 shows a 21 / 40960 tokens bar while slots 1 to 3 print "context denominator is unknown; no occupancy percentage is inferred" (text.noContext) although runtime.slots.request_context_tokens is 40960 for every slot. The bar is gated on slot.prompt_tokens !== null (webui/src/features/activity/runtime.tsx:26), and the server sets prompt_tokens to null whenever the slot has no task (src/server/slots_state.rs:194), so an idle slot is mislabelled as having an unknown denominator. "Accepted decode: N/A tokens · Cached prompt: N/A tokens" (runtime.tsx:27) repeats once per idle slot.
  • "Total completed requests" and "Total completion tokens" read 0 after a UI chat because streaming completions are never recorded in Metrics (fix(server): record streaming chat completions in Metrics so Activity stops reporting zero #1911).
  • Identifiers are page copy: each operation article prints the operation id and model id inside "Operation details" (webui/src/features/activity/operations.tsx:36), the kind is operation.kind.replaceAll('_', ' ') and the state badge shows the raw enum word (operations.tsx:31).

Current Behavior

webui/src/features/activity/index.tsx:23-32 stacks: a .screen-heading with "Last successful snapshot"; a full-width "Select a model to observe" Select; a button row (Refresh observations, Export sanitized diagnostics); the stale banner; Operations; RuntimeView; and a full-width "Show recent history" toggle that lazy-loads history.tsx, an SVG of discrete dots. operations.tsx:14 opens with "Operations: 0 active · 0 failed" and the retention sentence text.session. runtime.tsx:18-20 renders four dl.activity-metric tiles then the sentence "Unavailable measurements: 12. Some sources are disabled or do not publish an authoritative sample. Expand details for individual reasons." as body text, then "Request slots", "Effective parallelism: N/A / 4", "Request context window: 40960 tokens · Shared pool context: N/A tokens" and one <section> per slot. All copy lives in webui/src/features/activity/strings.ts outside webui/src/i18n/catalog.ts (the locale === 'ko' branch at strings.ts:37, and inline locale === 'ko' ? '진행 중' : 'active' at operations.tsx:14). format.ts:4-7 metricValue formats a MeasuredValue with its unit.

Proposed Solution

Recompose the page on #1914 (PageHeader with actions) and the @lablup/ui-common components that #1902 exposes through webui/src/design-system/primitives.tsx (StatCard, Badge, SmoothHeight, Skeleton, EmptyState, Tooltip). Feature code keeps importing from primitives.tsx, never from the package (docs/webui/ui-common.md).

  • Header. Title, then the model Select and the Refresh and Export buttons in the PageHeader actions slot. Options sort by entry.lifecycle.state, Ready models first, then the catalog order, so the loaded model is the first choice instead of one row among 212. The last-snapshot time moves into the header description. The Select stays a combobox whose options are identity.display_name, because webui/scripts/activity-performance.mjs:55-61 selects the model by getByRole('combobox') and getByRole('option', { name }).
  • Summary tiles. Four StatCards (Active requests, Completed requests, Completion tokens, Queued requests) with hint set to the measured_at time and metric.reason (provenance). A null value renders the card with value "unknown" and the reason as hint, never 0 and never hidden. While snapshot.runtimes has no entry for the selected model and the connection is ready, render four StatCard loading skeletons rather than the "no authoritative sample is available" EmptyState.
  • Unavailable count. Replace the sentence at runtime.tsx:20 with a Badge reading "12 unavailable" placed inside the <summary> of the existing details.activity-measurement-details, so activating it opens the per-metric list. The badge is omitted when the count is 0. The memory-scope and timing notes (text.memory, text.timing) stay inside that disclosure.
  • Slot table. Replace the per-slot <section>s with a compact DataTable (the typed export in primitives.tsx, required for Activity by docs/webui/ui-common.md) with columns slot, state (StatusBadge Processing or Idle), occupancy (ProgressBar with prompt_tokens / request_context_tokens tokens), decoded, cached prompt. Rules, in this order: if request_context_tokens is null or 0, every occupancy cell shows the existing "unknown denominator" text and no bar; else if prompt_tokens is non-null, bar plus N / D tokens; else if processing is false, 0 / D tokens with an empty bar, because the server derives null from the absence of a task (slots_state.rs:194), not from a failed measurement, and an empty slot holds no context; else (processing with null count) "unknown". Decoded and cached cells show the number, "unknown" for null on a processing slot, and stay empty for an idle slot with no task. The 0 / D idle case is a documented exception to "null is never shown as zero" in docs/webui/ux-contract.md:23; add the sentence there and in docs/webui/activity.md:11. "Effective parallelism", "Request context window" and "Shared pool context" become one line above the table; a null value still reads "unknown".
  • Operations. Each row is one line inside SmoothHeight (active when the list length changes): kind label from the catalog (six keys, one per OperationKind: catalog_refresh, model_load, model_unload, download, model_removal, settings_patch), target label (unchanged logic from operations.tsx:22), StatusBadge with the existing state mapping (operations.tsx:31) but a catalog label per OperationState instead of the enum word, relative time via a new relativeTime(iso, now, locale) in webui/src/features/activity/format.ts built on Intl.RelativeTimeFormat (no such helper exists in webui/src/design-system/format.ts), the download ProgressBar and the cancel Button for non-terminal rows. Operation id and model id stay behind the "Operation details" disclosure; nothing else prints them. The role="status" aria-live="polite" count moves into the section heading as "1 active · 0 failed". The retention sentence (text.session) becomes the EmptyState body when there are no operations and a Tooltip on the count otherwise.
  • History. Delete the page-wide "Show recent history" toggle and the lazy import. history.tsx becomes a small always-rendered sparkline passed to the Active requests StatCard sparkline slot (StatCardProps.sparkline in dist/components/StatCard/StatCard.d.ts), keeping the discrete-dot SVG so gaps stay blank; rendered only when runtimeHistory holds two or more samples with a non-null active_requests. historyNote and the per-point list move into the measurements disclosure.
  • Strings. Every new or moved string is a catalog.ts key with tests/fixtures/webui/strings.json in sync; strings.ts shrinks to whatever refactor(webui): move every user-facing string into the i18n catalog and remove native confirm dialogs #1913 has not already moved, and the inline locale === 'ko' ternaries at operations.tsx:14 go. No window.confirm.

Rejected: computing a percentage for a processing slot with null prompt_tokens from decoded_tokens (invents occupancy, docs/webui/activity.md:11); summing memory scopes into one tile (they overlap on unified memory).

Scope

In scope: webui/src/features/activity/{index,operations,runtime,history}.tsx, format.ts, strings.ts, activity.css; catalog keys in webui/src/i18n/catalog.ts and tests/fixtures/webui/strings.json; tests activity.test.tsx, format.test.ts, webui/tests/activity.spec.ts; webui/scripts/activity-performance.mjs selectors if the DOM change requires; docs/webui/activity.md and docs/webui/ux-contract.md for the idle-slot rule.

Out of scope: server changes (#1911 owns the counters; src/server/webui/runtime.rs is untouched); the shell and PageHeader (shell child); catalog display names (names child); the diagnostics allowlist in format.ts:17-30, which keeps its shape and its test.

Implementation Notes

  • Reuse: metricValue and operationProgress (format.ts), isTerminal (webui/src/state/observation.ts:24), StatusBadge/ProgressBar/DataTable/EmptyState adapters in common-adapters.tsx, downloadDiagnostics unchanged.
  • Constraints: no /ui-api/v1 contract change; keyboard order, visible focus, axe clean and expectSafeLayout at 1440 and 390 (activity.spec.ts); the observation schedule and the 2 percent median-degradation budget of scripts/webui/verify_activity_performance.py and webui/scripts/activity-performance.mjs are unchanged, so no new polling or timers; sparkline SVG has role="img" and a catalog aria-label.
  • Edge cases: slots.available === false with a reason (fixture tests/fixtures/webui/examples/runtime.snapshot.json) renders the reason in place of the table; slots.reason "showing the first 256 observational slots" still appears above the table (existing test at activity.test.tsx:59-64); more than 8 slots scroll inside the table, not the page; a failed operation keeps its ErrorBanner with error.code only, never error.message (activity.test.tsx:36-43); stale connection disables cancel and shows the warning banner as today.
  • Error handling: cancel failure keeps the inline cancelFailed banner; a runtime fetch error leaves the previous tiles with the stale banner rather than blanking them.

Acceptance Criteria

  • After one streaming chat from the UI against the bundled binary, the Completed requests tile reads 1 and the Completion tokens tile is non-zero (requires fix(server): record streaming chat completions in Metrics so Activity stops reporting zero #1911 merged).
  • With --slots and four idle slots, every idle row shows 0 / 40960 tokens; "unknown" appears only when request_context_tokens is null, covered by a unit test that fixes the mislabelled-idle case and was shown failing before the change.
  • No operation id or model id is visible outside an "Operation details" disclosure; the unit test asserts the ids are absent from the collapsed DOM text.
  • At 1440x900 with four idle slots and one operation, document.documentElement.scrollHeight <= 900 in a browser test using the mock API; the value is recorded in the PR with the before number 1,584.
  • The Unavailable badge opens the measurements disclosure; the sentence at runtime.tsx:20 no longer exists.
  • The kind and state of every operation row come from the catalog in both locales; strings.ts holds no locale === 'ko' ternary.
  • Integrated into the real page (index.tsx), not a parallel component; pnpm --dir webui run lint, typecheck, unit, browser, verify-generated green; the Activity performance script passes in visible-only-headed mode at minimum against the bundled binary.

Verification

pnpm --dir webui run lint && pnpm --dir webui run typecheck && pnpm --dir webui run unit && pnpm --dir webui run browser && pnpm --dir webui run verify-generated
cargo build --release --features metal,accelerate
./target/release/mlxcel-server --webui --models-dir models/mlx --api-key-file <f> --no-models-autoload --settings --props --metrics --slots
# load qwen3-0.6b-4bit from Models, send one chat from Chat, open Activity: tiles 1 / non-zero, four idle rows read 0 / 40960 tokens, page has no vertical scrollbar at 1440x900
cd webui && WEBUI_PERF_MODE=visible-only-headed WEBUI_PERF_BASE=http://127.0.0.1:8080/ WEBUI_PERF_KEY_FILE=<f> WEBUI_PERF_INFERENCE_MODEL=<inference-id> WEBUI_PERF_MODEL_ID=<mdl_id> WEBUI_PERF_OUTPUT=/tmp/activity-paired.json node scripts/activity-performance.mjs

Technical Considerations

Depends on #1911 (counters), #1902 (StatCard, Badge, SmoothHeight, Skeleton, Tooltip through primitives.tsx), #1914 (PageHeader actions) and #1913 (catalog consolidation). Sibling of the Models, Chat and Settings redesign children in #1910 Phase 2.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions