Repository navigation
Console: watch a device from its drawer's Screen tab; the lease stream route goes #910
Description
Part of #906.
Scope
An operator watches a device's screen from the device drawer, on a single worker or a gateway, whether or not the device is leased. The drawer gets two tabs, Details and Screen, and opens on Details. The picture starts when Screen opens and stops when the operator leaves the tab or closes the drawer. The stream numbers move behind an ⓘ button. The lease drawer loses its Screen tab. With its last caller gone, GET /v1/stream/leases/{id} is removed.
In short
Today: Leases → a lease → Screen tab → Show screen. After: Fleet → a worker → a device → Screen tab, and the picture starts by itself. When the device is not running, the tab says why and starts by itself once the device is ready again.
flowchart TD
R[Device row] --> D["Device drawer, Details tab (changed: tabs)"]
D -->|Screen tab, ?drawer=screen| S{device state}
S -->|ready or leased| P["Picture (new: no button)"]
S -->|other| N[Its sentence]
N -->|becomes ready| P
P -->|ⓘ| I[Popover: stream numbers]
P -->|leave tab, close drawer| X[Request aborted]
The tab's text, word for word (<worker> is the worker's name as the console shows it):
| Device state, or worker | Screen tab shows |
|---|---|
ready, leased |
the picture |
shutdown |
The device is shut down. Its screen shows while it runs. |
provisioning |
The device is starting. Its screen shows once it is ready. |
reclaiming |
The device is being reset. Its screen shows once it is ready. |
quarantined |
The device is quarantined. It has no screen to show. |
absent |
The device is not connected. Its screen shows once it is back. |
deleted, or a state word the console does not know |
The device is <state>. It has no screen to show. |
a 409 while the row still says ready or leased (the row is behind) |
the daemon's message, until the row catches up |
| worker with streaming off | Streaming is off on <worker>. (as today) |
| worker where streaming is unavailable | Streaming is unavailable on <worker>. <reason> (as today) |
The ⓘ button (named "Stream details") opens a popover with State, Picture, Frames, Rate, Bandwidth and Dropped, the facts that sit under the picture today. Escape, a click outside or a second click closes it.
A new term: drawer query parameter, the device drawer's open tab in the URL, e.g. /fleet/w1/dev_1?tab=devices&drawer=screen. tab stays the worker page's tab. drawer belongs to one open drawer: closing the drawer drops it, and device row links never carry it, so the next device opens on Details.
If this goes wrong, a user would see the picture keep the worker capturing after they left the tab, or no picture for a running device.
Technical spec
Facts
| # | Fact | Source |
|---|---|---|
| F1 | The lease drawer's tabs are Details, Screen (keepMounted) and Events; Screen is ScreenPanel with leaseId, offered, reason |
ui/src/screens/leases/drawer.tsx:36-60 |
| F2 | ScreenPanel draws a Show screen/Stop button, the canvas (aria-label="The leased device's screen"), then Facts with State and the readout |
ui/src/screens/leases/screen-panel.tsx:8-74 |
| F3 | useScreen(leaseId) streams /v1/stream/leases/<id>; leaving or changing the lease aborts |
ui/src/state/screen.ts:47-84,123-130 |
| F4 | refusalOf words a 404 as "only for a lease that is still held" and a 415 as "This device cannot be streamed." |
ui/src/state/screen.ts:90-109 |
| F5 | The readout's facts are Picture, Frames, Rate, Bandwidth, Dropped, plus a State | ui/src/model/screen.ts:41-62 |
| F6 | streamOffer(worker) reads the worker's stream capability and words "Streaming is off on ." and "Streaming is unavailable on . " |
ui/src/model/leases.ts:231-240 |
| F7 | The device drawer has props workerId, deviceId, no tabs, and an ActionBar only for a leased device; its row comes from the worker page (useDeviceDrawer) |
ui/src/screens/fleet/device-drawer.tsx:10-35; ui/src/state/workers.ts:164-175 |
| F8 | DeviceRow carries status from deviceStateStatus(device.state), not the raw state |
ui/src/model/worker-page.ts:290-302 and deviceRow |
| F9 | The worker page keeps its tab in ?tab=; device links keep ?tab=devices |
ui/src/model/worker-page.ts:623-627; :335 |
| F10 | DrawerTabs takes defaultValue only; no URL |
ui/src/ds/drawer.tsx:64-89 |
| F11 | @base-ui/react is 1.9.0 and ships popover; nothing in ui/src imports it |
pnpm-lock.yaml:548; ls /Users/szymon.chmal/Projects/pitlane/node_modules/@base-ui/react → popover, version 1.9.0; rg base-ui/react/popover ui/src → none |
| F12 | Icons are a hand-written path map with no info icon | ui/src/ds/icon.tsx PATHS |
| F13 | The console learns its mode and its worker list at runtime; on a worker, GET /v1/workers lists the worker itself with its instance id |
ui/src/data/mode.ts:4; src/daemon/dispatcher.ts:877-901 |
| F20 | Closing the device drawer keeps the whole query string; device row links keep every key but tab |
ui/src/state/workers.ts:172; ui/src/model/worker-page.ts:335,623-633 |
| F21 | Every unmatched stream path answers noSuchStream: 404 UNKNOWN_LEASE "This path names no stream lease" |
src/stream/refusal.ts:24-28; src/stream/service.ts:34; src/stream/service.test.ts:58 |
| F22 | The capture registry is keyed by lease id; #909 adds device keying beside it | src/stream/captures.ts:21-22,59-94; #909 |
| F23 | deviceStateStatus passes a state word it does not know through |
ui/src/model/tones.ts:11-20,36-38 |
| F24 | The drawer is a modal Base UI Dialog; Escape closes it | ui/src/ds/drawer.tsx:34 |
| F14 | Browser tests open the lease Screen tab and its button | e2e/console/screen.spec.ts:55-60,124-270; e2e/console/leases.spec.ts:645-646 |
| F15 | A unit test streams the lease path | ui/src/data/api.test.ts:273 |
| F16 | Docs describing the lease Screen: ui/AGENTS.md pages table "(Details, Screen, Events)"; website/.../worker/console.mdx:159; website/.../using-devices/watching-the-screen.mdx:8,55,69 |
those lines |
| F17 | DESIGN.md: a drawer has "for a lease, tabs"; "a device's shows the device's state as a status" | docs/internal/DESIGN.md:161-166 |
| F18 | The stream route after #909, its refusals and the streamable states | #906 Shared values |
| F19 | The lease route's server code: leaseIdOf, requireLease, holder in the gateway link, unknownLease, x-simlock-lease |
src/stream/service.ts:17-26,85; src/stream/worker-link.ts:113-130; src/stream/gateway-link.ts:7-62; src/stream/refusal.ts:20-28 |
Modules touched
Searches: rg "ScreenPanel|ScreenView|refusalOf|useScreen|stream/leases|leaseIdOf|requireLease|unknownLease|holder\(|DrawerTabs|streamOffer" over ui/src, src/, e2e, docs/internal, website/src/content/docs. Beyond F14–F16 they find e2e/console/leases.spec.ts:337 (the lease Screen tab), ui/src/screens/leases/leases.test.tsx:168-228 (ScreenView), ui/src/state/screen.test.ts:25-31 (the 404's lease words), src/stream/gateway-link.test.ts:82, watching-the-screen.mdx:103. The lease path also sits in src/daemon/stream-gateway.e2e.test.ts:144, src/http/openapi.test.ts:353-355, src/http/core-routes.test.ts:88, ui/src/data/stream.ts:2, ui/src/data/api.test.ts:273 and src/stream/{service,gateway-link,create}.test.ts; each moves to the device route or goes.
flowchart LR
DD[screens/fleet/device-drawer *] --> SP[screens: ScreenPanel, moved *]
SP --> US[state/screen: useScreen by worker+device *]
US --> API[data/api stream]
DD --> DT[ds/drawer DrawerTabs: value from URL *]
SP --> PO[ds/popover new *]
SP --> IC[ds/icon info *]
LD[screens/leases/drawer *] -.Screen tab removed.-> SP
ST[src/stream: lease route removed *]
sequenceDiagram
participant O as Operator
participant C as Console
participant D as Daemon
O->>C: Screen tab
C->>D: GET /v1/stream/workers/w1/devices/d1
D-->>C: 409 DEVICE_NOT_RUNNING
C-->>O: "The device is shut down. Its screen shows while it runs."
D-->>C: 200 frames
O->>C: Details tab
C->>D: abort
Contract and event changes
- HTTP:
GET /v1/stream/leases/{id}is removed, with its lease lookup, owner check and the lease keying of the capture registry (F19, F22).noSuchStreamchanges for every unmatched stream path, the old one included: 404UNKNOWN_LEASE"This path names no stream lease" becomes 404UNKNOWN_DEVICE"This path names no stream" (F21), anddefinition.ts's 404 listsUNKNOWN_DEVICEandUNKNOWN_WORKERonly. The route's declaration leaves the OpenAPI document. No protocol bump (nosrc/contractchange); no events. - Console URL:
drawerquery parameter,screenor absent (Details). Any other value reads as Details.
Other code on the same state
The console's live stream request and the worker's capture. Searched rg "useScreen|api.stream" ui/src (3 hits): the screen hook, its test, the API client. One ScreenPanel is mounted at a time per drawer; Base UI unmounts a tab panel that is not keepMounted, and the Screen tab is not, so leaving it aborts its request. Two browser tabs on one device run two captures (ADR 0045 §5). #909 serves the route; this task only reads it. #912 changes ActionBar, which the device drawer keeps using as is.
Failure modes
| Call | Outcome | What happens | Limit, counted from |
|---|---|---|---|
| Console → device stream | 200 | picture, live | — |
| Console → device stream | 409 DEVICE_NOT_RUNNING |
the state's sentence, or the daemon's message while the row still says ready or leased; retried when the row turns ready or leased |
— |
| Console → device stream | 404 UNKNOWN_DEVICE / UNKNOWN_WORKER |
the daemon's message, as refusalOf does today without the lease words |
— |
| Console → device stream | 415 | "This device cannot be streamed." as today | — |
| Console → device stream | 503 / network error | the daemon's message; the tab stays | the head timeout STREAM_HEAD_TIMEOUT_MS, from the request, as today |
| Console → device stream | stream ends | the end reason, as today; a later ready row starts it again |
— |
No state written.
Rules in play
- ADR 0045 §1: one route shape on either daemon — the console builds the URL from the drawer's worker id and device id, in both modes.
- ADR 0037: "the console acts through the HTTP routes, on Base UI" — the popover is Base UI's.
- DESIGN.md drawer rule — rewritten: a device's drawer has Details and Screen tabs; a lease's has Details and Events.
ui/AGENTS.mdRules: "A screen never fetches" — the stream stays instate/screen.ts; the sentence table is a pure function inmodel/screen.ts.- documentation rule 9:
console.mdx(the device drawer and its Screen tab; the lease drawer without it),watching-the-screen.mdx(Fleet path, picture not bound to a lease, the lease route gone),ui/AGENTS.mdpages table,src/stream/AGENTS.md(lease route gone).
Tests
Seam: the browser lane e2e/console/screen.spec.ts against a started worker with the scripted capture program (its witness log counts captures opened and closed), plus ui/src/model/screen.test.ts for the sentence table, and src/stream/service.test.ts for the removed route.
- An operator opens a device drawer and its Screen tab: opens on Details with no capture started; Screen → one capture opened and a picture painted, URL has
drawer=screen; Details → the capture closed; closing the drawer → closed, and the URL has nodrawer; opening another device after that → Details, no capture; the device is leased while the tab is open → the same request stays open (one capture opened, none closed); a gateway with one worker gives the same, through the same URL shape. - The Screen tab of a device that is not running: each state's sentence from the table, no capture opened (model unit test for the five states,
deleted, an unknown word, and a 409 on areadyrow; browser forshutdown); the device turnsreadywhile the tab is open → the picture starts; the device is shut down while watched → the stream ends and the tab shows theshutdownsentence; the console reopens only after a refusal or an end, never on aready→leasedchange; streaming off on the worker → "Streaming is off on ." with no request. - An operator opens the stream numbers: ⓘ "Stream details" → popover with State, Picture, Frames, Rate, Bandwidth, Dropped; Escape and a second click close it while the drawer stays open and no capture closes; no Picture fact is drawn outside the popover.
- An operator opens a lease drawer: its tabs are Details and Events (
leases.spec.ts:645changes to this). - A caller asks for a stream path that names no stream:
GET /v1/stream/leases/lse_1,/v1/stream/workers/w1,/v1/stream/devices/d1→ 404UNKNOWN_DEVICE"This path names no stream", nothing opened (extendsservice.test.ts:58).
Done when
pnpm testandpnpm test:consolepass, with the five Tests lines above.rg -l "stream/leases" src ui e2e website docs/internal --glob '!docs/internal/adr/**'lists only the test that proves the old path answers 404.- The OpenAPI document has no
/v1/stream/leases/{id}. console.mdx,watching-the-screen.mdx,ui/AGENTS.md,src/stream/AGENTS.mdand DESIGN.md's drawer rule describe the device drawer's Screen tab.
Out of scope
- The stream route itself (Stream any running device to an operator by worker and device id #909).
- Touch or key input on the picture.
- Any other drawer's tabs.
Activity
test: the Screen tab of a device that is not running shows each state…
feat(ui): the Screen tab says why a device that is not running has no…
test: the Screen tab of a device that is not running starts no captur…
test: the stream numbers sit behind Stream details in a popover (#910)
feat(ui): the stream numbers move behind a Stream details button (#910)
test: a stream path that names no stream, a lease's included, answers…
feat(stream): remove the lease stream route; a path that names no str…
docs: the device drawer's Screen tab replaces the lease's Screen tab (#…
test: the stream mount's listed route is the device route (#910)
test: a device drawer's tab lives in ?drawer= and row links never car…
test(e2e): streaming off is the only reason a leased device on that w…
test(e2e): the Screen tab shows the daemon's words for a 409 on a lea…
style(ui): format the screen panel stylesheet (#910)
fix(ui): read the workers list through the route query layer; the Scr…
feat(stream): stream any running device by worker and device id (#923)
feat(ui): watch a device's screen from the device drawer (#928)
feat(history): the worker's Events tab scrolls back through its histo…