Skip to content

Overview and Waiting: a range picker, wait p95, leases granted and three charts #633

Closed

Description

@V3RON

Part of #623.

Scope

After this PR, the Overview has a range picker (1 hour, 6 hours, 24 hours, 7 days). For the chosen range it shows how long requests waited (p95) and how many of the requests made in it were granted, and three charts: leases held, requests in the queue, and device slots in use. The figures come from the daemon (GET /v1/stats, #632), so a reload or a daemon restart shows the same numbers. The Waiting page shows the wait p95 of the last hour.

In short

Today the Overview counts its one chart and its "Granted, last hour" tile from the events the page holds: the last hour the feed loads, and the stream after (F1, F2, F23). Nothing reaches past an hour, and nothing whose events are gone counts. After this PR both come from GET /v1/stats?since=<range>. The range is in the URL as ?range= (feature #623 Shared values, "Console ranges"); the stats are fetched like every other route (F6). The Capacity panel (the current snapshot) stays as it is.

flowchart LR
  P["Range picker<br/>?range=1h|6h|24h|7d"] --> S["GET /v1/stats?since=range (new read)"]
  S --> T["Tiles: Wait p95, Granted (changed)"]
  S --> C["Charts: leases held, queue, slots (changed)"]
  W["/v1/workers"] --> K[Capacity panel, as today]
  S1["GET /v1/stats?since=1h"] --> WP["Waiting page: Wait p95 tile (new)"]
Loading
  • Bucket: one point of a chart, as wide as the answer's bucketMs (feature Operational metrics kept over hours and shown in the console #623 Shared values, "Bucket width"): 1 minute for 1 hour, 5 minutes for 6 hours, 15 minutes for 24 hours, 1 hour for 7 days. Each point is the peak in its bucket; null is no data and draws as a gap, not 0. A point's at is its bucket's end, and the last one's is to, so it may be short (usage.get and simlock stats from the projections #631). Its label names the bucket's start, window.from + i × bucketMs for point i: that is at − bucketMs for every full bucket, as today's minute labels name a minute's start (F3).
  • Wait p95: totals.wait.p95 of the answer. Below 1 second it shows in milliseconds (400 ms); from 1 second in the format the console uses for every duration (38 s, 2 min 10 s, F15); — when null (no request ended its wait in the range).
  • Granted: totals.granted: the requests made in the range that were granted by its end (usage.get and simlock stats from the projections #631, "Counts"), not the lease.granted events stamped in it. docs/CONSOLE.md says so.
  • Where a stats refusal goes: the fleet tiles (Utilisation, Leases, Waiting, Workers) stay. In the tile row, one item after Workers takes the place of Granted and Wait p95 and holds Loading… or the refusal (F19). The chart row holds one panel with the same note in place of the three charts.

What the operator sees:

Ask Today After
/ tile Granted 1 last hour, counted from events held, a link to Events tile Granted 1 last hour from the daemon, no link; then tile Wait p95 38 s last hour
/?range=7d as / tiles and charts for the last 7 days, hourly points
/?range=2d or /?range= as / as / (1 hour)
Reload, or daemon stop and start chart and Granted rebuilt from the events the feed loads; a pruned event no longer counts same numbers, events pruned or not
Range older than the kept figures — Figures cover from <day> <HH:MM>. above the tiles, <day> as today, yesterday or 16 Oct
/waiting, /waiting?range=7d no wait figure tile Wait p95 38 s last hour above the list; no picker, always the last hour

If this goes wrong, the operator sees figures that change on reload, a gap drawn as zero, or a refused stats read that hides the whole fleet.

Technical spec

Facts

# Fact Source
F1 The Granted tile counts lease.granted events the page holds from the last hour; its unit is last hour and it links to /events. ui/src/model/overview.ts:146-148, :165
F2 The Overview chart is leaseHistory: leases held now, counted back minute by minute through the lease events held, never below 0. ui/src/model/overview.ts:179, ui/src/model/history.ts:40-56
F3 A chart has 60 points, one per minute, labelled HH:MM. ui/src/model/history.ts:5, :17-30
F4 MinuteChart and MinuteTable take { at, label, count } with a number count; the table's disclosure is Minute by minute. ui/src/ds/chart.tsx:19-23, :28-35, :92-99
F5 The Events page draws its events-per-minute chart with the same two components; it stays counted from events. ui/src/screens/events/index.tsx:117-128, ui/src/model/event-list.ts:188
F6 The live connection refetches every watched path every second and on every stream event, one request per path in flight; a path is fetched at once when first watched, and when the tab is shown again (ADR 0013 §3). ui/src/data/live/connection.ts:5, :252-261, :360-369, :100-108, :409-426
F7 useLiveResource keys a resource by its whole path, so /v1/stats?since=1h and ?since=7d are two resources. ui/src/state/live.tsx:61-69
F8 An API path is any /v1/${string}; /v1/stats?since=7d passes. ui/src/data/api.ts:4, :41
F9 The duration parser accepts 1h, 6h, 24h and 7d. src/contract/duration.ts:16
F10 The Overview joins its reads with bothViews, so one refused read hides the whole page; a 501 on /v1/lease-requests is the one exception. ui/src/state/overview.ts:21-28, ui/src/state/resource.ts:40-45
F11 A URL-backed choice reads an unknown value as the default, leaves the default out of the URL, keeps other keys, and is written with replace. ui/src/model/event-list.ts:33-46, ui/src/state/events.ts:98-100
F12 ChoiceChips is a radio group with a hidden legend. ui/src/ds/choice.tsx:12-37
F13 The Overview has one chart panel Leases held, last hour and a Capacity panel beside it. ui/src/screens/overview/index.tsx:48-67
F14 The Waiting page reads /v1/lease-requests and /v1/workers only; its list pane has a title, a meta line and a footer. ui/src/state/waiting.ts:28-34, ui/src/screens/waiting/index.tsx:40-48
F15 Durations format as 38 s, 2 min 10 s. A missing figure shows —. ui/src/model/time.ts:7-25; ui/src/screens/overview/index.tsx:110, ui/src/model/waiting.ts:113
F16 A day is named today, yesterday or 16 Oct. ui/src/model/event-list.ts:63-69
F17 Browser tests pin today's tile text Granted | 1 | last hour and the chart title Leases held, last hour. e2e/console/views.spec.ts:293-299, :165-166, :135-142
F18 The browser lane can stop and start a daemon on the same home. e2e/console/fixtures.ts:41-44, :104-111
F19 A refused read shows its message with role="alert"; while loading, Loading…. ui/src/ds/loaded.tsx:16-35
F20 The design guide says a chart's table lists every minute and has 60 rows; a tile never makes a request of its own. docs/internal/DESIGN.md:177-178, :207-214, :157-159
F21 Docs describe the event-counted chart, the 60-minute table and the "last hour" Granted tile; the manual lists "No history beyond the last hour". docs/CONSOLE.md:39-40, :140-150; ui/AGENTS.md:45, :81-82
F22 The console reads contract types type-only from src/contract/schemas.ts. ui/src/model/waiting.ts:3
F23 Each screen's event feed loads the last hour from GET /v1/events, then follows the stream. ui/src/state/events.ts:33-42
F24 MinuteCount is defined in history.ts and used by the Events page model. ui/src/model/history.ts:7-11, ui/src/model/event-list.ts:11, :181
F25 A daemon with eventLog.retention of a few seconds prunes the events of a first run at its next start; projection rows keep metrics.retention (default the larger of 90 days and eventLog.retention). #629 Tests (e2e retention line); feature #623 Shared values, "Config keys", "Prune"

The answer's shape, GET /v1/stats and the ranges are feature #623 Shared values ("usage.get", "GET /v1/stats", "Bucket width", "Console ranges"); the field names are #631's (totals.granted, totals.wait.p95, series[], coversFrom, partial, bucketMs).

  • The charts draw complete minutes only (usage.get and simlock stats from the projections #631: the running minute is left out), so a lease taken now shows in a chart once its minute ends; the Granted tile is the live figure (decided in delivery, pending the maintainer).

Modules touched

Searches: leaseHistory, MinuteChart, MinuteTable, MinuteCount, HISTORY_MINUTES, Granted, last hour, useLiveResource, bothViews.

  • ui/src/model/range.ts (new): the four ranges, their labels (1 hour… for chips, last hour, last 6 hours, last 24 hours, last 7 days for units), reading and writing ?range= as F11, and the stats path of each range (/v1/stats?since=1h, 6h, 24h, 7d), so the state hooks only pass it on.
  • ui/src/model/figures.ts (new): one place for what both pages show from a stats answer: the Wait p95 tile and its millisecond format, the Granted tile, the partial note, and the three chart series.
  • ui/src/model/history.ts: leaseHistory goes; a bucket label by bucketMs and window is added; MinuteCount.count may be null; eventsPerMinute and extremes stay (F5). extremes skips null points.
  • ui/src/model/event-list.ts: follows the nullable count (F24); its figures stay numbers.
  • ui/src/model/overview.ts: the event-counted Granted tile and chart go; Granted (no link) then Wait p95 follow the Workers tile; the view model takes the figures separately from the fleet.
  • ui/src/state/range.ts (new): the range hook over useSearch and setSearch.
  • ui/src/state/overview.ts: reads /v1/stats?since=<range> with useLiveResource as its own view, not joined with bothViews (F10).
  • ui/src/state/waiting.ts: reads /v1/stats?since=1h the same way.
  • ui/src/ds/chart.tsx, ui/src/ds/chart.css: points may be null; the table's disclosure is named by the bucket width.
  • ui/src/screens/overview/index.tsx, overview.css: the picker, the note, the tiles, three chart panels, Capacity as today.
  • ui/src/screens/waiting/index.tsx, waiting.css: the Wait p95 tile above the list.
  • ui/src/screens/events/index.tsx: follows the chart component's props, nothing else.
  • e2e/console/views.spec.ts: the tile and chart-title tests (F17).
  • e2e/console/overview.spec.ts (new).
  • docs/CONSOLE.md, docs/internal/DESIGN.md, ui/AGENTS.md.
flowchart LR
  SO[screens/overview*] --> STO[state/overview*]
  SW[screens/waiting*] --> STW[state/waiting*]
  STO --> R[state/range*]
  STW --> R
  STO --> L[state/live]
  STW --> L
  STO --> F[model/figures*]
  STW --> F
  F --> H[model/history*]
  F --> MR[model/range*]
  SO --> DS[ds/chart*]
  SE[screens/events*] --> DS
Loading
sequenceDiagram
  participant B as Browser (Overview)
  participant D as Daemon
  B->>D: GET /v1/stats?since=1h (operator token)
  D-->>B: 200 usage.get answer
  Note over B: tiles and charts drawn
  B->>D: again on the next event or 1-second poll (coalesced)
  D-->>B: 4xx/5xx { error }
  Note over B: figures keep the last answer, the refusal shows above them; fleet tiles and panels unaffected
Loading

Contract and event changes

None. The console reads #632's GET /v1/stats; its answer is typed from #631's usage.get output schema (F22). The one new input is ?range= in the page URL: anything but 1h, 6h, 24h, 7d reads as 1h (F11).

Other code on the same state

Searches: location.search writers (setSearch: 3 hits, state/paging.ts, state/events.ts), resources keyed by path (watch(: 1 hit).

  • Paging (state/paging.ts) writes page and size into the same query. Writing range keeps every other key, and paging keeps range (F11): the last write wins only for its own key.
  • LiveConnection (F6) fetches the stats path as it fetches every route; at most one request per path is in flight, so a burst of events costs one more read, not one each.
  • Console Usage page #634 reads the same range and the same stats path. When both watch the same path, there is one resource and one fetch (F7). The Waiting page and the Overview at 1h share /v1/stats?since=1h the same way.

Failure modes

Call Outcome What happens Limit, counted from
GET /v1/stats ok Tiles, note and charts drawn from it —
GET /v1/stats error reply (403, 422, 503, 5xx) Before any answer: the tile-row item and the chart-row panel show the refusal; after one: the last figures stay with the refusal above them (F19). The four fleet tiles, Workers, Capacity, Attention, Activity and the Waiting list are unaffected. —
GET /v1/stats body not JSON As an error reply (connection.ts:63-65) —
GET /v1/stats no answer, or network error The console is disconnected, as for any route (ADR 0013 §5) 10 s from the request (api.ts:1)
GET /v1/stats answer for an older range arrives after the range changed Not shown: each range is its own resource (F7) —

State written: the URL's range key only. A restart in the middle leaves nothing. Two tabs each keep their own URL.

Rules in play

  • ADR 0013 §3, "Any event refetches the routes the current screen reads": the stats path is fetched like every route, with no cadence of its own (feature Operational metrics kept over hours and shown in the console #623 Shared values, "Console ranges").
  • ADR 0016 §8, "a client never re-buckets": the charts draw the answer's points as they come.
  • ADR 0032 §5, "A point is the peak of its bucket": each chart point is the series value, not a sum or a last value.
  • Architecture rule 10, one place: the Wait p95 tile and the range rule are built once (model/figures.ts, model/range.ts) and used by both pages.
  • ui/AGENTS.md layers: rules in model/, reads in state/, screens draw only.
  • DESIGN.md charts (F20): each chart keeps a title, a summary line and its table, now one row per bucket; animation off; colour by class. The tile rule holds: the page reads the stats, the tile only shows them.
  • Testing rule 7: the tile and chart-title browser tests in views.spec.ts are extended, not copied.

Tests

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

  • the range reads 1h, 6h, 24h and 7d from ?range=, reads a missing, empty or unknown value (2d) as 1h, and writing 1h drops the key while keeping page and size
  • each range maps to its stats path: 1h → /v1/stats?since=1h, 6h → ?since=6h, 24h → ?since=24h, 7d → ?since=7d
  • the Wait p95 tile shows totals.wait.p95 as 0 ms, 400 ms, 999 ms, 1 s, 38 s, 2 min 10 s for 0, 400, 999, 1 000, 38 000 and 130 000 ms, and — when it is null, with the range's unit (last hour, last 6 hours, last 24 hours, last 7 days), and comes right after Granted
  • the Granted tile shows totals.granted of the answer, not the lease.granted events held, and has no link (extend "counts the fleet's headline figures…" in ui/src/model/overview.test.ts: three granted in the answer, one event held, shows 3, to absent)
  • the three charts take leasesHeld, queueDepth and slotsUsed from each series point in order, with a fixture where the three differ, and a null point stays null
  • a bucket's label is its start (at − bucketMs, and for a short last point the previous point's at) as HH:MM for 1-minute, 5-minute, 15-minute and 1-hour buckets in a window of one day or less, and carries the day (today 14:00, yesterday 14:00) in a 7-day window
  • each chart's summary line says now, highest and lowest, skips null points, and is empty when every point is null
  • the partial note reads Figures cover from <day> <HH:MM>. from coversFrom when partial is true, and is absent when it is false
  • the Waiting page's Wait p95 tile shows the same text as the Overview's for the same 1-hour answer, with the unit last hour
  • the chart table lists every bucket newest first, shows — for a null point, and names its disclosure by the bucket width (Minute by minute, Every 5 minutes, Every 15 minutes, Hour by hour) (extend ui/src/ds/chart.test.tsx)
  • the Overview draws the range picker as a radio group of four with the chosen range checked, three chart panels titled Leases held, <unit>, Requests in the queue, <unit> and Device slots in use, <unit>, and the Capacity panel
  • the Overview shows the partial note above the tiles when the figures carry one, and no note otherwise
  • a refused stats read shows its message as one item after Workers in the tile row and as one panel in the chart row, while the Utilisation, Leases, Waiting and Workers tiles, the workers table, Capacity, Attention and Activity still render; a stats read still loading shows Loading… in the same two places
  • the Waiting page shows the Wait p95 tile above its list with no range picker, and a refused stats read leaves the list as it was
  • the Events page chart is unchanged: the existing eventsChart tests in ui/src/model/event-list.test.ts pass unedited
  • (console) extend "the overview's tiles show the numbers the daemon gives": the tiles end Granted | <totals.granted> | last hour, Wait p95 | <totals.wait.p95> | last hour from GET /v1/stats?since=1h
  • (console) extend the chart keyboard and outline tests to the new chart titles, each waiting until the three charts have drawn before it counts the stops (F17)
  • (console) after a scripted run on the fake driver with one device and a second request that waits, picking each of 1 hour, 6 hours, 24 hours and 7 days writes its ?range=, and the two tiles and all three chart tables equal GET /v1/stats?since=<range>
  • (console) on a daemon with eventLog.retention of a few seconds (F25), after a scripted run, simlock daemon stop, a wait past the retention and start, /?range=24h shows the same Wait p95, Granted and chart table rows (for the buckets before the stop) as before the stop, with GET /v1/events empty
  • (console) a reload of /?range=6h on that daemon keeps 6 hours checked and the same tile numbers
  • (console) a lease granted while the Overview is open raises Granted within 2 seconds with no reload
  • (console) the Waiting page's Wait p95 equals GET /v1/stats?since=1h totals.wait.p95, also when opened as /waiting?range=7d
  • (e2e) after a scripted run that spans a completed minute, each chart table's non-null rows equal the matching series of GET /v1/stats, and the same rows show after a daemon stop and start.

Done when

  • On the fake driver after a scripted run, the Overview shows Wait p95 and Granted, and three charts, for each of 1h, 6h, 24h and 7d, matching GET /v1/stats?since=<range>.
  • A reload and a daemon stop and start show the same numbers for the same range, also after the events behind them were pruned.
  • The Waiting page shows Wait p95 for the last hour.
  • A refused /v1/stats hides only the tiles and charts it feeds.
  • docs/CONSOLE.md describes the picker, the tiles (Granted: requests made in the range that were granted), the three charts and the partial note; docs/internal/DESIGN.md says a chart's table has one row per bucket; ui/AGENTS.md drops "No history beyond the last hour" for the Overview (the Events chart still counts from events) and updates its Pages row.

Out of scope

Depends on

Written by an agent.

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