Skip to content

Console Usage page #634

Closed
Closed

Description

@V3RON

Part of #623.

Scope

After this PR, the console has a Usage page at /usage, last in the sidebar. It shows the full figures for the chosen range (1 hour, 6 hours, 24 hours, 7 days): requests, grants and how each was served, wait, held time and turnaround, provisioning and boot time, rejections by reason, device incidents, slots, RAM and queue use over the range, and use per platform, per worker and per requester. The Overview's Granted tile now opens it. They are the same figures simlock stats --json and GET /v1/stats give for that range.

In short

The page reads GET /v1/stats?since=<range> (#632) and draws it as stat tiles and tables. It uses #633's range picker, its ?range= rule and its partial note, and fetches the route like every other (feature #623 Shared values, "Console ranges"). It works at 360 px wide and in both themes.

flowchart LR
  N["Sidebar: Usage (new)"] --> P["/usage?range=1h|6h|24h|7d"]
  P --> S["GET /v1/stats?since=range"]
  S --> V["Tiles + tables (new)"]
  S -->|partial| Q["Figures cover from today 09:12."]
  O["Overview: Granted tile (changed)"] -->|link| P
Loading

What the operator sees, for /usage?range=24h after a scripted run:

Usage                                   [1 hour] [6 hours] (24 hours) [7 days]
Figures cover from today 09:12.
Requests 214   Granted 209   Rejected 5   Wait p95 38 s   Held p50 4 min 2 s     (each: last 24 hours)

Grants by source     warm 160 · booted 41 · provisioned 8
Durations            Count  p50      p95       Max
  Wait               209    0 s      38 s      2 min 10 s
  Held               ...
  Turnaround / Provisioning / Boot ...
Rejections by reason   no-wait 3 · timeout 2
Use over the range     Slots: peak 8, mean 5.2, of 8 · RAM: peak 12.0 GiB, mean 900 MiB, of 16.0 GiB · Queue: peak 3, mean 0.4
Device incidents       Quarantined 1 · Recovered from quarantine 1 · Recovered after a crash 0 · Lost 0
Per platform           Platform, Requests, Granted, Rejected, Wait p95 (iOS, then Android)
Per worker             Worker, Requests, Granted, Rejected, Wait p95; one row per worker; on a single host one row, the host
Per requester          Requester (label, then id), Requests, Granted, Rejected, Held in total
Ask Today After
/usage page not found figures for the last hour
/usage?range=7d page not found figures for the last 7 days
/usage?range=30d page not found as /usage
A requester with no token label — its id alone
No requests in the range — tiles 0, durations and means —, the requester table says No requests in this range.
Overview on ?range=7d, click Granted no link (#633) opens /usage?range=7d (/usage from 1h)
A requester gw:<gateway>:agent-7 in a worker's answer — shown as agent-7
/usage?range=24h&requesters-page=3, then choose 7 days — /usage?range=7d, first page of each table

If this goes wrong, the operator sees figures that differ from simlock stats for the same range, or a page that scrolls sideways on a phone.

Technical spec

Facts

# Fact Source
F1 Pages are the entries of ROUTES, in sidebar order; a test pins that order. ui/src/app/routes.ts:18-25, ui/src/app/routes.test.ts:18-27
F2 A route's icon is a key of PATHS; there is no usage icon. ui/src/ds/icon.tsx:1-16
F3 The browser lane lists the pages in NAV; the every-view tests walk it: no sideways scroll at 360 px, keyboard alone, no CSP violation, sidebar order, and the axe WCAG AA check in light and dark. e2e/console/fixtures.ts:321-330; e2e/console/views.spec.ts:47, :77-93, :95-133, :208-210, :244-256; e2e/console/long-lists.spec.ts:776-800, :834-855
F4 A table of a page's items is DataTable, which needs a Paging; a page with two tables keeps each one's page under its own name. ui/src/ds/table.tsx:28-37, ui/src/state/paging.ts:16-23, docs/CONSOLE.md:42-45
F5 A holder is shown by its token's label, or its id where it has none. ui/src/model/leases.ts:57-59
F6 Keys the daemon spells (codes, payload keys) are shown as spelled, in mono. docs/internal/DESIGN.md:162-163
F7 Under 768 px a table's rows stack into blocks with data-label; a table of six columns or more stacks under 1100 px. docs/internal/DESIGN.md:228-234, ui/src/ds/table.tsx:78
F8 Stat tiles are a StatRow of StatTiles, each a label, a value and a unit. ui/src/ds/stat.tsx:7-54
F9 A refused read shows its message with role="alert"; while loading, Loading…. ui/src/ds/loaded.tsx:16-35
F10 The sidebar's pages are listed in the user manual. docs/CONSOLE.md:13-14
F11 #631's output keeps PR #356's figures. Per scope: requests, granted, bySource { warm, booted, provisioned, unknown? }, rejected { total, byReason } (refused-at-admission included), wait, held, turnaround, provisioning, boot (each { count, p50, p95, max }, ms, null over none), incidents { quarantined, crashRecovered, quarantineRecovered, lost }, utilisation { slots { peak, mean, max }, ram? { peakBytes, meanBytes, limitBytes } }, queue { peakDepth, meanDepth }, means time-weighted and null with no rows, queue and RAM from the scope's all rows; also declined, probes? (worker only) and failures.byEvent (device.recovery-failed only), which this page does not show. requesters[] { id, label?, requests, granted, rejected, heldTotalMs }, by requests then id. workers[] the figures plus { id, label? }: on a worker one entry, itself; on a gateway every registry worker and every worker id the rows name. #631 "Contract and event changes" (Counts, Incidents, Minute figures, Scopes, Requesters), F17, F21; gh pr diff 356 src/contract/schemas.ts usageFiguresSchema, usageOutputSchema
F12 A gateway in the browser lane takes a worker through its join token and gateway.url. e2e/console/waiting.spec.ts:115-131
F13 The Overview's Granted tile has no link after #633; the tile row links through to. #633; ui/src/model/overview.ts:15-20
F14 formatBytes prints MiB below 1 GiB (900 MiB) and GiB with one decimal from there (20.9 GiB). ui/src/model/load.ts:13-18
F15 agentIdOf shows the agent behind a gw:<instance>: requester, as the Leases page does. ui/src/model/leases.ts:47-55
F16 Page keys are <name>-page and <name>-size; a pager is hidden when every row fits on one page. ui/src/model/paging.ts:10-14; docs/internal/DESIGN.md:179-186

The output shape, the route and the ranges are feature #623 Shared values ("usage.get", "GET /v1/stats", "Console ranges"). The range picker, the Wait p95 tile and the partial note are #633's. Means are time-weighted by the daemon (Shared values, "usage.get"); the page prints them as given.

Modules touched

Searches: ROUTES, NAV, IconName, DataTable, usePaging, and #633's range and figures modules.

  • ui/src/model/usage.ts (new): one stats answer to the page's view model: tiles, grants by source, durations, rejections, incidents, use over the range, the platform, worker and requester rows.
  • ui/src/state/usage.ts (new): reads Overview and Waiting: a range picker, wait p95, leases granted and three charts #633's range and its stats path with useLiveResource. The worker and requester tables page in the URL (workers-page/workers-size, requesters-page/requesters-size, F16); the other tables (grants by source, durations, rejections, incidents, platforms) take one constant Paging that holds every row on one page, so no pager shows. A range change drops workers-page and requesters-page and keeps the sizes.
  • ui/src/model/overview.ts: the Granted tile links to /usage with the Overview's ?range= (none for 1h).
  • ui/src/screens/usage/index.tsx, usage.css (new): the page.
  • ui/src/app/routes.ts: a Usage entry at /usage, after Events.
  • ui/src/ds/icon.tsx: a usage icon.
  • e2e/console/fixtures.ts: Usage in NAV.
  • e2e/console/usage.spec.ts (new).
  • docs/CONSOLE.md, ui/AGENTS.md (Pages table).
flowchart LR
  RT[app/routes*] --> SU[screens/usage*]
  SU --> STU[state/usage*]
  SU --> DS[ds: StatRow, Panel, DataTable, ChoiceChips]
  STU --> R[state/range, #633]
  STU --> L[state/live]
  STU --> PG[state/paging]
  STU --> MU[model/usage*]
  MU --> F[model/figures, #633]
Loading

Contract and event changes

None. The page reads #632's route, typed from #631's output schema. The only input it takes is ?range= (#633's rule) and the four page keys (F4's rule).

Other code on the same state

Searches: setSearch writers (3: state/paging.ts, state/events.ts, #633's range), watch( (1).

Failure modes

Call Outcome What happens Limit, counted from
GET /v1/stats ok The page draws it —
GET /v1/stats error reply (403, 5xx) Before any answer, the refusal in the page's place; after one, the last figures stay with the refusal above them (F9) —
GET /v1/stats no answer The console is disconnected, as for any route 10 s from the request
GET /v1/stats requesters empty, or workers empty on a gateway with none The table says No requests in this range. or No workers in this range. —
GET /v1/stats utilisation.ram absent The RAM line is left out —

State written: the URL's range and page keys only. A restart in the middle leaves nothing.

Rules in play

  • ADR 0013 §1, "keeps no state the daemon does not report": every figure is the answer's, and the page sums, averages or re-buckets nothing.
  • ADR 0013 §3: the route is fetched on events and every second, coalesced, like every route.
  • ADR 0016 §5, "every surface shows that beside the figures": the partial note sits under the picker.
  • Architecture rule 10: the Wait p95 tile and the partial note are Overview and Waiting: a range picker, wait p95, leases granted and three charts #633's functions, not copies.
  • ui/AGENTS.md layers: the builder in model/, the read in state/, the screen draws only.
  • DESIGN.md: numbers mono and right-aligned; tables through DataTable; codes as spelled (F6); 360 px, both themes, AA contrast.
  • Testing rule 7: phone width, keyboard, CSP, axe and sidebar order come from adding Usage to NAV (F3), not from new tests.

Tests

Seam: ui/src/model/usage.ts unit tests and a screen test with renderToStaticMarkup, modelled on ui/src/screens/overview/overview.test.tsx; the browser lane modelled on e2e/console/views.spec.ts.

  • the tiles show Requests, Granted, Rejected, Wait p95 and Held p50 from totals, each with the range's unit, with a fixture where every figure differs
  • grants by source list warm, booted and provisioned, and unknown only when the answer has it
  • the durations table has rows Wait, Held, Turnaround, Provisioning and Boot, each with its own count, p50, p95 and max as durations, and — for a null figure
  • rejections list one row per reason as the daemon spells it, largest count first and ties by reason, and say No requests were rejected. when there are none
  • incidents list Quarantined, Recovered from quarantine, Recovered after a crash and Lost from their four fields
  • use over the range shows totals.utilisation.slots peak, mean (one decimal) and max, totals.queue peakDepth and meanDepth, and totals.utilisation.ram peakBytes, meanBytes and limitBytes through formatBytes (900 MiB below 1 GiB, 12.0 GiB above) only when it is present, — for a null figure, with a fixture where every figure differs
  • the page shows no declined, probes or failures.byEvent count, with a fixture that has them set
  • the platform table has the columns Platform, Requests, Granted, Rejected and Wait p95, with iOS then Android
  • the worker table has the columns Worker, Requests, Granted, Rejected and Wait p95; a row is named by its label, or its id where it has none, in the daemon's order; a single host's one entry is one row; an empty workers says No workers in this range.
  • a requester row shows its label with its id beside it, its id alone where it has no label, the agent alone for a gw:<instance>:<agent> id (agentIdOf), and its held time in total as a duration, in the daemon's order
  • an answer with no requests shows tiles of 0, — for every percentile and mean, and No requests in this range. in the requester table
  • the Usage screen draws the picker with the chosen range checked, the tiles, and each table as a region named by its panel title
  • the Usage screen shows the partial note under the picker when the figures carry one, and none otherwise
  • a refused stats read shows its message in place of the figures; while loading the page shows Loading…
  • extend "lists the sections in the sidebar's order" in ui/src/app/routes.test.ts with /usage last, and routeFor("/usage") is Usage
  • the Overview's Granted tile links to /usage from 1h and to /usage?range=6h, 24h, 7d from the others (extend Overview and Waiting: a range picker, wait p95, leases granted and three charts #633's Granted case in ui/src/model/overview.test.ts)
  • choosing another range drops workers-page and requesters-page from the URL and keeps the new range and both size keys (a model test of the URL writer)
  • (console) after a scripted run on the fake driver, /usage?range=24h shows the same requests, grants by source, rejections by reason, wait p95, slots peak and mean, and requester rows as GET /v1/stats?since=24h and simlock stats --since 24h --json, and its per-worker table has one row, the host
  • (console) /usage opens on 1 hour; choosing 7 days writes ?range=7d, and a reload keeps 7 days checked and the same figures
  • (console) a lease granted while the page is open raises Requests and Granted within 2 seconds with no reload
  • (console) clicking the Overview's Granted tile on /?range=7d opens /usage?range=7d
  • (console) on a gateway with two workers, the per-worker table has two rows, each named by its worker's label
  • (console) Usage in NAV puts /usage under the existing every-view tests: 360 px with no sideways scroll, keyboard alone, no CSP violation, sidebar order, and the axe check in light and dark (F3)

Done when

  • On the fake driver after a scripted run, the Usage page for 24 hours shows the same figures as simlock stats --since 24h --json and GET /v1/stats?since=24h.
  • The picker switches between 1 hour, 6 hours, 24 hours and 7 days, and the URL keeps the choice.
  • The page has no sideways scroll at 360 px and passes the axe check in both themes.
  • On a gateway the per-worker table has one row per worker; on a single host, one row.
  • docs/CONSOLE.md lists Usage in the sidebar and describes its figures and the partial note; ui/AGENTS.md has its row in Pages.

Out of scope

Depends on

Written by an agent.

Activity

  1. added
    task:draftScope written; technical spec or its spec check missing.
    on Oct 16, 2026
  2. added
    task:readySpec checked. An agent may implement it once its Depends on allow.
    and removed
    task:draftScope written; technical spec or its spec check missing.
    on Oct 16, 2026
  3. self-assigned this
    on Oct 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

task:readySpec checked. An agent may implement it once its Depends on allow.

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions