Skip to content

The console stays smooth and light on a big fleet #905

Closed
7 of 7 issues completed
Closed

Description

@V3RON

Request: none

Problem

The web console reads every route the open page uses once a second, and again after every event the daemon sends, with no pause between bursts. The sidebar's Inbox count keeps the four fleet-wide lists (workers, leases, waiting requests, tokens) in that loop on every page. On a gateway with many workers and leases, each open tab asks for the whole fleet several times a second and mostly gets the same answer back, and the browser rebuilds every view each second.

Nothing is loaded only when a page needs it, and nothing is dropped when the operator leaves a page: lease and stats reads stay in memory for the life of the tab, while the event lists are thrown away and fetched again each time a page opens.

Who it is for

An operator who keeps the console open on a gateway with up to 50 workers (5–10 devices each, about 500 devices and up to 500 live leases), often in several tabs, and the gateway that answers them.

Outcome

  • An idle console tab costs the gateway at most 40 requests a minute on any page, instead of about 4 a second.
  • A page reads only its own data, while it is on screen. Leaving it stops its reads, and its data is dropped a minute later.
  • A change the daemon sends an event for (a lease granted, a device booting, a worker connecting) still shows within about a second. Anything else (host load, a worker that connects as incompatible) shows within 15 seconds of the daemon serving it; a gateway refreshes a worker's host load every 30 s, so there host load can take about 45 s (decided in delivery, accepted by the maintainer 2026-10-23).
  • An answer that has not changed costs no download and no re-render.

How it works for the user

sequenceDiagram
  participant O as Operator
  participant C as Console tab
  participant G as Gateway
  O->>C: opens Leases
  C->>G: GET /v1/leases, /v1/workers, /v1/inbox
  G-->>C: 200 + ETag
  Note over C: polls leases every 10 s, workers 15 s, inbox 10 s
  C->>G: GET /v1/leases (If-None-Match)
  G-->>C: 304, nothing changed
  G--)C: event lease.granted
  Note over C: waits 300 ms for the burst to end
  C->>G: GET /v1/leases, /v1/workers, /v1/inbox
  C->>G: same reads again 1.5 s later
  G--)C: event lease.renewed
  Note over C: deadline patched, no read
  O->>C: opens Usage
  Note over C: lease reads stop; lease data dropped after 60 s
  G--xC: connection lost
  Note over C: reads stop until the gateway answers again
Loading

What the operator sees does not change: the same pages, the same data, the same "Connected" and "Reconnecting" line. In the browser's network panel, before and after, an idle Leases page for one minute:

Before After
Requests about 240 (4 routes × 60) plus one round per event at most 40
Most answers 200 with the full list 304 with no body

Examples

When a page's data is read, per route:

Route Read while a page that shows it is on screen Also read
waiting requests every 5 s after a request or queue event
leases, one lease every 10 s after a lease is granted, released or expired; a renewal changes the deadline in place
inbox (sidebar count, Inbox, Overview) every 10 s after any lease, request, device or worker event; after a renewal of a lease the Inbox shows as ending
workers every 15 s after a device, worker, capacity, component or update event
stats every 60 s never after an event
tokens when first needed, again after 5 min never after an event
any route — once when the tab is shown again or the gateway comes back

What could go wrong

  • A change with no event of its own (host load) shows up to 15 s late instead of 1 s, and on a gateway up to about 45 s, since the gateway itself refreshes host load every 30 s. Expected; the docs say so (decided in delivery, accepted by the maintainer 2026-10-23).
  • A new event name that should refresh a page refreshes nothing until the poll, up to 15 s late, until someone adds it to the event map. Likely over time.
  • The Inbox count differs for a few seconds from what the Fleet page shows, because they are read at different moments. Rare and harmless.

Words used

  • Poll interval: how often a page re-reads one route while it is on screen, e.g. leases every 10 s.
  • Stale: data the console will re-read the next time a page needs it; an event marks data stale.
  • Dropped: data no page has used for a while is removed from the tab's memory, e.g. leases 60 s after the last page that showed them closed.
  • Burst window: the console waits until no event has arrived for 300 ms, but never more than 1 s after the burst's first event, before reading, so 50 events in a second cost one read.
  • ETag / 304: a short fingerprint of an answer. The console sends back the last one it got; when the answer has not changed, the server says "304 Not Modified" with no body.
  • Inbox item: one thing that needs the operator, e.g. a quarantined device or a request waiting over a minute. It carries a small copy of what it is about (its subject): the worker's own fields, or the device, the lease or the request.

Non-goals

  • No server-side paging or filtering of /v1/workers or /v1/leases, and no GET /v1/workers/{id}.
  • No filter on the event stream, and no resume after a reconnect from the last event id.
  • No new event for a worker that connects as incompatible.
  • No change to what any page shows, or to the CLI, except that the Inbox and Overview's "Could not check" note gives way to the page's refusal when GET /v1/inbox fails.
  • Activity's scroll-back stays as it is: it is already dropped when the operator leaves Activity.
  • The per-second clock still rebuilds the views that show a time; a cheaper clock is a follow-up.

