Repository navigation
The console reads per page through a query cache, and events mark data stale #914
Description
Part of #905.
Scope
The console reads each route only while a page that shows it is on screen, at that route's own interval, and an event marks only the data it changes as stale, read once per burst. Leaving a page stops its reads; its data is dropped a minute later. Nothing the operator sees on a page changes.
Kept whole although it touches about 20 files: the cache, the event map and the change of every useLiveResource caller are one switch; a half switch would run two read loops at once.
In short
An idle Leases page stops asking for four lists every second. It reads leases every 10 s, workers every 15 s, and after a lease event once, 300 ms after the burst ends (at most 1 s after it starts), and once more 1.5 s later. A change with an event still shows within about a second; host load shows within 15 s.
flowchart LR
P[page hook] --> Q["query cache (new, ui/src/state)"]
Q --> A[ApiClient.get]
S[event stream] --> M["event map + burst window (new)"]
M -->|mark stale| Q
C[connection: connected / lost / hidden] --> Q
Before → after, an idle Leases page for one minute (no events): about 240 requests → about 22 (leases 6, workers 4, and the sidebar's waiting requests 12; tokens once) until #915 moves the sidebar to the inbox.
If it goes wrong: a change waits up to 15 s, or a hidden tab keeps reading.
Technical spec
Facts
| # | Fact | Source |
|---|---|---|
| F1 | Every watched route is read every 1 s and after every stream event; one request in flight per route, one queued. | ui/src/data/live/connection.ts:5, 175-185, 260-268, 385 |
| F2 | watch reads at once on a route's first listener; routes stay in the map forever. |
ui/src/data/live/connection.ts:108-112, 167-173 |
| F3 | A read failure is classified: refusal (ApiError, SyntaxError) keeps data and stores the error; a timeout while starting retries; anything else loses the connection. Each answer's Date header corrects the clock offset; the first answer while starting marks it connected. |
ui/src/data/live/connection.ts:70-72, 196-240 |
| F4 | Hidden tab: polling stops, the stream closes; shown again: reads the screen once and reopens. Lost: health checks 1, 2, 4, 8 s then every 10 s, then reads the screen once. | ui/src/data/live/connection.ts:404-454 |
| F5 | useLiveResource is the only route read hook: 29 calls in ui/src/state/. |
ui/src/state/live.tsx:68-76; grep -n useLiveResource ui/src/state/*.ts | grep -v import |
| F6 | @tanstack/* is forbidden in ui/src/data/ and ui/src/model/, allowed in ui/src/state/. |
.dependency-cruiser.mjs:18, 240-262 |
| F7 | @tanstack/react-query is not a dependency. |
package.json:89-90 |
| F8 | A new dependency nothing imports statically gets its .fallowrc.json entry. |
docs/internal/agent-rules/always-in-scope.md:53-56 |
| F9 | LiveProvider wraps Shell once signed in. |
ui/src/app/app.tsx:43-45 |
| F10 | watchMode reads /v1/status on each stream open and every 10 min, outside the route map. |
ui/src/data/mode.ts:53-57, 89 |
| F11 | Public docs: "a lease being granted or a device booting shows within about a second, with no reload." | website/src/content/docs/docs/worker/console.mdx:94 |
| F12 | e2e helpers assume "the page re-reads the list every second". | e2e/console/workers-answers.ts:12-14, used by workers.spec.ts, workers-gateway-stops-mid-fetch.spec.ts |
| F13 | Event names and payload ids. | src/bus/index.ts:12-400 |
| F14 | Tests drive LiveConnection with a fake API and clock: "the screen is refetched every second…", "an event refetches the routes…", "a burst of events … exactly one more request". |
ui/src/data/live/connection.test.ts:18, 33, 53; ui/src/data/live/test-support.ts |
| F15 | The 1 s poll is what asks again for a stream the daemon refused ("the next poll asks for one again"). | ui/src/data/live/connection.ts:264-266, 324-327; connection.test.ts:462 |
| F16 | A read lost to the network stores nothing today: the page keeps its data, only the sidebar says Reconnecting; loadView turns any stored error into a refusal message. |
ui/src/data/live/connection.ts:224-226; ui/src/state/resource.ts:11-27 |
| F17 | 22 lines of connection.test.ts drive reads through connection.watch. |
grep -n "connection.watch" ui/src/data/live/connection.test.ts |
Modules touched
package.json, pnpm-lock.yaml, .fallowrc.json (if needed); ui/src/data/live/connection.ts (drops the route map and poll; keeps the stream, phase, health checks and clock offset; adds read(path), which does F3's classification for one GET); ui/src/state/queries.ts (new: QueryClient, keys and times from #905 Shared values, useRouteQuery returning today's ResourceState); ui/src/model/stale-keys.ts (new, pure: event → keys, #905 Event map; the ['inbox'] key is added by #915); ui/src/state/live.tsx (provider holds the client; useLiveResource → useRouteQuery); every ui/src/state/*.ts that calls useLiveResource (F5); tests in ui/src/data/live/connection.test.ts and new ui/src/state/queries.test.ts; e2e/console/workers-answers.ts and the specs that rely on it (F12); ui/AGENTS.md (Layers, a "Reads" section), console.mdx:94 (F11). Searches: useLiveResource, connection.watch, connection.resource, refetchScreen.
Contract and event changes
None (searched src/contract/ and src/bus/: no change). Reads the event names of F13 only.
Other code on the same state
EventFeed(ui/src/data/event-feed.ts) keeps listening to the stream as today; One shared recent-events list per console tab #917 moves it.watchMode(F10) keeps its own read; it is not a query key.state/search.tsuseSettledOncereads throughuseRouteQuery; it keeps the first settled answer as today.- Actions (
state/actions.ts) send writes and rely on the events that follow, as today.
Searches:onEvent(,onStreamOpened(,api.getinui/src/(8 hits).
Failure modes
| Call | Outcome | What happens | Limit, counted from |
|---|---|---|---|
| query read | ok | data replaced (structurally shared) | 10 s request timeout, from send (today's) |
| query read | refusal (4xx/5xx with a code, 501 unsupported) | error stored, last data kept, no retry until the next poll or event | — |
| query read | timeout while starting | read again at once (F3) | 10 s per try |
| query read | network failure or timeout while connected | connection lost; the key keeps its state from before the read (data, or loading), never an error (F16); every query paused; reads resume once the health check answers, one per key on screen | F4's back-off |
| stream | refused | asked for again 1 s later, and every 1 s while refused, as today (F15) | 1 s from the refusal |
| stream | lost / hidden | as F4; on return each key on screen is read once | — |
No stored state; the cache lives in the tab.
Rules in play
- ADR 0044 §1, §2: complies (keys, times, event map, burst window as The console stays smooth and light on a big fleet #905 Shared values).
- ADR 0013 §2, §4, §5 still bind: one stream read with
fetch, browser-side ticking, the lost/health-check cycle. ui/AGENTS.md"A screen never fetches": screens unchanged; hooks keep their view models.- Documentation rule 9:
console.mdx:94gains "Readings with no event of their own, such as host load, refresh within 15 seconds."
Tests
Seam: the query layer in ui/src/state/queries.ts through QueryObservers in Node, with the fake API and clock of ui/src/data/live/test-support.ts, modelled on connection.test.ts; that file's 1 s and refetch-on-event tests (F14) are replaced by the lines below, and every other test that reads through connection.watch (F17: hidden tab, health-check reconnect, refused read, starting daemon, refused stream, data age, clock offset) moves to queries.test.ts with its title and cases.
- A page on screen polls its keys; leaving or hiding stops it: each key reads at its interval (lease-requests 5 s, leases and one lease 10 s, workers 15 s, stats 60 s, tokens never); last observer gone → no more reads; hidden tab → none, shown → one read per observed key; disconnected → none; reconnected → one read per observed key.
- Data no page uses is dropped: observer back within 60 s → kept data at once, a read only if no longer fresh (2 s; stats 30 s; tokens 5 min); after 60 s → loading, then a read.
- Events mark keys stale per the map, in bursts: one case per Event map row (each marks exactly its keys;
device.*withleaseIdmarks every['lease', id]); 50 events within 200 ms → one read per observed key 300 ms after the last, one more 1.5 s later; an event every 200 ms → first read 1 s after the first event; a stale key with no observer → no read until observed; an unknown name → nothing. - A read's failure is handled as today (F3, F16): a refusal keeps the last data and shows its message; a timeout while starting reads again; a network failure loses the connection and the page shows its data, or loading, with no error; a refused stream is asked for again 1 s later.
Done when
pnpm vitest run ui/srcpasses, andconnection.test.tshas no 1 s poll test left.pnpm lintpasses (dependency-cruiser: no@tanstack/*underui/src/dataorui/src/model).- The console browser lane passes (
e2e/console).
Out of scope
ETags (#916), renewals (#916), the Inbox route (#915), event lists and the clock (#917).
Depends on
Written by an agent.
Activity
[-]One lease is read directly, and gateway leases name the worker's lease id[/-][+]The console reads per page through a query cache, and events mark data stale[/+]test: data no page uses is dropped a minute after its last page closes (
test: events mark keys stale per the map, in bursts (#914)
feat(ui): an event marks the keys it changes stale, read once per bur…
test: a read lost to the network leaves the page its data, or its loa…
feat(ui): a read lost to the network shows the page no error (#914)
test(e2e): console specs return to the tab for a fresh read instead o…
refactor(ui): createQueries takes its options as parameters (#914)
docs: the console no longer polls worker.list every second (#914)
fix(ui): a hidden tab reads nothing at a burst, a read lost while hid…
feat(ui): the console reads per page and marks data stale from events (…
test(console): an idle console on a 50-worker fleet stays under 40 re…