Repository navigation
The console stays smooth and light on a big fleet #905
Description
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
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/workersor/v1/leases, and noGET /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/inboxfails. - 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/inboxand 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,workerLeaseIdandGET /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 viaworkerLeaseId; 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/workersfor 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 insrc/contract/inbox.ts, and the console imports that file by path, a second exemption besideversion-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:249forbids@tanstack/*inui/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]
- Unchanged JSON answers under /v1 are 304 with an ETag #919 Unchanged answers are 304. Risk: a stream gets buffered.
- One lease is read directly, and gateway leases name the worker's lease id #913 One lease read directly; gateway leases carry the worker's id. Risk: an agent sees another's lease.
- The console reads per page through a query cache, and events mark data stale #914 The console reads per page with TanStack Query and marks data stale from events. Risk: a change waits for the poll.
- The daemon says what needs the operator: GET /v1/inbox #915 The server says what needs the operator, and the console shows it. Risk: the Inbox shows a different list.
- The console sends ETags back and patches lease renewals in place #916 The console sends ETags back and patches renewals. Risk: a lease shows an old deadline.
- One shared recent-events list per console tab #917 One shared recent-events list per tab. Risk: a drawer misses events that came while it was closed.
- A browser test proves an idle console stays quiet on a 50-worker fleet #918 A browser test proves an idle tab stays quiet. Risk: a flaky ceiling.
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:
- Console: watch any device's screen, and layout fixes from the 2026-10-23 review #906 (watch any device's screen, layout fixes): Console: the worker's Events tab scrolls back through its history #911 (worker Events history) bumps the protocol, edits the contract and both dispatchers, rewrites
useWorkerEventsand builds onEventFeed.loadOlder→ Console: the worker's Events tab scrolls back through its history #911 already depends on The console reads per page through a query cache, and events mark data stale #914, The daemon says what needs the operator: GET /v1/inbox #915 and One shared recent-events list per console tab #917, so it lands after them. Console: layout fixes from the 2026-10-23 review #912 (layout fixes) changesinboxActions,state/inbox.tsand the Inbox screen → The daemon says what needs the operator: GET /v1/inbox #915 depends on it. Stream any running device to an operator by worker and device id #909, Console: watch a device from its drawer's Screen tab; the lease stream route goes #910 (device stream, lease Screen tab): no shared code. docs(adr): 0045 a device's screen streams to operators, not a lease's (#906) #908 took ADR 0045. - Every device state change is one device.state-changed event #320 (one
device.state-changedevent): the map'sdevice.*prefix covers it. None. - Console: time in state for every device #319 (time in state for every device; draft, blocked by Every device shape says when the device entered its state #318): edits the Inbox's stalled line, which The daemon says what needs the operator: GET /v1/inbox #915 moves to
src/contract/inbox.ts. Whichever lands second edits the other's file. None. - Review fleet reconciliation: what breaks while a gateway rebuilds its view #412, fix(gateway): a stale worker view no longer brings back a released lease (#898) #899 (gateway views), fix(daemon): status.get shows an agent only its own leases and queue entries #893 (
status.getfor agents): no shared code. None. - e2e console specs that assume a re-read every second (
e2e/console/workers-answers.ts:12-14): The console reads per page through a query cache, and events mark data stale #914 updates them.
Written by an agent.
Activity
docs(adr): draft ADR 0044 (#905)
docs(adr): 0044 after the feature check (#905)
V3RON commentedon Oct 23, 2026
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.
docs(adr): 0044 after the task checks (#905)
docs(adr): accept 0044, supersede 0013 §3 (#905)
docs(adr): host load shows within 15 s of the daemon, about 45 s on a…
V3RON commentedon Oct 23, 2026
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.get404 → added as a case; #916 — missing dependencies → added #914 and #915 to Depends onAlso filed flaky-test #933 (
daemon-start-failure.spec.ts).Written by an agent.
github-actions commentedon Oct 23, 2026
All 7 sub-issues are closed. If the completion conditions hold on
main, close this feature and flip its ADRs to Accepted.github-actions commentedon Oct 23, 2026
All 7 sub-issues are closed. If the completion conditions hold on
main, close this feature and flip its ADRs to Accepted.github-actions commentedon Oct 23, 2026
All 7 sub-issues are closed. If the completion conditions hold on
main, close this feature and flip its ADRs to Accepted.github-actions commentedon Oct 23, 2026
All 7 sub-issues are closed. If the completion conditions hold on
main, close this feature and flip its ADRs to Accepted.github-actions commentedon Oct 23, 2026
All 7 sub-issues are closed. If the completion conditions hold on
main, close this feature and flip its ADRs to Accepted.docs(adr): 0044 the console reads what its page shows, and events mar…
github-actions commentedon Oct 23, 2026
All 7 sub-issues are closed. If the completion conditions hold on
main, close this feature and flip its ADRs to Accepted.test(console): an idle console on a 50-worker fleet stays under 40 re…
V3RON commentedon Oct 23, 2026
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.get404 → 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.
V3RON commentedon Oct 23, 2026
All tasks merged in ab4698e; the delivery decisions are accepted by the maintainer.
Written by an agent.