Completion conditions

  • With a fake gateway of 50 workers and 500 devices, an idle console tab makes at most 40 requests to /v1 (the event stream not counted) in the 60 s after each of the seven pages has loaded.
  • A lease granted through the gateway shows on the Leases page within about a second, as today.
  • Leaving a page stops its reads; coming back within a minute shows the kept data at once.
  • A second read of an unchanged list answers 304.
  • The sidebar count reads GET /v1/inbox and none of the four fleet lists; the Inbox page and the Overview's Needs you read it and /v1/workers, and not leases, tokens or waiting requests.

Open questions

None.

Decisions

  • ADR 0044 — The console reads what its page shows, and events mark it stale: replaces the 1 s poll and the read after every event (ADR 0013 §3) with per-route intervals, a burst window and an event map; adds ETags, lease.get, workerLeaseId and GET /v1/inbox.
  • Maintainer, this session: target 50 workers × 5–10 devices; event changes within about a second, others within 15 s; server scope = ETag, lease.get, GET /v1/inbox; ETag is a hash of the answer; inbox items carry ids, labels and timestamps; renewals patched in place via workerLeaseId; the event map below; read twice after a burst; test seams: console query layer in Node, HTTP app tests, one browser-lane request-count test.
  • Maintainer, after the feature check: an inbox item embeds a small subject record (the worker's own fields without devices, catalog, leases or downloads, plus its held lease count; or the device with its worker's id and label; or the lease record with its holder; or the request), (the Inbox page reading nothing else was replaced by the next line).
  • After the task checks (to confirm at approval): the Inbox page and Needs you also read /v1/workers for names, links and lease counts, since a row's menu and the drawer's event lines need them; the Activity history and the cheaper clock leave this feature; The daemon says what needs the operator: GET /v1/inbox #915 waits for Console: watch any device's screen, and layout fixes from the 2026-10-23 review #906's Console: layout fixes from the 2026-10-23 review #912.
  • Taken as recommended (maintainer: smaller choices follow the recommendation): the rules and their helpers (holderName, deviceOfLease, isExpiring, stateEnteredAt, requestedDevice) live zod-free in src/contract/inbox.ts, and the console imports that file by path, a second exemption beside version-order.ts; idle ceiling 40 a minute over the 60 s after load, event stream not counted; the burst read waits at most 1 s after a burst's first event; a renewal of a lease the cached inbox shows as ending marks the inbox stale; the server rules and the console switch are one task, so the rules move in one change; the Inbox page's "could not check" note goes (the server reads its own data; a failed read shows the page's refusal); the worker page's Events tab is left to Console: watch any device's screen, and layout fixes from the 2026-10-23 review #906 The console reads per page through a query cache, and events mark data stale #914.
  • The query cache lives in ui/src/state/: .dependency-cruiser.mjs:249 forbids @tanstack/* in ui/src/data/.

Shared values

Value or state Rule Written by Read by Source
Query keys and times ['workers'] poll 15 s; ['leases'] 10 s; ['lease', id] 10 s; ['lease-requests'] 5 s; ['inbox'] 10 s; ['stats', range] 60 s; ['tokens'] no poll, fresh 5 min. Fresh for 2 s after a read (tokens 5 min, stats 30 s). Dropped 60 s after the last page using it closes. A poll runs only while a page using the key is on screen, never in a hidden tab, never while disconnected. A read lost to the network leaves the key as it was (data or loading), never an error. #914 (['inbox'] #915) #915, #916, #918 connection.ts:5, maintainer
Shared recent events One EventFeed per tab for the Inbox drawer and the lease drawer: since=1h on first use, then the stream; kept, and still following the stream, for 60 s after its last user, then dropped. Not a query key. Activity keeps its own feed. #917 — ui/src/state/events.ts:36-56
Event map lease.granted/released/expired → leases, workers, inbox, every ['lease', id]; lease.requested/queued/rejected/declined, request.*, queue.changed → lease-requests, inbox; lease.renewed → none (#916 patches); device.* → workers, inbox, plus every ['lease', id] when the payload has leaseId; worker.*, capacity.changed, component.*, daemon.update-* → workers, inbox; any other name → none. Inbox entries are added by #915. #914, #915 #916 src/bus/index.ts:12-400
Burst window Reads start 300 ms after the last event of a burst, or 1 s after its first event, whichever comes first, and once more 1.5 s after that; only keys a page on screen uses are read, others are only marked stale. #914 #916, #918 maintainer
ETag Strong, Hono's etag() digest of the body (SHA-1, chained per 256 KiB above that size), on every GET under /v1 that answers 200 with application/json; never on text/event-stream or a mount (/v1/stream, agent-device). Equal bodies give equal tags. A matching If-None-Match → 304, no body. #919 #916 node_modules/hono/dist/middleware/etag/index.js
lease.get Op lease.get { leaseId } → { lease } (the lease.list record). An unknown or not-owned id → UNKNOWN_LEASE, 404, as today. The HTTP reply of GET /v1/leases/{id} keeps its shape. #913 — src/http/app.ts:783-801
workerLeaseId On a gateway, every lease.list, lease.get and list.get kind=leases record carries the worker's own lease id (the raw id before projection, also for leases the index does not know); absent on a worker and in status.get. Not in the GET /v1/leases/{id} reply. #913 #915, #916 src/gateway/lease-index.ts:332-352
Renewal match Event with payload.workerId: in ['leases'] the record with that workerId and workerLeaseId === leaseId, in ['workers'] that worker's lease with id === leaseId; without workerId: the record with id === leaseId. Match → ttlDeadline = newDeadline, lastRenewedAt = the event's timestamp, ttlMs = newDeadline − timestamp; the deadline is kept 60 s and applied to later answers that carry an older one; ['lease', <id>] and, when the cached inbox has an ending item for it, ['inbox'] are marked stale through the burst window. No match → nothing. #916 — src/bus/index.ts:42, worker-link.ts:556, worker-page.ts:353-366
inbox.get Op inbox.get, role admin, both modes → { items }. Item: key (today's format), group (Broken / Needs a look / Heads-up), condition, and one subject: worker (the worker view without devices, catalog, leases, downloads, plus heldLeases and leasedDevices), device (the device record plus workerId, workerLabel, stateEnteredAt), lease (the lease.list record plus holder, model), or request (the waiting request); usedBytes/limitBytes on over RAM budget. Times are the subject's own timestamps (createdAt, ttlDeadline, stateEnteredAt); the browser ticks them. Rules, thresholds (waiting > 60 s, ending < 5 min), order and every helper they call are today's ui/src/model/inbox.ts and leases.ts/workers.ts/waiting.ts helpers, moved to src/contract/inbox.ts; the console copy is deleted in the same task and the console imports that file. Count = items not Heads-up. #915 #916, #917 ui/src/model/inbox.ts:60-163, leases.ts:138
Protocol #913 and #915 each change the contract and bump the protocol by one; #911 (#906) does too and lands after #915. #913, #915 — src/contract/protocol.ts:13, toolchain.md:196-203
Idle ceiling ≤ 40 requests to /v1, counted over the 60 s that start once no /v1 request other than the stream has been sent for 2 s, or 15 s after the page opened, whichever comes first; the event stream is not counted; 50 workers, 500 devices, 300 leases, no events. #918 — maintainer

Behaviours

Behaviour (invoker, action, what it sees) Seam Cases Task
A client reads a JSON route twice, sending back the ETag src/http/app.test.ts unchanged → 304 empty; changed → 200, new ETag; stale tag → 200; 404 answer → no ETag; event stream → no ETag; a JSON mount → no ETag #919
A client reads one lease daemon and gateway dispatcher tests, src/http/app.test.ts own → found, today's reply shape; another agent's → 404 UNKNOWN_LEASE; admin any → found; unknown → 404; gateway id → found; gateway admin, a worker's local lease → found; a raw id the index renames → 404; /events of an unknown id → 404 #913
An admin lists leases on a gateway gateway dispatcher test gateway-minted id; requester-chosen id; a lease the index does not know; list.get kind=leases carries it; status.get and worker mode do not #913
A page on screen polls its keys; leaving or hiding stops it ui/src/state query layer, fake API and clock each key's interval; leave page; hidden tab; disconnected; reconnect reads once #914
Data no page uses is dropped same back within 60 s → kept, shown at once; after 60 s → read again #914
Events mark keys stale per the map, in bursts same one case per map row; 50 events within 200 ms → one read, one more 1.5 s later; steady events every 200 ms → first read 1 s after the first; key not on screen → no read; unknown event → nothing #914
A read fails same refusal keeps data with its message; timeout while starting reads again; network failure → data kept, no error; refused stream asked for again 1 s later #914
The inbox rules turn fleet data into items src/contract/inbox.test.ts, from ui/src/model/inbox.test.ts each worker, device, waiting (59 s no, 61 s yes) and ending (4 min yes, 6 min no) condition; group, order, keys; each subject's fields #915
An admin reads GET /v1/inbox src/http/app.test.ts gateway; worker (fleet of one); agent → 403 #915
The sidebar count, Inbox and Needs you show the server's items query layer + renderToStaticMarkup count leaves out Heads-up; keys read on Usage, Inbox and Overview; fix buttons, Update… confirm, Release menu; drawer facts and event lines #915
A re-read of an unchanged route ui/src/data/api.test.ts + query layer 304 → previous data, same object; 200 → new data; 304 after drop → read without tag #916
A renewal patches its lease query layer worker match; gateway match in leases and workers; later stale answer keeps the new deadline; no match; ending in inbox → one inbox read per burst #916
The recent events are shared per tab state hooks via LiveContext Inbox then lease drawer → one read; event while unused but kept → shown; after 60 s → new read #917
An idle console on a big fleet stays quiet e2e/console, as long-lists.spec.ts each of the seven pages, 60 s idle → ≤ 40 requests #918

Tasks

flowchart LR
  X[#912 of #906] -.-> T4
  T6 -.-> Y[#911 of #906]
  T1[#919 ETag] --> T5
  T2[#913 lease.get + workerLeaseId] --> T4
  T2 --> T5
  T3[#914 query layer] --> T4[#915 inbox on the server]
  T4 --> T5[#916 304 + renewals]
  T4 --> T6[#917 shared recent events]
  T5 --> T7[#918 request-count test]
Loading

Completion conditions: idle ceiling → #918; a lease shows within a second → #914; leaving stops reads → #914; 304 → #919, #916; inbox reads → #915.

Across features: #915 depends on #912; #911 depends on #914, #915 and #917.

Open work in the same area:

Written by an agent.

Activity

  1. added
    feature:specSpec in progress, business and technical together.
    on Oct 23, 2026
  2. V3RON commented on Oct 23, 2026

    @V3RON
    ContributorAuthor

    Spec updated: Decisions, Shared values, Behaviours and Tasks after the feature check; tasks #919, #913, #914, #915, #916, #917, #918 created.

    Written by an agent.

  3. added
    feature:plannedSplit into tasks. Never picked up itself.
    and removed
    feature:specSpec in progress, business and technical together.
    on Oct 23, 2026
  4. V3RON commented on Oct 23, 2026

    @V3RON
    ContributorAuthor

    Delivery run

    Merged: none (the stack stays open until its last task: #907 ADR → #920 (#919) → #922 (#913) → #926 (#914), all reviewed and ready)
    Parked: none
    Waiting: #915 — on #912 (PR #924, feature #906); #916 — on #915 (PR #927 draft, 2/3 cycles built); #917 — on #915; #918 — on #916
    Decided: #905/ADR 0044 — host-load bound on a gateway → about 45 s, code unchanged; #913 — test the stale-index lease.get 404 → added as a case; #916 — missing dependencies → added #914 and #915 to Depends on

    Also filed flaky-test #933 (daemon-start-failure.spec.ts).

    Written by an agent.

  5. github-actions commented on Oct 23, 2026

    @github-actions

    All 7 sub-issues are closed. If the completion conditions hold on main, close this feature and flip its ADRs to Accepted.

  6. github-actions commented on Oct 23, 2026

    @github-actions

    All 7 sub-issues are closed. If the completion conditions hold on main, close this feature and flip its ADRs to Accepted.

  7. github-actions commented on Oct 23, 2026

    @github-actions

    All 7 sub-issues are closed. If the completion conditions hold on main, close this feature and flip its ADRs to Accepted.

  8. github-actions commented on Oct 23, 2026

    @github-actions

    All 7 sub-issues are closed. If the completion conditions hold on main, close this feature and flip its ADRs to Accepted.

  9. github-actions commented on Oct 23, 2026

    @github-actions

    All 7 sub-issues are closed. If the completion conditions hold on main, close this feature and flip its ADRs to Accepted.

  10. github-actions commented on Oct 23, 2026

    @github-actions

    All 7 sub-issues are closed. If the completion conditions hold on main, close this feature and flip its ADRs to Accepted.

  11. V3RON commented on Oct 23, 2026

    @V3RON
    ContributorAuthor

    Delivery run

    Merged: #907 (ADR 0044), #920 (#919), #922 (#913), #926 (#914), #937 (#915), #927 (#916), #939 (#917), #940 (#918) — one stack, squashed into main as ab4698e
    Parked: none
    Waiting: none
    Decided: ADR 0044 — gateway host-load bound → about 45 s, code unchanged; #913 — stale-index lease.get 404 → test case added; #916 — missing dependencies → #914, #915 added; #915 — "Overview's Needs you" → the panel only, tiles keep their reads; #915 — dependency on #912 → dropped (maintainer, breaks the loop with #906)

    Also filed flaky-test #933 (daemon-start-failure.spec.ts). #906's #911 can now build on main.

    Written by an agent.

  12. V3RON commented on Oct 23, 2026

    @V3RON
    ContributorAuthor

    All tasks merged in ab4698e; the delivery decisions are accepted by the maintainer.

    Written by an agent.

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

    feature:plannedSplit into tasks. Never picked up itself.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions