From 830099b95bfd012cf6e3a414bdd34d603dcd4b95 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 13:07:10 +0200 Subject: [PATCH 01/16] test: an operator opens a device drawer and its Screen tab (#910) --- e2e/console/fixtures.ts | 7 +- e2e/console/screen.spec.ts | 346 +++++++++++++++++-------------------- 2 files changed, 160 insertions(+), 193 deletions(-) diff --git a/e2e/console/fixtures.ts b/e2e/console/fixtures.ts index d279368b6..84ecc15e8 100644 --- a/e2e/console/fixtures.ts +++ b/e2e/console/fixtures.ts @@ -435,7 +435,11 @@ export interface Fleet { * leases through the gateway or reads a card's capacity right away would see that gap. */ export async function startFleet( - workers: readonly { readonly label: string; readonly driverScript?: FakeDriverScript }[], + workers: readonly { + readonly label: string; + readonly driverScript?: FakeDriverScript; + readonly config?: Readonly>; + }[], ): Promise { const gateway = await startDaemon("gateway"); const started: RunningDaemon[] = []; @@ -444,6 +448,7 @@ export async function startFleet( started.push( await startDaemon("worker", { config: { + ...worker.config, gateway: { label: worker.label, token: gateway.tokens.worker, diff --git a/e2e/console/screen.spec.ts b/e2e/console/screen.spec.ts index 746514f0b..bc0a8f246 100644 --- a/e2e/console/screen.spec.ts +++ b/e2e/console/screen.spec.ts @@ -4,7 +4,15 @@ import { join } from "node:path"; import type { Page } from "@playwright/test"; -import { expect, type RunningDaemon, signIn, startDaemon, test } from "./fixtures.js"; +import { + disposeFleet, + expect, + type RunningDaemon, + signIn, + startDaemon, + startFleet, + test, +} from "./fixtures.js"; import { captureLog, writeCaptureProgram } from "./screen-capture.js"; // why: the capture program this lane writes is its witness as well as its picture source -- it records @@ -19,7 +27,6 @@ interface StreamingHost { readonly command: string; readonly dir: string; readonly host: RunningDaemon; - readonly lease: string; } async function streamingHost(): Promise { @@ -33,14 +40,7 @@ async function streamingHost(): Promise { }, driverScript: { ios: { ...IOS, screenCaptureCommand: command } }, }); - const granted = await host.cli([...LEASE, "--agent-id", "console-screen", "--detach"]); - expect(granted.code, granted.stderr).toBe(0); - return { - command, - dir, - host, - lease: (granted.json as { lease: { id: string } }).lease.id, - }; + return { command, dir, host }; } catch (error) { await rm(dir, { force: true, recursive: true }); await host?.dispose(); @@ -53,19 +53,58 @@ async function cleanup(started: StreamingHost): Promise { await rm(started.dir, { force: true, recursive: true }); } -async function openLeasePage(page: Page, host: RunningDaemon, lease: string): Promise { - await page.goto(new URL(`/leases/${encodeURIComponent(lease)}`, host.url).toString()); - await signIn(page, host.tokens.operator); - await page.getByRole("tab", { name: "Screen" }).click(); - await expect(page.getByRole("heading", { level: 3, name: "Screen" })).toBeVisible(); +interface Target { + readonly deviceId: string; + readonly origin: string; + readonly token: string; + readonly workerId: string; } -function showButton(page: Page) { - return page.getByRole("button", { name: /^(Show screen|Stop)$/ }); +async function workerIdOn(reader: RunningDaemon): Promise { + const listed = await reader.cli(["worker", "list", "--json"]); + expect(listed.code, listed.stderr).toBe(0); + const [view] = (listed.json as { workers: { id: string }[] }).workers; + return view?.id ?? ""; +} + +async function deviceIds(host: RunningDaemon): Promise { + const result = await host.cli(["status", "--json"]); + expect(result.code, result.stderr).toBe(0); + return (result.json as { devices: { id: string }[] }).devices.map((device) => device.id); +} + +// why: a lease then a release leaves one warm device that nobody holds, which is the device a viewer +// can watch without a lease. +async function warmDevice(host: RunningDaemon): Promise { + const granted = await host.cli([...LEASE, "--agent-id", "console-screen", "--detach"]); + expect(granted.code, granted.stderr).toBe(0); + const released = await host.cli(["release", "--all", "--yes"]); + expect(released.code, released.stderr).toBe(0); + let ready = ""; + await expect + .poll(async () => { + const listed = await host.cli(["status", "--json"]); + const [device] = (listed.json as { devices: { id: string; state: string }[] }).devices; + ready = device?.state === "ready" ? device.id : ""; + return ready; + }) + .not.toBe(""); + return ready; +} + +async function openDevice(page: Page, target: Target, search = "?tab=devices"): Promise { + const path = `/fleet/${encodeURIComponent(target.workerId)}/${encodeURIComponent(target.deviceId)}${search}`; + await page.goto(new URL(path, target.origin).toString()); + await signIn(page, target.token); + await expect(page.getByRole("dialog")).toBeVisible(); +} + +function drawer(page: Page) { + return page.getByRole("dialog"); } function screen(page: Page) { - return page.getByLabel("The leased device's screen"); + return page.getByLabel("The device's screen"); } // why: the mean colour of what the canvas holds, so "the picture changed" is a number rather than a @@ -91,190 +130,113 @@ async function averageColour(page: Page): Promise { return rgb.join(","); } -async function fact(page: Page, label: string): Promise { - return page - .getByRole("region", { exact: true, name: "Screen" }) - .locator(".facts > div") - .filter({ has: page.getByText(label, { exact: true }) }) - .first() - .getByRole("definition") - .innerText(); -} - -async function frames(page: Page): Promise { - const shown = await fact(page, "Frames"); - const parsed = Number.parseInt(shown, 10); - return Number.isNaN(parsed) ? 0 : parsed; -} - -async function streamCapability(host: RunningDaemon): Promise { - const listed = await host.cli(["worker", "list", "--json"]); - expect(listed.code, listed.stderr).toBe(0); - const views = (listed.json as { workers: { capabilities?: { name: string; state: string }[] }[] }) - .workers; - return views[0]?.capabilities?.find((entry) => entry.name === "stream")?.state; -} - async function captureLogLines(dir: string, word: string): Promise { const log = await captureLog(dir); return log.split("\n").filter((line) => line.startsWith(word)).length; } -test.describe("the Screen pane", () => { - test("shows nothing and starts no capture until asked", async ({ page }) => { - const started = await streamingHost(); - try { - await openLeasePage(page, started.host, started.lease); - await expect(screen(page)).toHaveCount(0); - await expect(page.getByText("No picture while this is off.")).toBeVisible(); - await expect.poll(() => fact(page, "Picture")).toBe("—"); - await expect.poll(() => fact(page, "State")).toBe("off"); - expect(await captureLogLines(started.dir, "opened")).toBe(0); - expect(await streamCapability(started.host)).toBe("available"); - } finally { - await cleanup(started); - } - }); - - test("Show screen starts the capture, paints it, and reports what it is showing", async ({ +test.describe("the device drawer's Screen tab", () => { + test("opens on Details with no capture, starts one and paints it on Screen, closes it on Details or closing the drawer, and keeps it when the device is leased", async ({ page, }) => { const started = await streamingHost(); + const fleetDir = await mkdtemp(join(tmpdir(), "simlock-screen-")); + let fleet: Awaited> | undefined; try { - await openLeasePage(page, started.host, started.lease); - await showButton(page).click(); - await expect(screen(page)).toBeVisible(); - await logReached(started.dir, "opened", 1); - - // why: these numbers are the capture program's own, so the pane sizing itself from them proves it - // read the meta frame rather than guessing a size from whatever arrived first. - await expect.poll(() => fact(page, "Picture"), { timeout: 10_000 }).toBe("120 × 240 mjpeg"); - await expect.poll(async () => (await frames(page)) > 0, { timeout: 10_000 }).toBe(true); - await expect.poll(() => fact(page, "State"), { timeout: 10_000 }).toBe("live"); - - // why: three different colours arrive in turn, so readings that keep differing prove frames are - // still arriving rather than one picture painted once and left on the canvas. - const seen = new Set(); - await expect - .poll( - async () => { - seen.add(await averageColour(page)); - return seen.size; - }, - { timeout: 10_000 }, - ) - .toBeGreaterThanOrEqual(2); - } finally { - await cleanup(started); - } - }); - - test("Stop stops the capture, and so does leaving the page", async ({ page }) => { - const started = await streamingHost(); - try { - await openLeasePage(page, started.host, started.lease); - await showButton(page).click(); - await logReached(started.dir, "opened", 1); - await expect.poll(async () => (await frames(page)) > 0, { timeout: 10_000 }).toBe(true); - - await showButton(page).click(); - await logReached(started.dir, "closed", 1); - await expect(screen(page)).toHaveCount(0); - await expect(showButton(page)).toHaveText("Show screen"); - - await showButton(page).click(); - await logReached(started.dir, "opened", 2); - await page.goto(new URL("/leases", started.host.url).toString()); - await logReached(started.dir, "closed", 2); - - // why: arriving back at the lease page must not resume a picture nobody asked for -- a third - // "opened" here is a capture running for a viewer that only opened a page. - await page.goto( - new URL(`/leases/${encodeURIComponent(started.lease)}`, started.host.url).toString(), - ); - await page.getByRole("tab", { name: "Screen" }).click(); - await expect(showButton(page)).toHaveText("Show screen"); - expect(await captureLogLines(started.dir, "opened")).toBe(2); - } finally { - await cleanup(started); - } - }); - - test("switching from Screen to Details keeps the capture running, and closing the drawer stops it", async ({ - page, - }) => { - const started = await streamingHost(); - try { - const ended: string[] = []; - const track = (request: { url(): string }) => { - if (request.url().includes("/v1/stream/leases/")) ended.push(request.url()); - }; - page.on("requestfinished", track); - page.on("requestfailed", track); - await openLeasePage(page, started.host, started.lease); - await showButton(page).click(); - await logReached(started.dir, "opened", 1); - await expect.poll(async () => (await frames(page)) > 0, { timeout: 10_000 }).toBe(true); - - await page.getByRole("tab", { name: "Details" }).click(); - await expect(page.getByRole("tab", { name: "Details" })).toHaveAttribute( - "aria-selected", - "true", - ); - await page.waitForTimeout(500); - expect(ended).toEqual([]); - expect(await captureLogLines(started.dir, "closed")).toBe(0); - - await page.keyboard.press("Escape"); - await logReached(started.dir, "closed", 1); - await expect.poll(() => ended.length).toBe(1); - } finally { - await cleanup(started); - } - }); - - test("a worker with streaming off disables Show screen and says why before any click", async ({ - page, - }) => { - const dir = await mkdtemp(join(tmpdir(), "simlock-screen-")); - let host: RunningDaemon | undefined; - try { - host = await startDaemon("worker", { driverScript: { ios: IOS } }); - const granted = await host.cli([...LEASE, "--agent-id", "console-screen", "--detach"]); - expect(granted.code, granted.stderr).toBe(0); - const lease = (granted.json as { lease: { id: string } }).lease.id; - await openLeasePage(page, host, lease); - await expect(showButton(page)).toBeDisabled(); - await expect(page.getByText(/^Streaming is off on /)).toBeVisible(); - expect(await streamCapability(host)).toBe("off"); - await expect(screen(page)).toHaveCount(0); - expect(await captureLog(dir)).toBe(""); - } finally { - await host?.dispose(); - await rm(dir, { force: true, recursive: true }); - } - }); - - test("a release takes the pane away and leaves no capture running", async ({ page }) => { - const started = await streamingHost(); - try { - await openLeasePage(page, started.host, started.lease); - await showButton(page).click(); - await logReached(started.dir, "opened", 1); - await expect.poll(async () => (await frames(page)) > 0, { timeout: 10_000 }).toBe(true); - - const released = await started.host.cli(["release", started.lease]); - expect(released.code, released.stderr).toBe(0); - - // why: a lease that is gone has no page to watch, so the pane goes with it and its abort stops the - // capture. What this proves is the promise an operator can see: leaving a lease behind never - // leaves a device being filmed. The guarantee that a lease ending stops a capture whose viewer is - // still connected is not observable from a browser -- it is pinned in - // `src/stream/captures.test.ts`, where a capture can be held open past the lease. - await logReached(started.dir, "closed", 1); - await expect(page.getByText("This lease is not held.")).toBeVisible(); - expect(await captureLogLines(started.dir, "opened")).toBe(1); + const gatewayCommand = await writeCaptureProgram(fleetDir); + fleet = await startFleet([ + { + config: { capabilities: { stream: { enabled: true, fps: 5 } } }, + driverScript: { ios: { ...IOS, screenCaptureCommand: gatewayCommand } }, + label: "worker-a", + }, + ]); + const worker = fleet.workers[0]; + if (worker === undefined) throw new Error("the fleet has no worker"); + const cases = [ + { dir: started.dir, host: started.host, reader: started.host }, + { dir: fleetDir, host: worker, reader: fleet.gateway }, + ]; + for (const { dir, host, reader } of cases) { + const workerId = await workerIdOn(reader); + const watched = await warmDevice(host); + const target = { + deviceId: watched, + origin: reader.url, + token: reader.tokens.operator, + workerId, + }; + const streams: string[] = []; + const track = (request: { url(): string }) => { + if (request.url().includes("/v1/stream/")) streams.push(request.url()); + }; + page.on("requestfinished", track); + page.on("requestfailed", track); + + await openDevice(page, target); + await expect(drawer(page).getByRole("tab", { name: "Details" })).toHaveAttribute( + "aria-selected", + "true", + ); + await expect(screen(page)).toHaveCount(0); + expect(await captureLogLines(dir, "opened")).toBe(0); + + await drawer(page).getByRole("tab", { name: "Screen" }).click(); + await expect(page).toHaveURL(/[?&]drawer=screen(&|$)/); + await expect(screen(page)).toBeVisible(); + await logReached(dir, "opened", 1); + const seen = new Set(); + await expect + .poll( + async () => { + seen.add(await averageColour(page)); + return seen.size; + }, + { timeout: 10_000 }, + ) + .toBeGreaterThanOrEqual(2); + + const leased = await host.cli([...LEASE, "--agent-id", "console-screen", "--detach"]); + expect(leased.code, leased.stderr).toBe(0); + await page.waitForTimeout(500); + expect(await captureLogLines(dir, "opened")).toBe(1); + expect(await captureLogLines(dir, "closed")).toBe(0); + expect(streams).toEqual([]); + + await drawer(page).getByRole("tab", { name: "Details" }).click(); + await logReached(dir, "closed", 1); + await expect(screen(page)).toHaveCount(0); + + await drawer(page).getByRole("tab", { name: "Screen" }).click(); + await logReached(dir, "opened", 2); + await page.keyboard.press("Escape"); + await logReached(dir, "closed", 2); + await expect(drawer(page)).toBeHidden(); + await expect(page).not.toHaveURL(/drawer=/); + + const second = await host.cli([...LEASE, "--agent-id", "console-screen-2", "--detach"]); + expect(second.code, second.stderr).toBe(0); + const other = (await deviceIds(host)).find((id) => id !== watched) ?? ""; + expect(other).not.toBe(""); + await page + .getByRole("region", { name: "Devices", exact: true }) + .getByRole("row") + .filter({ hasText: other }) + .getByRole("link") + .first() + .click(); + await expect(drawer(page).getByRole("tab", { name: "Details" })).toHaveAttribute( + "aria-selected", + "true", + ); + await page.waitForTimeout(500); + expect(await captureLogLines(dir, "opened")).toBe(2); + page.off("requestfinished", track); + page.off("requestfailed", track); + } } finally { + if (fleet !== undefined) await disposeFleet(fleet); + await rm(fleetDir, { force: true, recursive: true }); await cleanup(started); } }); From 94319f36ba2b0e6eb759c9409c73997743838dd5 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 13:12:26 +0200 Subject: [PATCH 02/16] feat(ui): a device's Screen tab starts its picture when opened and stops it when left (#910) --- e2e/console/fixtures.ts | 6 +- e2e/console/leases.spec.ts | 5 +- e2e/console/screen.spec.ts | 17 +++-- ui/src/ds/drawer.tsx | 14 +++- ui/src/model/worker-page.ts | 17 ++++- ui/src/screens/fleet/device-drawer.tsx | 30 +++++++-- ui/src/screens/fleet/screen-panel.tsx | 51 +++++++++++++++ ui/src/screens/leases/drawer.tsx | 13 ---- ui/src/screens/leases/leases.css | 16 ----- ui/src/screens/leases/leases.test.tsx | 89 -------------------------- ui/src/screens/leases/screen-panel.tsx | 74 --------------------- ui/src/state/screen.test.ts | 4 +- ui/src/state/screen.ts | 41 ++++++------ ui/src/state/workers.ts | 20 +++++- 14 files changed, 162 insertions(+), 235 deletions(-) create mode 100644 ui/src/screens/fleet/screen-panel.tsx delete mode 100644 ui/src/screens/leases/screen-panel.tsx diff --git a/e2e/console/fixtures.ts b/e2e/console/fixtures.ts index 84ecc15e8..e7d575a1a 100644 --- a/e2e/console/fixtures.ts +++ b/e2e/console/fixtures.ts @@ -440,8 +440,12 @@ export async function startFleet( readonly driverScript?: FakeDriverScript; readonly config?: Readonly>; }[], + options: { readonly gatewayConfig?: Readonly> } = {}, ): Promise { - const gateway = await startDaemon("gateway"); + const gateway = await startDaemon( + "gateway", + options.gatewayConfig === undefined ? {} : { config: options.gatewayConfig }, + ); const started: RunningDaemon[] = []; try { for (const worker of workers) { diff --git a/e2e/console/leases.spec.ts b/e2e/console/leases.spec.ts index caf8e1925..3fb3ac4ce 100644 --- a/e2e/console/leases.spec.ts +++ b/e2e/console/leases.spec.ts @@ -334,7 +334,7 @@ test.describe("the leases page", () => { await leaseRow(page, lease.id).click(); await expect(page.getByRole("dialog")).toBeVisible(); - for (const name of ["Screen", "Details", "Screen", "Details"]) { + for (const name of ["Events", "Details", "Events", "Details"]) { await page.getByRole("tab", { name }).click(); } await page.waitForTimeout(400); @@ -642,8 +642,7 @@ test.describe("the leases page", () => { await leaseRow(page, granted.id).click(); await expect(inspectorTitle(page, /ci-runner-3/)).toBeVisible(); await expect(fact(page, "Device", "UDID")).toHaveText(granted.udid); - await page.getByRole("tab", { name: "Screen" }).click(); - await expect(page.getByRole("button", { name: "Show screen" })).toBeVisible(); + await expect(page.getByRole("dialog").getByRole("tab")).toHaveText(["Details", "Events"]); expect(await hasHorizontalScroll(page)).toBe(false); } finally { await host.dispose(); diff --git a/e2e/console/screen.spec.ts b/e2e/console/screen.spec.ts index bc0a8f246..0b40543f7 100644 --- a/e2e/console/screen.spec.ts +++ b/e2e/console/screen.spec.ts @@ -144,13 +144,16 @@ test.describe("the device drawer's Screen tab", () => { let fleet: Awaited> | undefined; try { const gatewayCommand = await writeCaptureProgram(fleetDir); - fleet = await startFleet([ - { - config: { capabilities: { stream: { enabled: true, fps: 5 } } }, - driverScript: { ios: { ...IOS, screenCaptureCommand: gatewayCommand } }, - label: "worker-a", - }, - ]); + fleet = await startFleet( + [ + { + config: { capabilities: { stream: { enabled: true, fps: 5 } } }, + driverScript: { ios: { ...IOS, screenCaptureCommand: gatewayCommand } }, + label: "worker-a", + }, + ], + { gatewayConfig: { capabilities: { stream: { enabled: true } } } }, + ); const worker = fleet.workers[0]; if (worker === undefined) throw new Error("the fleet has no worker"); const cases = [ diff --git a/ui/src/ds/drawer.tsx b/ui/src/ds/drawer.tsx index ffd488a9b..bb7e42ade 100644 --- a/ui/src/ds/drawer.tsx +++ b/ui/src/ds/drawer.tsx @@ -63,12 +63,20 @@ export interface DrawerTab { // shows, so switching tabs never stops it. export function DrawerTabs(props: { readonly tabs: readonly DrawerTab[]; - readonly defaultValue: string; + readonly defaultValue?: string; + // why: a drawer whose tab lives in the address passes its value and hears the choice; the others + // keep theirs inside. + readonly value?: string; + readonly onValueChange?: (value: string) => void; readonly label: string; }) { - const { defaultValue, label, tabs } = props; + const { defaultValue, label, onValueChange, tabs, value } = props; return ( - + onValueChange?.(String(next))} + > {tabs.map((tab) => ( diff --git a/ui/src/model/worker-page.ts b/ui/src/model/worker-page.ts index e62fc7d75..f83a55d08 100644 --- a/ui/src/model/worker-page.ts +++ b/ui/src/model/worker-page.ts @@ -332,7 +332,7 @@ function deviceRow( platform: platformName(device.spec.platform), runtime: device.spec.osVersion, status: deviceStateStatus(device.state), - to: `${devicePath(worker.id, device.id)}${writeWorkerTab(search, "devices")}`, + to: `${devicePath(worker.id, device.id)}${writeWorkerTab(writeDrawerTab(search, "details"), "devices")}`, ...leaseFacts(device, worker, now, tokens, leaseIds), ...defined({ udid: device.physical ? device.driverDeviceId : undefined, @@ -626,6 +626,21 @@ export function writeWorkerTab(search: string, tab: WorkerTab): string { return `?${params.toString()}`; } +export type DeviceDrawerTab = "details" | "screen"; + +// why: the device drawer's tab is `?drawer=screen`, or no key for Details, so any other value reads +// as Details and the next device opens on Details. +export function readDrawerTab(search: string): DeviceDrawerTab { + return new URLSearchParams(search).get("drawer") === "screen" ? "screen" : "details"; +} + +export function writeDrawerTab(search: string, tab: DeviceDrawerTab): string { + const params = new URLSearchParams(search); + if (tab === "screen") params.set("drawer", "screen"); + else params.delete("drawer"); + return params.size === 0 ? "" : `?${params.toString()}`; +} + export interface WorkerBanner { readonly text: string; readonly tone: "warn" | "error"; diff --git a/ui/src/screens/fleet/device-drawer.tsx b/ui/src/screens/fleet/device-drawer.tsx index b40a64638..db2c5dc22 100644 --- a/ui/src/screens/fleet/device-drawer.tsx +++ b/ui/src/screens/fleet/device-drawer.tsx @@ -1,15 +1,21 @@ import "./fleet.css"; -import { ActionBar, Drawer, Facts, Link, Loaded, Status } from "../../ds"; +import { ActionBar, Drawer, DrawerTabs, Facts, Link, Loaded, Status } from "../../ds"; import { useLeaseDrawerMenu } from "../../state/leases"; -import { type DeviceRow, useDeviceDrawer, useLeaseUdid } from "../../state/workers"; +import { + type DeviceDrawerTab, + type DeviceRow, + useDeviceDrawer, + useLeaseUdid, +} from "../../state/workers"; +import { ScreenPanel } from "./screen-panel"; // why: the drawer opens at once, titled "Device" until the worker's page arrives, and says so when // the worker no longer lists the device, so an address that outlives its device still lands // somewhere. export function DeviceDrawer(props: { readonly workerId: string; readonly deviceId: string }) { const { deviceId, workerId } = props; - const { close, view } = useDeviceDrawer(workerId, deviceId); + const { chooseTab, close, tab, view } = useDeviceDrawer(workerId, deviceId); const device = view.kind === "ready" ? view.data : undefined; return ( This device is gone.

) : ( - {(row) => (row === undefined ? null : )} + {(row) => + row === undefined ? null : ( + chooseTab(next as DeviceDrawerTab)} + tabs={[ + { content: , label: "Details", value: "details" }, + { + content: , + label: "Screen", + value: "screen", + }, + ]} + /> + ) + } )}
diff --git a/ui/src/screens/fleet/screen-panel.tsx b/ui/src/screens/fleet/screen-panel.tsx new file mode 100644 index 000000000..48f6a1022 --- /dev/null +++ b/ui/src/screens/fleet/screen-panel.tsx @@ -0,0 +1,51 @@ +import { useId } from "react"; + +import { Facts, Refusal, Status } from "../../ds"; +import { type Screen, useScreen } from "../../state/screen"; + +// why: the hook lives in the panel's own component so that leaving the tab, closing the drawer and +// leaving the device all end the same capture, and a panel that is not drawn never starts one. +export function ScreenPanel(props: { readonly workerId: string; readonly deviceId: string }) { + const screen = useScreen(props.workerId, props.deviceId); + return ; +} + +export function ScreenView(props: { readonly screen: Screen }) { + const { screen } = props; + const { live, note, readout, waiting } = screen; + const title = useId(); + return ( +
+

+ Screen +

+
+ {live ? ( + + ) : ( +

No picture while this is off.

+ )} + {waiting === undefined ? null :

{waiting}

} +
+ {readout.state.word}, + }, + ...readout.facts.map((fact) => ({ + label: fact.label, + value: {fact.value}, + })), + ]} + /> + {note === undefined ? null : } +
+ ); +} diff --git a/ui/src/screens/leases/drawer.tsx b/ui/src/screens/leases/drawer.tsx index 28dd60adf..a25fd40d0 100644 --- a/ui/src/screens/leases/drawer.tsx +++ b/ui/src/screens/leases/drawer.tsx @@ -8,7 +8,6 @@ import { useLeaseEvents, useLeaseInspection, } from "../../state/leases"; -import { ScreenPanel } from "./screen-panel"; // why: the drawer opens at once, titled "Lease" until the details arrive, and says so when // the lease is no longer held, so an address that outlives its lease still lands somewhere. @@ -43,18 +42,6 @@ export function LeaseDrawer(props: { label: "Details", value: "details", }, - { - content: ( - - ), - keepMounted: true, - label: "Screen", - value: "screen", - }, { content: , label: "Events", value: "events" }, ]} /> diff --git a/ui/src/screens/leases/leases.css b/ui/src/screens/leases/leases.css index db2f6f9f5..cdadfe99a 100644 --- a/ui/src/screens/leases/leases.css +++ b/ui/src/screens/leases/leases.css @@ -101,22 +101,6 @@ border: 1px solid var(--border); } -.screen-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--space-3); -} - -.screen-head .button:disabled { - cursor: not-allowed; -} - -.screen-reason { - margin: 0; - font-size: var(--text-sm); -} - /* The picture sits in a phone-shaped frame; the canvas keeps the ratio it was painted at inside * it, so a landscape picture letterboxes rather than stretches. */ .phone { diff --git a/ui/src/screens/leases/leases.test.tsx b/ui/src/screens/leases/leases.test.tsx index 4d9f485a1..6f2b96ee0 100644 --- a/ui/src/screens/leases/leases.test.tsx +++ b/ui/src/screens/leases/leases.test.tsx @@ -2,12 +2,10 @@ import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; import type { LeaseInspection, LeaseRowView } from "../../state/leases"; -import type { Screen } from "../../state/screen"; import type { Paging } from "../../ds"; import { inConsole } from "../../state/test-support"; import { LeaseDetail, LeaseEventList, LeaseTitle } from "./drawer"; import { LeaseTable } from "./index"; -import { ScreenView } from "./screen-panel"; function text(html: string): string { return html @@ -58,27 +56,6 @@ function inspection(overrides: Partial = {}): LeaseInspection { }; } -const IDLE: Screen = { - canvas: { current: null }, - height: 0, - live: false, - note: undefined, - readout: { - facts: [ - { label: "Picture", value: "—" }, - { label: "Frames", value: "—" }, - { label: "Rate", value: "—" }, - { label: "Bandwidth", value: "—" }, - { label: "Dropped", value: "—" }, - ], - state: { tone: "idle", word: "off" }, - }, - start: () => undefined, - stop: () => undefined, - waiting: undefined, - width: 0, -}; - const PAGING: Paging = { go: () => undefined, last: 1, @@ -163,72 +140,6 @@ describe("a lease's drawer", () => { }); }); -describe("the screen panel before anything is asked for", () => { - it("offers the one control a screen has and says there is no picture while it is off", () => { - const html = renderToStaticMarkup(); - - expect(html).toContain("Show screen"); - expect(html).not.toContain("disabled"); - expect(html).toContain("No picture while this is off."); - expect(html).not.toContain(" { - const html = renderToStaticMarkup(); - const words = [...html.matchAll(/
([^<]*)<\/dt>]*>(.*?)<\/dd>/g)].map( - ([, label = "", value = ""]) => `${label}=${text(value)}`, - ); - - expect(words).toEqual([ - "State=off", - "Picture=—", - "Frames=—", - "Rate=—", - "Bandwidth=—", - "Dropped=—", - ]); - }); - - it("disables Show screen and says why before any click when the worker has streaming off", () => { - const html = renderToStaticMarkup( - , - ); - - expect(html).toMatch(/]*disabled=""[^>]*>Show screen<\/button>/); - expect(text(html)).toContain("Streaming is off on mac-mini-1."); - }); -}); - -describe("the screen panel while a picture is asked for", () => { - const live: Screen = { - ...IDLE, - height: 240, - live: true, - readout: { ...IDLE.readout, state: { tone: "ok", word: "live" } }, - waiting: "Waiting for the first frame.", - width: 120, - }; - - it("offers Stop, mounts the canvas, and says it is waiting for the first frame", () => { - const html = renderToStaticMarkup(); - - expect(html).toContain(">Stop<"); - expect(html).toContain('aria-label="The leased device's screen"'); - expect(html).toContain("Waiting for the first frame."); - expect(html).not.toContain("No picture while this is off."); - }); - - it("shows a refusal as an alert, and no canvas, once the stream has stopped", () => { - const html = renderToStaticMarkup( - , - ); - - expect(html).toContain('role="alert"'); - expect(html).toContain("Unknown lease: lse_1"); - expect(html).not.toContain(" { it("says there are no events for the lease in the last hour, or lists them", () => { expect(text(renderToStaticMarkup())).toBe( diff --git a/ui/src/screens/leases/screen-panel.tsx b/ui/src/screens/leases/screen-panel.tsx deleted file mode 100644 index e531f9684..000000000 --- a/ui/src/screens/leases/screen-panel.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { useId } from "react"; - -import { Button, Facts, Refusal, Status } from "../../ds"; -import { type Screen, useScreen } from "../../state/screen"; - -// why: the hook lives in the panel's own component so that closing the page, leaving the lease and -// pressing Stop all end the same capture, and a panel that is not drawn never starts one. -export function ScreenPanel(props: { - readonly leaseId: string; - readonly offered: boolean; - readonly reason: string | undefined; -}) { - const screen = useScreen(props.leaseId); - return ; -} - -export function ScreenView(props: { - readonly screen: Screen; - readonly offered: boolean; - readonly reason: string | undefined; -}) { - const { offered, reason, screen } = props; - const { live, note, readout, waiting } = screen; - const title = useId(); - const why = useId(); - return ( -
-
-

- Screen -

- -
- {offered || reason === undefined ? null : ( -

- {reason} -

- )} -
- {live ? ( - - ) : ( -

No picture while this is off.

- )} - {waiting === undefined ? null :

{waiting}

} -
- {readout.state.word}, - }, - ...readout.facts.map((fact) => ({ - label: fact.label, - value: {fact.value}, - })), - ]} - /> - {note === undefined ? null : } -
- ); -} diff --git a/ui/src/state/screen.test.ts b/ui/src/state/screen.test.ts index 8a48f47ad..e2a59df6d 100644 --- a/ui/src/state/screen.test.ts +++ b/ui/src/state/screen.test.ts @@ -23,10 +23,10 @@ describe("what a refused stream says", () => { }); it("keeps the daemon's own reason when it named one, and says what else to check", () => { - const refused = new ApiError(404, undefined, "Unknown lease: lse_1"); + const refused = new ApiError(404, undefined, "Unknown device: dev_1"); expect(refusalOf(refused)).toBe( - "Unknown lease: lse_1 A stream is only offered where capabilities.stream.enabled is on, and only for a lease that is still held.", + "Unknown device: dev_1 A stream is only offered where capabilities.stream.enabled is on.", ); }); diff --git a/ui/src/state/screen.ts b/ui/src/state/screen.ts index 7ba5c05a1..b58f820fb 100644 --- a/ui/src/state/screen.ts +++ b/ui/src/state/screen.ts @@ -1,10 +1,10 @@ /** - * why: one button turns a device's picture on and off and nothing else controls it, because this - * surface has no command of its own to send — the request *is* the on command and aborting it *is* the - * off one. So the pane's lifecycle is the capture's lifecycle: closing the page, navigating away, - * signing out and pressing Stop are four ways of doing the same single thing, and a capture cannot - * outlive the pane that asked for it. That is also why nothing here polls, retries, or holds a stream - * open across a navigation. + * why: nothing controls a device's picture but the panel that shows it, because this surface has no + * command of its own to send — the request *is* the on command and aborting it *is* the off one. So + * the pane's lifecycle is the capture's lifecycle: leaving the tab, closing the drawer, navigating + * away and signing out are four ways of doing the same single thing, and a capture cannot outlive the + * pane that asked for it. That is also why nothing here polls, retries, or holds a stream open across + * a navigation. */ import { type RefObject, useCallback, useEffect, useRef, useState } from "react"; @@ -40,11 +40,9 @@ export interface Screen { readonly waiting: string | undefined; readonly readout: Readout; readonly note: string | undefined; - readonly start: () => void; - readonly stop: () => void; } -export function useScreen(leaseId: string): Screen { +export function useScreen(workerId: string, deviceId: string): Screen { const api = useApi(); const canvas = useRef(null); const controller = useRef(undefined); @@ -61,9 +59,6 @@ export function useScreen(leaseId: string): Screen { // why: leaving the page stops the capture with the same abort the Stop button performs — a page that // was navigated away from must not leave a device being filmed for a viewer that is no longer there. - // Navigating to another lease is that same event, and asks for the other lease's picture instead. - useEffect(() => stop, [leaseId, stop]); - const start = useCallback(() => { const abort = new AbortController(); controller.current = abort; @@ -71,7 +66,7 @@ export function useScreen(leaseId: string): Screen { setMeta(undefined); setStats(EMPTY); setPhase("starting"); - void capture(api, leaseId, abort.signal, { + void capture(api, workerId, deviceId, abort.signal, { canvas, ended: (reason) => { setNote(reason); @@ -81,9 +76,16 @@ export function useScreen(leaseId: string): Screen { setMeta, setStats, }); - }, [api, leaseId]); + }, [api, workerId, deviceId]); + + // why: the picture starts when the panel is drawn and stops when it is not, so leaving the tab, + // closing the drawer and navigating away are one event and a capture cannot outlive its viewer. + useEffect(() => { + start(); + return stop; + }, [start, stop]); - return { canvas, start, stop, ...screenView({ meta, note, phase, stats }) }; + return { canvas, ...screenView({ meta, note, phase, stats }) }; } /** @@ -97,10 +99,10 @@ export function refusalOf(error: unknown): string { if (!(error instanceof ApiError)) { return error instanceof Error ? error.message : String(error); } - // why: an unknown route and an unknown lease are the same answer, because a daemon with + // why: an unknown route and an unknown device are the same answer, because a daemon with // `capabilities.stream.enabled` off does not mount the route at all and answers like any path it has not heard of. if (error.status === 404) { - return `${error.message} A stream is only offered where capabilities.stream.enabled is on, and only for a lease that is still held.`; + return `${error.message} A stream is only offered where capabilities.stream.enabled is on.`; } if (error.status === 415) { return "This device cannot be streamed."; @@ -120,13 +122,14 @@ interface CaptureSinks { // and telling them the stream they stopped has stopped is noise. async function capture( api: ReturnType, - leaseId: string, + workerId: string, + deviceId: string, signal: AbortSignal, sinks: CaptureSinks, ): Promise { try { const { body } = await api.stream( - `/v1/stream/leases/${encodeURIComponent(leaseId)}`, + `/v1/stream/workers/${encodeURIComponent(workerId)}/devices/${encodeURIComponent(deviceId)}`, signal, STREAM_HEAD_TIMEOUT_MS, ); diff --git a/ui/src/state/workers.ts b/ui/src/state/workers.ts index 127748f21..6e99fdaeb 100644 --- a/ui/src/state/workers.ts +++ b/ui/src/state/workers.ts @@ -7,11 +7,14 @@ import { listSearch, readQuery, TABLE_SIZES, writeQuery } from "../model/paging" import { type FleetAddress, fleetAddress } from "../model/paths"; import { filterWorkers, + readDrawerTab, readFilter, searchWorkers, upMeta, type WorkerFilter, + writeDrawerTab, writeFilter, + type DeviceDrawerTab, type DeviceRow, type WorkerPage, type WorkerRow, @@ -33,6 +36,7 @@ import { navigate, setSearch, usePath, useSearch } from "./router"; export type { EventRow } from "../model/event-list"; export type { CapabilityRow, + DeviceDrawerTab, DeviceRow, FigureCard, InstallRow, @@ -164,12 +168,22 @@ export function useWorkerPage(id: string): LoadView export function useDeviceDrawer( workerId: string, deviceId: string, -): { readonly view: LoadView; readonly close: () => void } { +): { + readonly view: LoadView; + readonly close: () => void; + readonly tab: DeviceDrawerTab; + readonly chooseTab: (tab: DeviceDrawerTab) => void; +} { const page = useWorkerPage(workerId); const search = useSearch(); return { - // why: closing replaces the drawer's entry with the worker's own page, tab and paging kept. - close: () => navigate(`${workerPath(workerId)}${search}`, { replace: true }), + // why: each choice replaces the entry rather than adding one, as the worker page's tabs do. + chooseTab: (tab) => setSearch(writeDrawerTab(search, tab), { replace: true }), + // why: closing replaces the drawer's entry with the worker's own page, tab and paging kept; the + // drawer's own tab belongs to one open drawer, so it goes. + close: () => + navigate(`${workerPath(workerId)}${writeDrawerTab(search, "details")}`, { replace: true }), + tab: readDrawerTab(search), view: mapView(page, (shown) => shown?.overview.devices?.find((row) => row.id === deviceId)), }; } From 0b75e358dafebe3202d89fac9db0cf3b97fde3b8 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 13:14:00 +0200 Subject: [PATCH 03/16] test: the Screen tab of a device that is not running shows each state's sentence (#910) --- ui/src/model/screen.test.ts | 36 +++++++++++++++++++++++++++++++++++- ui/src/model/screen.ts | 13 +++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/ui/src/model/screen.test.ts b/ui/src/model/screen.test.ts index ed6785b5f..002f3c285 100644 --- a/ui/src/model/screen.test.ts +++ b/ui/src/model/screen.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { readout, type ScreenPhase, screenView } from "./screen"; +import { deviceScreen, readout, type ScreenPhase, screenView } from "./screen"; const NONE = { dropped: 0, framesPerSecond: 0, kilobitsPerSecond: 0, painted: 0 }; const META = { codec: "mjpeg", height: 240, width: 120 }; @@ -82,3 +82,37 @@ describe("the screen pane", () => { expect(word("live", "an old reason", undefined)).toBe("live"); }); }); + +describe("what a device's Screen tab shows", () => { + const offer = { offered: true } as const; + const sentence = (text: string) => ({ kind: "sentence", text }); + + it("shows the picture of a ready or leased device, the state's sentence for any other, and the daemon's message for a refusal the row has not caught up with", () => { + const states: readonly (readonly [ + string, + ReturnType | { kind: "picture" }, + ])[] = [ + ["ready", { kind: "picture" }], + ["leased", { kind: "picture" }], + ["shutdown", sentence("The device is shut down. Its screen shows while it runs.")], + ["provisioning", sentence("The device is starting. Its screen shows once it is ready.")], + ["reclaiming", sentence("The device is being reset. Its screen shows once it is ready.")], + ["quarantined", sentence("The device is quarantined. It has no screen to show.")], + ["absent", sentence("The device is not connected. Its screen shows once it is back.")], + ["deleted", sentence("The device is deleted. It has no screen to show.")], + ["hibernating", sentence("The device is hibernating. It has no screen to show.")], + ]; + + for (const [state, expected] of states) { + expect(deviceScreen({ conflict: undefined, offer, state }), state).toEqual(expected); + } + + const conflict = "The device is shutdown; only a ready or leased device streams"; + + expect(deviceScreen({ conflict, offer, state: "ready" })).toEqual(sentence(conflict)); + expect(deviceScreen({ conflict, offer, state: "leased" })).toEqual(sentence(conflict)); + expect(deviceScreen({ conflict, offer, state: "shutdown" })).toEqual( + sentence("The device is shut down. Its screen shows while it runs."), + ); + }); +}); diff --git a/ui/src/model/screen.ts b/ui/src/model/screen.ts index 903f48a95..bc15369a5 100644 --- a/ui/src/model/screen.ts +++ b/ui/src/model/screen.ts @@ -1,3 +1,4 @@ +import type { StreamOffer } from "./leases"; import type { Toned } from "./tones"; export type ScreenPhase = "idle" | "starting" | "live" | "stopped"; @@ -86,3 +87,15 @@ export function screenView(input: { width: meta?.width ?? 0, }; } + +export type DeviceScreen = + | { readonly kind: "picture" } + | { readonly kind: "sentence"; readonly text: string }; + +export function deviceScreen(_input: { + readonly state: string; + readonly offer: StreamOffer; + readonly conflict: string | undefined; +}): DeviceScreen { + return { kind: "picture" }; +} From 066c41ba75fe760a3d23c7c5e917a13bb7810d96 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 13:15:51 +0200 Subject: [PATCH 04/16] feat(ui): the Screen tab says why a device that is not running has no picture (#910) --- ui/src/model/leases.test.ts | 42 -------------------- ui/src/model/leases.ts | 22 ----------- ui/src/model/screen.test.ts | 32 +++++++++++++++- ui/src/model/screen.ts | 53 ++++++++++++++++++++++++-- ui/src/screens/fleet/device-drawer.tsx | 11 +++++- ui/src/screens/fleet/screen-panel.tsx | 18 +++++++-- ui/src/screens/leases/leases.test.tsx | 1 - ui/src/state/screen.ts | 51 ++++++++++++++++++++++--- ui/src/state/workers.ts | 4 ++ 9 files changed, 153 insertions(+), 81 deletions(-) diff --git a/ui/src/model/leases.test.ts b/ui/src/model/leases.test.ts index 94812a411..b526d0df7 100644 --- a/ui/src/model/leases.test.ts +++ b/ui/src/model/leases.test.ts @@ -11,7 +11,6 @@ import { leaseRows, leasesOnWorker, searchLeaseRows, - streamOffer, type TokenRecord, } from "./leases"; import type { ConsoleEvent } from "./events"; @@ -209,37 +208,6 @@ describe("a worker's leases", () => { }); }); -describe("whether a worker offers the live screen", () => { - it("offers it when the worker's stream capability is available", () => { - expect(streamOffer(host())).toEqual({ offered: true }); - }); - - it("says streaming is off on the worker when the capability is off or not reported", () => { - const off = host({ capabilities: [{ name: "stream", state: "off" }] }); - const silent = host({ capabilities: [] }); - const unreported = host({ capabilities: undefined }); - - expect(streamOffer(off)).toEqual({ offered: false, reason: "Streaming is off on mac-mini-1." }); - expect(streamOffer(silent).offered).toBe(false); - expect(streamOffer(unreported).offered).toBe(false); - }); - - it("gives the worker's reason when the capability is unavailable", () => { - const worker = host({ - capabilities: [{ name: "stream", reason: "ffmpeg is missing", state: "unavailable" }], - }); - - expect(streamOffer(worker)).toEqual({ - offered: false, - reason: "Streaming is unavailable on mac-mini-1. ffmpeg is missing", - }); - }); - - it("leaves it to the daemon's answer when the lease's worker is not known", () => { - expect(streamOffer(undefined)).toEqual({ offered: true }); - }); -}); - describe("a lease's inspection", () => { const inspect = ( overrides: { @@ -350,16 +318,6 @@ describe("a lease's inspection", () => { inspect({ lease: details({ workerId: "wrk_gone" }), record: undefined, workers: [] }).worker, ).toEqual({ name: "wrk_gone" }); }); - - it("carries whether the lease's worker offers the live screen", () => { - const off = host({ capabilities: [{ name: "stream", state: "off" }] }); - - expect(inspect({ workers: [off] }).stream).toEqual({ - offered: false, - reason: "Streaming is off on mac-mini-1.", - }); - expect(inspect().stream.offered).toBe(true); - }); }); describe("a lease's holder on a worker a gateway feeds", () => { diff --git a/ui/src/model/leases.ts b/ui/src/model/leases.ts index 72c63a426..e462f9d04 100644 --- a/ui/src/model/leases.ts +++ b/ui/src/model/leases.ts @@ -220,26 +220,6 @@ export function searchLeaseRows( ); } -export interface StreamOffer { - readonly offered: boolean; - readonly reason?: string; -} - -// why: a worker answers a stream request with a 404 when its `stream` capability is not on, so the -// console says so before the click; a worker the console cannot see is left to the daemon's own -// answer. -export function streamOffer(worker: WorkerView | undefined): StreamOffer { - if (worker === undefined) return { offered: true }; - const entry = worker.capabilities?.find((capability) => capability.name === "stream"); - if (entry?.state === "available") return { offered: true }; - const name = workerName(worker); - if (entry?.state === "unavailable") { - const reason = `Streaming is unavailable on ${name}. ${entry.reason ?? ""}`; - return { offered: false, reason: reason.trim() }; - } - return { offered: false, reason: `Streaming is off on ${name}.` }; -} - export interface LeaseInspection { readonly id: string; readonly title: string; @@ -262,7 +242,6 @@ export interface LeaseInspection { readonly holder?: Holder; readonly ownerId?: string; readonly requestId?: string; - readonly stream: StreamOffer; } // why: `record` is the lease's entry in `GET /v1/leases`, which alone says who holds it and when it @@ -288,7 +267,6 @@ export function leaseInspection(input: { id: lease.id, model: lease.device, remaining: formatDuration(deadline - now), - stream: streamOffer(worker), ...recordFacts(record, tokens, deadline, now, lease.id), ...defined({ requestId: lease.requestId, diff --git a/ui/src/model/screen.test.ts b/ui/src/model/screen.test.ts index 002f3c285..5ec703357 100644 --- a/ui/src/model/screen.test.ts +++ b/ui/src/model/screen.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; -import { deviceScreen, readout, type ScreenPhase, screenView } from "./screen"; +import { deviceScreen, readout, type ScreenPhase, screenView, streamOffer } from "./screen"; +import type { WorkerView } from "./workers"; const NONE = { dropped: 0, framesPerSecond: 0, kilobitsPerSecond: 0, painted: 0 }; const META = { codec: "mjpeg", height: 240, width: 120 }; @@ -116,3 +117,32 @@ describe("what a device's Screen tab shows", () => { ); }); }); + +describe("whether a worker offers the live screen", () => { + const worker = (capabilities: WorkerView["capabilities"]): WorkerView => + ({ capabilities, id: "wrk_1", label: "mac-mini-1" }) as WorkerView; + + it("offers it when the stream capability is available or the worker is not known, and says why not when it is off, unreported or unavailable", () => { + expect(streamOffer(worker([{ name: "stream", state: "available" }]))).toEqual({ + offered: true, + }); + expect(streamOffer(undefined)).toEqual({ offered: true }); + const silent: WorkerView["capabilities"][] = [ + [{ name: "stream", state: "off" }], + [], + undefined, + ]; + for (const capabilities of silent) { + expect(streamOffer(worker(capabilities))).toEqual({ + offered: false, + reason: "Streaming is off on mac-mini-1.", + }); + } + expect( + streamOffer(worker([{ name: "stream", reason: "ffmpeg is missing", state: "unavailable" }])), + ).toEqual({ + offered: false, + reason: "Streaming is unavailable on mac-mini-1. ffmpeg is missing", + }); + }); +}); diff --git a/ui/src/model/screen.ts b/ui/src/model/screen.ts index bc15369a5..fe19401a0 100644 --- a/ui/src/model/screen.ts +++ b/ui/src/model/screen.ts @@ -1,4 +1,4 @@ -import type { StreamOffer } from "./leases"; +import { type WorkerView, workerName } from "./workers"; import type { Toned } from "./tones"; export type ScreenPhase = "idle" | "starting" | "live" | "stopped"; @@ -88,14 +88,61 @@ export function screenView(input: { }; } +export interface StreamOffer { + readonly offered: boolean; + readonly reason?: string; +} + +// why: a worker answers a stream request with a 404 when its `stream` capability is not on, so the +// console says so before it asks; a worker the console cannot see is left to the daemon's own +// answer. +export function streamOffer(worker: WorkerView | undefined): StreamOffer { + if (worker === undefined) return { offered: true }; + const entry = worker.capabilities?.find((capability) => capability.name === "stream"); + if (entry?.state === "available") return { offered: true }; + const name = workerName(worker); + if (entry?.state === "unavailable") { + const reason = `Streaming is unavailable on ${name}. ${entry.reason ?? ""}`; + return { offered: false, reason: reason.trim() }; + } + return { offered: false, reason: `Streaming is off on ${name}.` }; +} + +// why: the daemon streams a device that is ready or leased, and the console asks for a picture only +// then; a change from one to the other is no reason to ask again. +const STREAMING_STATES: ReadonlySet = new Set(["ready", "leased"]); + +const WAITING_SENTENCES: ReadonlyMap = new Map([ + ["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."], +]); + +export function streamsDevice(state: string, offer: StreamOffer): boolean { + return offer.offered && STREAMING_STATES.has(state); +} + export type DeviceScreen = | { readonly kind: "picture" } | { readonly kind: "sentence"; readonly text: string }; -export function deviceScreen(_input: { +// why: `conflict` is the daemon's 409 message for a device the row still calls ready or leased: the +// row is behind, and the daemon's words stand until it catches up. A state word this console does +// not know is shown as itself, because wire input is a claim. +export function deviceScreen(input: { readonly state: string; readonly offer: StreamOffer; readonly conflict: string | undefined; }): DeviceScreen { - return { kind: "picture" }; + const { conflict, offer, state } = input; + if (!offer.offered) return { kind: "sentence", text: offer.reason ?? "" }; + if (STREAMING_STATES.has(state)) { + return conflict === undefined ? { kind: "picture" } : { kind: "sentence", text: conflict }; + } + return { + kind: "sentence", + text: WAITING_SENTENCES.get(state) ?? `The device is ${state}. It has no screen to show.`, + }; } diff --git a/ui/src/screens/fleet/device-drawer.tsx b/ui/src/screens/fleet/device-drawer.tsx index db2c5dc22..66495c899 100644 --- a/ui/src/screens/fleet/device-drawer.tsx +++ b/ui/src/screens/fleet/device-drawer.tsx @@ -15,7 +15,7 @@ import { ScreenPanel } from "./screen-panel"; // somewhere. export function DeviceDrawer(props: { readonly workerId: string; readonly deviceId: string }) { const { deviceId, workerId } = props; - const { chooseTab, close, tab, view } = useDeviceDrawer(workerId, deviceId); + const { chooseTab, close, stream, tab, view } = useDeviceDrawer(workerId, deviceId); const device = view.kind === "ready" ? view.data : undefined; return ( , label: "Details", value: "details" }, { - content: , + content: ( + + ), label: "Screen", value: "screen", }, diff --git a/ui/src/screens/fleet/screen-panel.tsx b/ui/src/screens/fleet/screen-panel.tsx index 48f6a1022..c1ddf0313 100644 --- a/ui/src/screens/fleet/screen-panel.tsx +++ b/ui/src/screens/fleet/screen-panel.tsx @@ -1,13 +1,23 @@ import { useId } from "react"; import { Facts, Refusal, Status } from "../../ds"; -import { type Screen, useScreen } from "../../state/screen"; +import { type Screen, type StreamOffer, useDeviceScreen } from "../../state/screen"; // why: the hook lives in the panel's own component so that leaving the tab, closing the drawer and // leaving the device all end the same capture, and a panel that is not drawn never starts one. -export function ScreenPanel(props: { readonly workerId: string; readonly deviceId: string }) { - const screen = useScreen(props.workerId, props.deviceId); - return ; +export function ScreenPanel(props: { + readonly workerId: string; + readonly deviceId: string; + readonly state: string; + readonly offer: StreamOffer; +}) { + const { deviceId, offer, state, workerId } = props; + const shown = useDeviceScreen(workerId, deviceId, state, offer); + return shown.kind === "picture" ? ( + + ) : ( +

{shown.text}

+ ); } export function ScreenView(props: { readonly screen: Screen }) { diff --git a/ui/src/screens/leases/leases.test.tsx b/ui/src/screens/leases/leases.test.tsx index 6f2b96ee0..892bec271 100644 --- a/ui/src/screens/leases/leases.test.tsx +++ b/ui/src/screens/leases/leases.test.tsx @@ -49,7 +49,6 @@ function inspection(overrides: Partial = {}): LeaseInspection { renewed: "30 s ago", requestId: "req_7d1a", runtime: "18.4", - stream: { offered: true }, title: "ci-runner-3", worker: { name: "mac-mini-1", to: "/fleet/wrk_host" }, ...overrides, diff --git a/ui/src/state/screen.ts b/ui/src/state/screen.ts index b58f820fb..e76f9ba22 100644 --- a/ui/src/state/screen.ts +++ b/ui/src/state/screen.ts @@ -10,10 +10,17 @@ import { type RefObject, useCallback, useEffect, useRef, useState } from "react" import { ApiError } from "../data/api"; import { readStream, type StreamMeta, type StreamStats } from "../data/stream"; -import { type Readout, type ScreenPhase, screenView } from "../model/screen"; +import { + deviceScreen, + type Readout, + type ScreenPhase, + screenView, + type StreamOffer, + streamsDevice, +} from "../model/screen"; import { useApi } from "./services"; -export type { Readout } from "../model/screen"; +export type { Readout, StreamOffer } from "../model/screen"; const EMPTY: StreamStats = { dropped: 0, @@ -40,9 +47,29 @@ export interface Screen { readonly waiting: string | undefined; readonly readout: Readout; readonly note: string | undefined; + // why: the daemon's 409 message, kept apart from `note` because the panel words it with the device's + // state and the row may be about to say the same. + readonly conflict: string | undefined; } -export function useScreen(workerId: string, deviceId: string): Screen { +export type DeviceScreenView = + | { readonly kind: "picture"; readonly screen: Screen } + | { readonly kind: "sentence"; readonly text: string }; + +// why: the tab shows the picture only for a device the daemon streams, and says in words why it +// shows none for any other, so the decision sits here and the panel only draws. +export function useDeviceScreen( + workerId: string, + deviceId: string, + state: string, + offer: StreamOffer, +): DeviceScreenView { + const screen = useScreen(workerId, deviceId, streamsDevice(state, offer)); + const shown = deviceScreen({ conflict: screen.conflict, offer, state }); + return shown.kind === "picture" ? { kind: "picture", screen } : shown; +} + +function useScreen(workerId: string, deviceId: string, enabled: boolean): Screen { const api = useApi(); const canvas = useRef(null); const controller = useRef(undefined); @@ -50,6 +77,7 @@ export function useScreen(workerId: string, deviceId: string): Screen { const [meta, setMeta] = useState(undefined); const [stats, setStats] = useState(EMPTY); const [note, setNote] = useState(undefined); + const [conflict, setConflict] = useState(undefined); const stop = useCallback(() => { controller.current?.abort(); @@ -63,11 +91,16 @@ export function useScreen(workerId: string, deviceId: string): Screen { const abort = new AbortController(); controller.current = abort; setNote(undefined); + setConflict(undefined); setMeta(undefined); setStats(EMPTY); setPhase("starting"); void capture(api, workerId, deviceId, abort.signal, { canvas, + conflict: (message) => { + setConflict(message); + setPhase("stopped"); + }, ended: (reason) => { setNote(reason); setPhase("stopped"); @@ -80,12 +113,15 @@ export function useScreen(workerId: string, deviceId: string): Screen { // why: the picture starts when the panel is drawn and stops when it is not, so leaving the tab, // closing the drawer and navigating away are one event and a capture cannot outlive its viewer. + // A change of the device's state between two that stream does not change `enabled`, so it asks + // for nothing again; one that stops and then allows streaming does. useEffect(() => { + if (!enabled) return undefined; start(); return stop; - }, [start, stop]); + }, [enabled, start, stop]); - return { canvas, ...screenView({ meta, note, phase, stats }) }; + return { canvas, conflict, ...screenView({ meta, note, phase, stats }) }; } /** @@ -112,6 +148,7 @@ export function refusalOf(error: unknown): string { interface CaptureSinks { readonly canvas: RefObject; + readonly conflict: (message: string) => void; readonly live: () => void; readonly ended: (reason: string) => void; readonly setMeta: (meta: StreamMeta) => void; @@ -151,7 +188,9 @@ async function capture( signal, ); } catch (error) { - if (!signal.aborted) sinks.ended(refusalOf(error)); + if (signal.aborted) return; + if (error instanceof ApiError && error.status === 409) sinks.conflict(error.message); + else sinks.ended(refusalOf(error)); } } diff --git a/ui/src/state/workers.ts b/ui/src/state/workers.ts index 6e99fdaeb..daa8c2d92 100644 --- a/ui/src/state/workers.ts +++ b/ui/src/state/workers.ts @@ -23,6 +23,7 @@ import { workerRow, writeWorkerTab, } from "../model/worker-page"; +import { type StreamOffer, streamOffer } from "../model/screen"; import { type WorkerList, workerPath } from "../model/workers"; import { type EventRow, eventRows } from "../model/event-list"; import { eventsOfWorker } from "../model/events"; @@ -173,9 +174,11 @@ export function useDeviceDrawer( readonly close: () => void; readonly tab: DeviceDrawerTab; readonly chooseTab: (tab: DeviceDrawerTab) => void; + readonly stream: StreamOffer; } { const page = useWorkerPage(workerId); const search = useSearch(); + const workers = useLiveResource("/v1/workers").data?.workers; return { // why: each choice replaces the entry rather than adding one, as the worker page's tabs do. chooseTab: (tab) => setSearch(writeDrawerTab(search, tab), { replace: true }), @@ -183,6 +186,7 @@ export function useDeviceDrawer( // drawer's own tab belongs to one open drawer, so it goes. close: () => navigate(`${workerPath(workerId)}${writeDrawerTab(search, "details")}`, { replace: true }), + stream: streamOffer(workers?.find((worker) => worker.id === workerId)), tab: readDrawerTab(search), view: mapView(page, (shown) => shown?.overview.devices?.find((row) => row.id === deviceId)), }; From 5d7dcc826791ff33388d0ef0541bb1794950fc83 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 13:18:00 +0200 Subject: [PATCH 05/16] test: the Screen tab of a device that is not running starts no capture and starts the picture when it runs (#910) --- e2e/console/screen.spec.ts | 90 +++++++++++++++++++++++++++++++++++++- 1 file changed, 89 insertions(+), 1 deletion(-) diff --git a/e2e/console/screen.spec.ts b/e2e/console/screen.spec.ts index 0b40543f7..ce365f049 100644 --- a/e2e/console/screen.spec.ts +++ b/e2e/console/screen.spec.ts @@ -29,7 +29,9 @@ interface StreamingHost { readonly host: RunningDaemon; } -async function streamingHost(): Promise { +async function streamingHost( + extraConfig: Readonly> = {}, +): Promise { const dir = await mkdtemp(join(tmpdir(), "simlock-screen-")); const command = await writeCaptureProgram(dir); let host: RunningDaemon | undefined; @@ -37,6 +39,7 @@ async function streamingHost(): Promise { host = await startDaemon("worker", { config: { capabilities: { stream: { enabled: true, fps: 5 } }, + ...extraConfig, }, driverScript: { ios: { ...IOS, screenCaptureCommand: command } }, }); @@ -92,6 +95,25 @@ async function warmDevice(host: RunningDaemon): Promise { return ready; } +// why: with the warm pool off a released device is left shut down, which is the one state a viewer +// can reach a device in without a lease changing under them. +async function shutdownDevice(host: RunningDaemon): Promise { + const granted = await host.cli([...LEASE, "--agent-id", "console-screen", "--detach"]); + expect(granted.code, granted.stderr).toBe(0); + const released = await host.cli(["release", "--all", "--yes"]); + expect(released.code, released.stderr).toBe(0); + let id = ""; + await expect + .poll(async () => { + const listed = await host.cli(["status", "--json"]); + const [device] = (listed.json as { devices: { id: string; state: string }[] }).devices; + id = device?.state === "shutdown" ? device.id : ""; + return id; + }) + .not.toBe(""); + return id; +} + async function openDevice(page: Page, target: Target, search = "?tab=devices"): Promise { const path = `/fleet/${encodeURIComponent(target.workerId)}/${encodeURIComponent(target.deviceId)}${search}`; await page.goto(new URL(path, target.origin).toString()); @@ -245,6 +267,72 @@ test.describe("the device drawer's Screen tab", () => { }); }); +const SHUT_DOWN = "The device is shut down. Its screen shows while it runs."; + +test.describe("the Screen tab of a device that is not running", () => { + test("says why instead of starting a capture, starts the picture when the device runs, shows the sentence again when it is shut down while watched, and says streaming is off where it is", async ({ + page, + }) => { + const started = await streamingHost({ warmPool: { enabled: false } }); + const off = await startDaemon("worker", { + config: { warmPool: { enabled: false } }, + driverScript: { ios: IOS }, + }); + try { + const host = started.host; + const workerId = await workerIdOn(host); + const deviceId = await shutdownDevice(host); + const streams: string[] = []; + page.on("request", (request) => { + if (request.url().includes("/v1/stream/")) streams.push(request.url()); + }); + + await openDevice( + page, + { deviceId, origin: host.url, token: host.tokens.operator, workerId }, + "?tab=devices&drawer=screen", + ); + await expect(drawer(page).getByText(SHUT_DOWN)).toBeVisible(); + await expect(screen(page)).toHaveCount(0); + expect(streams).toEqual([]); + expect(await captureLogLines(started.dir, "opened")).toBe(0); + + const leased = await host.cli([...LEASE, "--agent-id", "console-screen", "--detach"]); + expect(leased.code, leased.stderr).toBe(0); + await expect(screen(page)).toBeVisible(); + await logReached(started.dir, "opened", 1); + + const released = await host.cli(["release", "--all", "--yes"]); + expect(released.code, released.stderr).toBe(0); + await logReached(started.dir, "closed", 1); + await expect(drawer(page).getByText(SHUT_DOWN)).toBeVisible(); + await expect(screen(page)).toHaveCount(0); + expect(await captureLogLines(started.dir, "opened")).toBe(1); + + const silent = await shutdownDevice(off); + const quiet: string[] = []; + page.on("request", (request) => { + if (request.url().includes("/v1/stream/")) quiet.push(request.url()); + }); + await openDevice( + page, + { + deviceId: silent, + origin: off.url, + token: off.tokens.operator, + workerId: await workerIdOn(off), + }, + "?tab=devices&drawer=screen", + ); + await expect(drawer(page).getByText(/^Streaming is off on /)).toBeVisible(); + expect(quiet).toEqual([]); + } finally { + await off.dispose(); + await cleanup(started); + } + }); +}); + async function logReached(dir: string, word: string, count: number): Promise { // why: a capture stopping is the daemon's act, not the browser's, and it is the fact under test -- so // a lane that did not wait for it would pass with a capture that never started or never stopped. From 188a83d0eb7340e6a45b4c14867ceda4427704dc Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 13:19:12 +0200 Subject: [PATCH 06/16] test: the stream numbers sit behind Stream details in a popover (#910) --- e2e/console/screen.spec.ts | 53 +++++++++++++++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/e2e/console/screen.spec.ts b/e2e/console/screen.spec.ts index ce365f049..8d1b7a6ee 100644 --- a/e2e/console/screen.spec.ts +++ b/e2e/console/screen.spec.ts @@ -121,8 +121,9 @@ async function openDevice(page: Page, target: Target, search = "?tab=devices"): await expect(page.getByRole("dialog")).toBeVisible(); } +// why: the stream numbers' popover is a dialog too, so the drawer is the one that holds the tabs. function drawer(page: Page) { - return page.getByRole("dialog"); + return page.getByRole("dialog").filter({ has: page.getByRole("tablist") }); } function screen(page: Page) { @@ -333,6 +334,56 @@ test.describe("the Screen tab of a device that is not running", () => { }); }); +test.describe("the stream numbers", () => { + test("sit behind the Stream details button, in a popover that Escape or a second click closes while the drawer and its capture stay", async ({ + page, + }) => { + const started = await streamingHost(); + try { + const host = started.host; + const target = { + deviceId: await warmDevice(host), + origin: host.url, + token: host.tokens.operator, + workerId: await workerIdOn(host), + }; + await openDevice(page, target, "?tab=devices&drawer=screen"); + await expect(screen(page)).toBeVisible(); + await logReached(started.dir, "opened", 1); + + const details = drawer(page).getByRole("button", { name: "Stream details" }); + const numbers = page.getByRole("dialog", { name: "Stream details" }); + await expect(drawer(page).getByText("Picture", { exact: true })).toHaveCount(0); + await expect(numbers).toHaveCount(0); + + await details.click(); + await expect(numbers).toBeVisible(); + const labels = await numbers.locator(".facts dt").allInnerTexts(); + expect(labels).toEqual(["State", "Picture", "Frames", "Rate", "Bandwidth", "Dropped"]); + await expect(drawer(page).getByText("Picture", { exact: true })).toHaveCount(0); + const picture = numbers + .locator(".facts > div") + .filter({ has: page.getByText("Picture", { exact: true }) }) + .getByRole("definition"); + await expect(picture).toHaveText("120 × 240 mjpeg", { timeout: 10_000 }); + + await page.keyboard.press("Escape"); + await expect(numbers).toHaveCount(0); + await expect(drawer(page)).toBeVisible(); + + await details.click(); + await expect(numbers).toBeVisible(); + await details.click(); + await expect(numbers).toHaveCount(0); + await expect(drawer(page)).toBeVisible(); + await expect(screen(page)).toBeVisible(); + expect(await captureLogLines(started.dir, "closed")).toBe(0); + } finally { + await cleanup(started); + } + }); +}); + async function logReached(dir: string, word: string, count: number): Promise { // why: a capture stopping is the daemon's act, not the browser's, and it is the fact under test -- so // a lane that did not wait for it would pass with a capture that never started or never stopped. From 185e2006405d9fe19119f29099dfe3914549f1bc Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 13:21:25 +0200 Subject: [PATCH 07/16] feat(ui): the stream numbers move behind a Stream details button (#910) --- e2e/console/screen.spec.ts | 2 +- ui/src/ds/icon.tsx | 1 + ui/src/ds/index.ts | 1 + ui/src/ds/popover.css | 11 +++++ ui/src/ds/popover.tsx | 31 ++++++++++++ ui/src/screens/fleet/screen-panel.css | 54 +++++++++++++++++++++ ui/src/screens/fleet/screen-panel.tsx | 38 ++++++++------- ui/src/screens/leases/leases.css | 69 --------------------------- 8 files changed, 121 insertions(+), 86 deletions(-) create mode 100644 ui/src/ds/popover.css create mode 100644 ui/src/ds/popover.tsx create mode 100644 ui/src/screens/fleet/screen-panel.css diff --git a/e2e/console/screen.spec.ts b/e2e/console/screen.spec.ts index 8d1b7a6ee..9fe9dca5e 100644 --- a/e2e/console/screen.spec.ts +++ b/e2e/console/screen.spec.ts @@ -358,7 +358,7 @@ test.describe("the stream numbers", () => { await details.click(); await expect(numbers).toBeVisible(); - const labels = await numbers.locator(".facts dt").allInnerTexts(); + const labels = await numbers.locator(".facts dt").allTextContents(); expect(labels).toEqual(["State", "Picture", "Frames", "Rate", "Bandwidth", "Dropped"]); await expect(drawer(page).getByText("Picture", { exact: true })).toHaveCount(0); const picture = numbers diff --git a/ui/src/ds/icon.tsx b/ui/src/ds/icon.tsx index bb0f48d3a..2ff484a93 100644 --- a/ui/src/ds/icon.tsx +++ b/ui/src/ds/icon.tsx @@ -13,6 +13,7 @@ const PATHS = { signOut: "M6 2.5H2.5v11H6M10 5l3 3-3 3M13 8H6", more: "M3.5 8h.01M8 8h.01M12.5 8h.01", device: "M5 1.5h6v13H5zM7.5 12.5h1", + info: "M8 14.5a6.5 6.5 0 1 0 0-13 6.5 6.5 0 0 0 0 13zM8 7.5v4M8 5h.01", search: "M7 12.5a5.5 5.5 0 1 0 0-11 5.5 5.5 0 0 0 0 11zM11 11l3.5 3.5", } as const; diff --git a/ui/src/ds/index.ts b/ui/src/ds/index.ts index b4f8afcc4..8cb4aa3a1 100644 --- a/ui/src/ds/index.ts +++ b/ui/src/ds/index.ts @@ -14,6 +14,7 @@ export { Loaded, type LoadView, Refusal } from "./loaded"; export { ListRow } from "./master-detail"; export { ActionBar, type MenuEntry, RowMenu } from "./menu"; export { Meter } from "./meter"; +export { InfoPopover } from "./popover"; export type { Paging } from "./pager"; export { DesignProvider } from "./provider"; export { Card, Empty, Grid, Id, Panel, Row } from "./panel"; diff --git a/ui/src/ds/popover.css b/ui/src/ds/popover.css new file mode 100644 index 000000000..aa03234c3 --- /dev/null +++ b/ui/src/ds/popover.css @@ -0,0 +1,11 @@ +.popover-positioner { + z-index: 90; +} + +.popover { + min-width: 14rem; + padding: var(--space-3); + border: 1px solid var(--border-strong); + background: var(--bg); + font-size: var(--text-sm); +} diff --git a/ui/src/ds/popover.tsx b/ui/src/ds/popover.tsx new file mode 100644 index 000000000..678160bb0 --- /dev/null +++ b/ui/src/ds/popover.tsx @@ -0,0 +1,31 @@ +import "./popover.css"; + +import { Popover } from "@base-ui/react/popover"; +import type { ReactNode } from "react"; + +import { IconButton } from "./button"; +import { Icon } from "./icon"; + +// why: an ⓘ button that opens a small panel of facts beside it: Escape, a click outside and a second +// click on the button all close it, and none of them reaches the drawer the button sits in. +export function InfoPopover(props: { readonly label: string; readonly children: ReactNode }) { + const { children, label } = props; + return ( + + + + + } + /> + + + + {children} + + + + + ); +} diff --git a/ui/src/screens/fleet/screen-panel.css b/ui/src/screens/fleet/screen-panel.css new file mode 100644 index 000000000..80f4af154 --- /dev/null +++ b/ui/src/screens/fleet/screen-panel.css @@ -0,0 +1,54 @@ +.screen-panel { + display: flex; + flex-direction: column; + gap: var(--space-3); + min-width: 0; + padding: var(--space-4); + background: var(--surface); + border: 1px solid var(--border); +} + +/* The picture sits in a phone-shaped frame; the canvas keeps the ratio it was painted at inside + * it, so a landscape picture letterboxes rather than stretches. */ +.phone { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 100%; + max-width: 300px; + margin: 0 auto; + aspect-ratio: 9 / 19.5; + background: var(--field); + border: 1px solid var(--border-strong); +} + +.phone-canvas { + display: block; + width: 100%; + height: 100%; + object-fit: contain; +} + +.phone-off, +.phone-wait { + margin: 0; + padding: var(--space-4); + color: var(--text-muted); + font-size: var(--text-sm); + text-align: center; +} + +.phone-wait { + position: absolute; + inset: auto 0 0; + background: var(--field); +} + + +.screen-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); +} diff --git a/ui/src/screens/fleet/screen-panel.tsx b/ui/src/screens/fleet/screen-panel.tsx index c1ddf0313..439f306d9 100644 --- a/ui/src/screens/fleet/screen-panel.tsx +++ b/ui/src/screens/fleet/screen-panel.tsx @@ -1,6 +1,8 @@ +import "./screen-panel.css"; + import { useId } from "react"; -import { Facts, Refusal, Status } from "../../ds"; +import { Facts, InfoPopover, Refusal, Status } from "../../ds"; import { type Screen, type StreamOffer, useDeviceScreen } from "../../state/screen"; // why: the hook lives in the panel's own component so that leaving the tab, closing the drawer and @@ -26,9 +28,25 @@ export function ScreenView(props: { readonly screen: Screen }) { const title = useId(); return (
-

- Screen -

+
+

+ Screen +

+ + {readout.state.word}, + }, + ...readout.facts.map((fact) => ({ + label: fact.label, + value: {fact.value}, + })), + ]} + /> + +
{live ? ( {waiting}

}
- {readout.state.word}, - }, - ...readout.facts.map((fact) => ({ - label: fact.label, - value: {fact.value}, - })), - ]} - /> {note === undefined ? null : }
); diff --git a/ui/src/screens/leases/leases.css b/ui/src/screens/leases/leases.css index cdadfe99a..44caf692d 100644 --- a/ui/src/screens/leases/leases.css +++ b/ui/src/screens/leases/leases.css @@ -21,28 +21,6 @@ font-weight: 400; } -/* Two columns once there is room: the cards on the left, the phone on the right. On a narrower - * pane the phone comes first, since the picture is what an operator opens a lease for. */ -.lease-body { - display: grid; - gap: var(--space-4); - align-items: start; -} - -.lease-body > .screen-panel { - order: -1; -} - -@media (min-width: 1200px) { - .lease-body { - grid-template-columns: minmax(0, 1fr) 20rem; - } - - .lease-body > .screen-panel { - order: 0; - } -} - .lease-cards { display: flex; flex-direction: column; @@ -91,53 +69,6 @@ font-size: var(--text-sm); } -.screen-panel { - display: flex; - flex-direction: column; - gap: var(--space-3); - min-width: 0; - padding: var(--space-4); - background: var(--surface); - border: 1px solid var(--border); -} - -/* The picture sits in a phone-shaped frame; the canvas keeps the ratio it was painted at inside - * it, so a landscape picture letterboxes rather than stretches. */ -.phone { - position: relative; - display: flex; - align-items: center; - justify-content: center; - width: 100%; - max-width: 300px; - margin: 0 auto; - aspect-ratio: 9 / 19.5; - background: var(--field); - border: 1px solid var(--border-strong); -} - -.phone-canvas { - display: block; - width: 100%; - height: 100%; - object-fit: contain; -} - -.phone-off, -.phone-wait { - margin: 0; - padding: var(--space-4); - color: var(--text-muted); - font-size: var(--text-sm); - text-align: center; -} - -.phone-wait { - position: absolute; - inset: auto 0 0; - background: var(--field); -} - .lease-line { margin: 0; color: var(--text-muted); From b31dc6af3cfc7c73e0da1188e1009854593e6b07 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 13:23:24 +0200 Subject: [PATCH 08/16] test: a stream path that names no stream, a lease's included, answers 404 UNKNOWN_DEVICE (#910) --- src/stream/service.test.ts | 45 ++++++++++++++++++++++++++++---------- 1 file changed, 33 insertions(+), 12 deletions(-) diff --git a/src/stream/service.test.ts b/src/stream/service.test.ts index 37e01ba3f..3cafcbb53 100644 --- a/src/stream/service.test.ts +++ b/src/stream/service.test.ts @@ -2,7 +2,13 @@ import { describe, expect, it } from "vitest"; import type { CapturedStream } from "./feed.js"; import { StreamRefusalError } from "./refusal.js"; -import { handleStreamRequest, leaseIdOf, streamResponse, type StreamLink } from "./service.js"; +import { + handleStreamRequest, + leaseIdOf, + streamResponse, + type StreamLink, + streamTargetOf, +} from "./service.js"; function stream(codec: "h264" | "mjpeg" = "h264"): CapturedStream { return { @@ -46,17 +52,32 @@ describe("leaseIdOf", () => { }); describe("handleStreamRequest", () => { - it("refuses a path that names no lease with the body shape every other route uses", async () => { - const response = await handleStreamRequest( - { link: linkThat(async () => stream()) }, - get("/v1/stream/leases"), - OPERATOR, - leaseIdOf("/v1/stream/leases"), - ); - expect(response.status).toBe(404); - expect(await response.json()).toEqual({ - error: { code: "UNKNOWN_LEASE", message: "This path names no stream lease" }, - }); + it("refuses a path that names no stream, a lease's included, with the body shape every other route uses and opens nothing", async () => { + const asked: unknown[] = []; + const options = { + link: linkThat(async (target) => { + asked.push(target); + return stream(); + }), + }; + for (const path of [ + "/v1/stream/leases/lse_1", + "/v1/stream/leases", + "/v1/stream/workers/w1", + "/v1/stream/devices/d1", + ]) { + const response = await handleStreamRequest( + options, + get(path), + OPERATOR, + streamTargetOf(path), + ); + expect(response.status, path).toBe(404); + expect(await response.json(), path).toEqual({ + error: { code: "UNKNOWN_DEVICE", message: "This path names no stream" }, + }); + } + expect(asked).toEqual([]); }); it("refuses to be written to", async () => { From 6350ee51310c06cd0e8e3a9df6f274283b2c9831 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 13:28:39 +0200 Subject: [PATCH 09/16] feat(stream): remove the lease stream route; a path that names no stream answers 404 UNKNOWN_DEVICE (#910) --- src/daemon/stream-gateway.e2e.test.ts | 24 +----- src/http/core-routes.test.ts | 5 +- src/http/openapi.test.ts | 33 ++------ src/stream/AGENTS.md | 69 ++++++++-------- src/stream/caller.ts | 1 - src/stream/captures.test.ts | 59 +++++++------- src/stream/captures.ts | 61 ++++++--------- src/stream/create.test.ts | 55 ++++--------- src/stream/create.ts | 2 +- src/stream/definition.ts | 40 ---------- src/stream/gateway-link.test.ts | 63 ++++----------- src/stream/gateway-link.ts | 53 +------------ src/stream/gateway.ts | 13 +--- src/stream/mounts.ts | 31 +++----- src/stream/refusal.ts | 10 +-- src/stream/service.test.ts | 59 +++++++------- src/stream/service.ts | 24 ++---- src/stream/test-fixtures.ts | 13 +--- src/stream/worker-link.test.ts | 108 ++++++++++---------------- src/stream/worker-link.ts | 75 ++++-------------- ui/src/data/api.test.ts | 2 +- ui/src/data/stream.ts | 2 +- 22 files changed, 239 insertions(+), 563 deletions(-) diff --git a/src/daemon/stream-gateway.e2e.test.ts b/src/daemon/stream-gateway.e2e.test.ts index 52720adc2..30842df48 100644 --- a/src/daemon/stream-gateway.e2e.test.ts +++ b/src/daemon/stream-gateway.e2e.test.ts @@ -143,16 +143,12 @@ describe("a screen stream through a gateway", () => { const deviceId = (granted as { lease: { deviceId: string } }).lease.deviceId; const { workers } = await gateway.dispatch("worker.list", {}, admin); const workerId = workers[0]?.id ?? ""; - const deviceStream = (path = `${workerId}/devices/${deviceId}`) => - fetch(`http://127.0.0.1:${String(port)}/v1/stream/workers/${path}`, { - headers: { authorization: `Bearer ${operator}` }, - }); const stream = (signal?: AbortSignal) => - fetch(`http://127.0.0.1:${String(port)}/v1/stream/leases/${encodeURIComponent(leaseId)}`, { + fetch(`http://127.0.0.1:${String(port)}/v1/stream/workers/${workerId}/devices/${deviceId}`, { headers: { authorization: `Bearer ${operator}` }, ...(signal === undefined ? {} : { signal }), }); - return { deviceStream, gateway, leaseId, screen, stream }; + return { gateway, leaseId, screen, stream }; } it( @@ -181,22 +177,6 @@ describe("a screen stream through a gateway", () => { }, ); - it( - "relays an operator's device stream through the gateway: the worker's frames", - { timeout: 30_000 }, - async () => { - const { deviceStream } = await fleet(true); - const response = await deviceStream(); - expect(response.status).toBe(200); - expect(response.headers.get("simlock-stream-codec")).toBe("h264"); - const reader = response.body?.getReader(); - if (reader === undefined) throw new Error("no body"); - const first = await reader.read(); - expect(first.value?.[4]).toBe(SCREEN_FRAME_TAG.meta); - await reader.cancel(); - }, - ); - it( "a viewer leaving the gateway stops the capture on the worker", { timeout: 30_000 }, diff --git a/src/http/core-routes.test.ts b/src/http/core-routes.test.ts index 8f930f32e..4c7677bd3 100644 --- a/src/http/core-routes.test.ts +++ b/src/http/core-routes.test.ts @@ -85,10 +85,7 @@ describe("the routes createHttpApp registers", () => { expect(registered.some((route) => route.path.startsWith("/v1/stream"))).toBe(true); expect(registered.some((route) => route.path.startsWith("/v1/agent-device"))).toBe(true); expect(coverageProblems(core, declared)).toEqual([]); - expect(moduleRoutes).toEqual([ - "GET /v1/stream/leases/{id}", - "GET /v1/stream/workers/{workerId}/devices/{deviceId}", - ]); + expect(moduleRoutes).toEqual(["GET /v1/stream/workers/{workerId}/devices/{deviceId}"]); expect([...documented].sort()).toEqual([...declared, ...moduleRoutes].sort()); expect(coverageProblems([...core, "GET /v1/new"], declared)).toEqual([ "not declared: GET /v1/new", diff --git a/src/http/openapi.test.ts b/src/http/openapi.test.ts index 599ed2488..0903f7aac 100644 --- a/src/http/openapi.test.ts +++ b/src/http/openapi.test.ts @@ -350,12 +350,11 @@ describe("buildOpenApiDocument with a module that carries http", () => { expect(result.valid).toBe(true); }); - it("an integrator reads GET /v1/stream/leases/{id} and GET /v1/stream/workers/{workerId}/devices/{deviceId} under stream, with its headers, binary 200, 405 and refusals, and no agent-device or uplink path", () => { + it("an integrator reads GET /v1/stream/workers/{workerId}/devices/{deviceId} under stream, with its headers, binary 200, 405 and refusals, and no lease stream, agent-device or uplink path", () => { const document = buildOpenApiDocument(optionalModules); - const operation = document.paths?.["/v1/stream/leases/{id}"]?.get; - const ok = (operation?.responses ?? {})["200"] as OkResponse; + const device = document.paths?.["/v1/stream/workers/{workerId}/devices/{deviceId}"]?.get; + const ok = (device?.responses ?? {})["200"] as OkResponse; - expect(operation?.tags).toEqual(["stream"]); expect(document.tags?.find((tag) => tag.name === "stream")?.description).toContain( "capabilities.stream.enabled", ); @@ -363,31 +362,9 @@ describe("buildOpenApiDocument with a module that carries http", () => { "application/vnd.simlock.stream+h264", "application/vnd.simlock.stream+mjpeg", ]); - expect(sortedKeys(ok.headers)).toEqual([ - "simlock-stream-codec", - "simlock-stream-fps", - "simlock-stream-height", - "simlock-stream-width", - "x-simlock-lease", + expect(sortedKeys(document.paths).filter((path) => path.startsWith("/v1/stream"))).toEqual([ + "/v1/stream/workers/{workerId}/devices/{deviceId}", ]); - expect( - Object.fromEntries( - ["401", "403", "404", "405", "415", "500", "502", "503"].map((status) => [ - status, - errorCodes(operation, status), - ]), - ), - ).toEqual({ - "401": ["UNAUTHENTICATED"], - "403": ["FORBIDDEN"], - "404": ["UNKNOWN_LEASE"], - "405": ["BAD_REQUEST"], - "415": ["STREAM_NOT_SUPPORTED"], - "500": ["INTERNAL"], - "502": ["STREAM_UNAVAILABLE"], - "503": ["STREAM_UNAVAILABLE", "WORKER_UNREACHABLE"], - }); - const device = document.paths?.["/v1/stream/workers/{workerId}/devices/{deviceId}"]?.get; expect(device?.tags).toEqual(["stream"]); expect( Object.fromEntries( diff --git a/src/stream/AGENTS.md b/src/stream/AGENTS.md index a9781d91a..f8dd46f8e 100644 --- a/src/stream/AGENTS.md +++ b/src/stream/AGENTS.md @@ -1,6 +1,6 @@ # Stream -Does: serves a running device's live screen to operators at `GET /v1/stream/workers/{workerId}/devices/{deviceId}` (and, until #910, a lease's at `GET /v1/stream/leases/{leaseId}`), on a worker and through a gateway. +Does: serves a running device's live screen to operators at `GET /v1/stream/workers/{workerId}/devices/{deviceId}` on a worker and through a gateway. Entry: `index.ts` → `streamModule`, an optional module (ADR 0030): `worker.ts` `createWorker`, `gateway.ts` `createGateway`, loaded by the module host only when turned on. Uses: core (`ScreenFeed`, frame tags, registry records), modules (the context and hook types), ports. @@ -9,51 +9,48 @@ Off unless `capabilities.stream.enabled` (false). Off: nothing loaded, and the ` ## Files -| File | Role | -| ------------------------- | ------------------------------------------------------------------------------------------ | -| `definition.ts` | `streamModule`: name `stream`, config section, `http` route data, the two loaders | -| `worker.ts`, `gateway.ts` | The module's two sides, built from the host's context | -| `create.ts` | The two factories; the `stream` capability; the four device events that stop captures | -| `worker-link.ts` | Worker: lease or device check, open through the driver, register, re-check after frame 1 | -| `gateway-link.ts` | Gateway: fleet ownership (lease) or known worker (device), relay, refusal and header check | -| `feed.ts` | `readScreenFeed`: meta frame first, start and silence bounds, close on any end | -| `framing.ts` | `MetaFrameScanner`, `checkedMeta`: codec and size a picture may claim | -| `captures.ts` | Captures per lease and per device; the lease-end participant that stops them | -| `service.ts` | One request either door: method, refusal body, response headers, log line | -| `mounts.ts` | The HTTP mount and the relay target | -| `refusal.ts` | `StreamRefusalError`, each refusal's status and code, a relayed refusal checked | -| `caller.ts` | Who asks | -| `test-fixtures.ts` | `TestRegistry`, `ScriptedFeed` for tests | -| `testing.ts` | What other components' tests may import: `createStreamMount` | +| File | Role | +| ------------------------- | ------------------------------------------------------------------------------------- | +| `definition.ts` | `streamModule`: name `stream`, config section, `http` route data, the two loaders | +| `worker.ts`, `gateway.ts` | The module's two sides, built from the host's context | +| `create.ts` | The two factories; the `stream` capability; the four device events that stop captures | +| `worker-link.ts` | Worker: device check, open through the driver, register, re-check after frame 1 | +| `gateway-link.ts` | Gateway: known worker, relay, refusal and header check | +| `feed.ts` | `readScreenFeed`: meta frame first, start and silence bounds, close on any end | +| `framing.ts` | `MetaFrameScanner`, `checkedMeta`: codec and size a picture may claim | +| `captures.ts` | Captures per device; the lease-end participant that stops them | +| `service.ts` | One request either door: method, refusal body, response headers, log line | +| `mounts.ts` | The HTTP mount and the relay target | +| `refusal.ts` | `StreamRefusalError`, each refusal's status and code, a relayed refusal checked | +| `caller.ts` | Who asks | +| `test-fixtures.ts` | `TestRegistry`, `ScriptedFeed` for tests | +| `testing.ts` | What other components' tests may import: `createStreamMount` | ## How it works Worker — `worker-link.ts` `createWorkerStreamLink`: -1. Lease route: the lease from the registry snapshot. Agent: its requester (relayed) or principal - must hold it. Unknown and not yours both answer 404 `UNKNOWN_LEASE`; nothing is opened first. - Device route: operator only (an agent gets 404 `UNKNOWN_DEVICE`, as for any device), the path's - worker id must be this worker's own (404 `UNKNOWN_WORKER`), the device must exist (404 - `UNKNOWN_DEVICE`) and be `ready` or `leased` (409 `DEVICE_NOT_RUNNING`). A relayed device - request is served whatever the tenant: the gateway has already refused non-operators. -2. `captures.begin(leaseId)` or `captures.beginOnDevice(deviceId)` — registered before the capture starts. +1. Operator only (an agent gets 404 `UNKNOWN_DEVICE`, as for any device), the path's worker id + must be this worker's own (404 `UNKNOWN_WORKER`), the device must exist (404 `UNKNOWN_DEVICE`) + and be `ready` or `leased` (409 `DEVICE_NOT_RUNNING`); nothing is opened first. A relayed + request is served whatever the tenant: the gateway has already refused non-operators. Any other + stream path, a lease's included, answers 404 `UNKNOWN_DEVICE` "This path names no stream". +2. `captures.beginOnDevice(deviceId)` — registered before the capture starts. 3. `ctx.screens.openScreen(platform, device, settings)` (the daemon passes its `DriverCatalog`) → the driver's `ScreenFeed`. `ScreenNotSupportedError` → 415 `STREAM_NOT_SUPPORTED`; anything else → 503 `STREAM_UNAVAILABLE`. 4. `readScreenFeed`: the meta frame within the start bound, or 503 and close. -5. Lease re-read: gone, re-granted, or its slot stopped → close, 404. A device slot stopped → close, 409 `DEVICE_NOT_RUNNING`. +5. Its slot stopped meanwhile → close, 409 `DEVICE_NOT_RUNNING`. 6. 200; headers `simlock-stream-{codec,fps,width,height}`; body = the frames, meta first. Ends — each closes the capture once (`closedOnce`): the viewer cancels or aborts; the capture ends or errors; no byte within the silence bound; the lease ends (the `beforeDeviceReclaim` hook, `createStreamLeaseEnd` → -`captures.stop` and `captures.stopOnDevice`, which wait for every close, bounded by the driver and by leasing's signal); +`captures.stopOnDevice`, which waits for every close, bounded by the driver and by leasing's signal); its device stops with no lease end (`device.shutdown`, `device.absence-detected`, `device.quarantined` or `device.deleted` → `captures.stopOnDevice`). A lease granted while streaming ends nothing. -Gateway — `gateway-link.ts`: the fleet lease index names owner, worker and the worker's lease -id. Not the owner (agent) → 404. Relays `GET /stream/leases/` as target `stream` -with tenant `` from the index, never from the caller. A device -request: not an operator → 404 `UNKNOWN_DEVICE`; a worker not in the fleet → 404 `UNKNOWN_WORKER`; -else relays `GET /stream/devices/` with tenant `` and no lease id. A refusal +Gateway — `gateway-link.ts`: not an operator → 404 `UNKNOWN_DEVICE`; a worker not in the fleet → +404 `UNKNOWN_WORKER`; else relays `GET /stream/devices/` with tenant +``. A refusal body is read to 16 KiB; its code and message are forwarded only if the code matches `^[A-Z][A-Z0-9_]{0,63}$`, else the code becomes `STREAM_UNAVAILABLE`. The worker's 4xx or 5xx status is kept either way; a 2xx without a picture → 502. Headers go through `checkedMeta`. @@ -64,7 +61,7 @@ cannot says why in its 503); `unavailable` when none can, with each platform's r ## State and timers -Memory only: running captures per lease and per device (`captures.ts`). +Memory only: running captures per device (`captures.ts`). | What | Default | Key | | ---------------------------- | -------------------- | ------------------------------------------------------------------------------------------------ | @@ -76,11 +73,11 @@ Memory only: running captures per lease and per device (`captures.ts`). ## Rules - No platform name here: a driver offers `screenCapture` or not (`core/screen.ts`). -- Ownership before any work; unknown and not-yours answer the same. -- A capture is registered before it starts and the lease is re-read after its first frame, - so a capture never outlives its lease or its device's running state (`worker-link.test.ts`, +- The caller and the device are checked before any work; an agent and an unknown device get the same answer. +- A capture is registered before it starts and its slot is read again after its first frame, + so a capture never outlives its device's running state (`worker-link.test.ts`, `create.test.ts`). -- A gateway never parses a video byte and never takes a caller's word for the requester. +- A gateway never parses a video byte and never takes a caller's word for the tenant. - No business events: watching changes no lease or device. Each open and refusal is logged. ## Known gaps diff --git a/src/stream/caller.ts b/src/stream/caller.ts index c5f78a84d..22b319115 100644 --- a/src/stream/caller.ts +++ b/src/stream/caller.ts @@ -2,6 +2,5 @@ export type StreamRole = "agent" | "operator"; export interface StreamCaller { readonly principal: string; - readonly requesterId?: string; readonly role: StreamRole; } diff --git a/src/stream/captures.test.ts b/src/stream/captures.test.ts index 2622fa274..147e5ee3a 100644 --- a/src/stream/captures.test.ts +++ b/src/stream/captures.test.ts @@ -6,16 +6,16 @@ async function flush(): Promise { for (let turn = 0; turn < 10; turn += 1) await new Promise((resolve) => setImmediate(resolve)); } -function ended(leaseId: string) { - return { leaseId } as Parameters["leaseEnded"]>[0]; +function ended(deviceId: string) { + return { deviceId } as Parameters["leaseEnded"]>[0]; } describe("the capture registry", () => { - it("a lease that ends while its capture is starting aborts the slot, waits for it to attach, and closes it", async () => { + it("a device that stops while its capture is starting aborts the slot, waits for it to attach, and closes it", async () => { const captures = createCaptureRegistry(); - const slot = captures.begin("lse_1"); + const slot = captures.beginOnDevice("dev_1"); let stopped = false; - const stopping = captures.stop("lse_1").then(() => (stopped = true)); + const stopping = captures.stopOnDevice("dev_1").then(() => (stopped = true)); expect(slot.ended.aborted).toBe(true); await Promise.resolve(); expect(stopped).toBe(false); @@ -27,53 +27,54 @@ describe("the capture registry", () => { expect(closes).toBe(1); }); - it("stops every capture of the lease that ended and leaves another lease's running", async () => { + it("stops every capture of the device that stopped and leaves another device's running", async () => { const captures = createCaptureRegistry(); const closed: string[] = []; - captures.begin("lse_1").attach(async () => void closed.push("a")); - captures.begin("lse_1").attach(async () => void closed.push("b")); - captures.begin("lse_2").attach(async () => void closed.push("c")); - expect(await captures.stop("lse_1")).toBe(2); + captures.beginOnDevice("dev_1").attach(async () => void closed.push("a")); + captures.beginOnDevice("dev_1").attach(async () => void closed.push("b")); + captures.beginOnDevice("dev_2").attach(async () => void closed.push("c")); + expect(await captures.stopOnDevice("dev_1")).toBe(2); expect(closed).toEqual(["a", "b"]); - expect(captures.leases()).toEqual(["lse_2"]); + expect(await captures.stopOnDevice("dev_2")).toBe(1); }); it("closes a capture once however many doors close it", async () => { const captures = createCaptureRegistry(); let closes = 0; - const slot = captures.begin("lse_1"); + const slot = captures.beginOnDevice("dev_1"); slot.attach(async () => { closes += 1; }); await slot.release(); await slot.release(); - await captures.stop("lse_1"); + await captures.stopOnDevice("dev_1"); expect(closes).toBe(1); }); - it("an abandoned open no longer counts as its lease's", async () => { + it("an abandoned open no longer counts as its device's", async () => { const captures = createCaptureRegistry(); - captures.begin("lse_1").abandon(); - expect(captures.leases()).toEqual([]); - expect(await captures.stop("lse_1")).toBe(0); + captures.beginOnDevice("dev_1").abandon(); + expect(await captures.stopOnDevice("dev_1")).toBe(0); }); - it("a released capture counts as its lease's until it is closed", async () => { + it("a released capture counts as its device's until it is closed", async () => { const captures = createCaptureRegistry(); - const slot = captures.begin("lse_1"); + const slot = captures.beginOnDevice("dev_1"); let finish!: () => void; slot.attach(() => new Promise((resolve) => (finish = resolve))); const releasing = slot.release(); await flush(); - expect(captures.leases()).toEqual(["lse_1"]); + const stopping = captures.stopOnDevice("dev_1"); + await flush(); finish(); await releasing; - expect(captures.leases()).toEqual([]); + expect(await stopping).toBe(1); + expect(await captures.stopOnDevice("dev_1")).toBe(0); }); it("a slot released before its capture attached closes that capture once it attaches", async () => { const captures = createCaptureRegistry(); - const slot = captures.begin("lse_1"); + const slot = captures.beginOnDevice("dev_1"); const releasing = slot.release(); let closes = 0; slot.attach(async () => { @@ -81,18 +82,20 @@ describe("the capture registry", () => { }); await releasing; expect(closes).toBe(1); - expect(captures.leases()).toEqual([]); + expect(await captures.stopOnDevice("dev_1")).toBe(0); }); }); describe("the stream's lease-end participant", () => { - it("returns only once the ended lease's captures have closed", async () => { + it("returns only once the ended lease's device's captures have closed", async () => { const captures = createCaptureRegistry(); let finish!: () => void; - captures.begin("lse_1").attach(() => new Promise((resolve) => (finish = resolve))); + captures + .beginOnDevice("dev_1") + .attach(() => new Promise((resolve) => (finish = resolve))); let returned = false; const done = createStreamLeaseEnd(captures) - .leaseEnded(ended("lse_1"), new AbortController().signal) + .leaseEnded(ended("dev_1"), new AbortController().signal) .then(() => { returned = true; }); @@ -105,10 +108,10 @@ describe("the stream's lease-end participant", () => { it("stops waiting when leasing gives up on it", async () => { const captures = createCaptureRegistry(); - captures.begin("lse_1").attach(() => new Promise(() => undefined)); + captures.beginOnDevice("dev_1").attach(() => new Promise(() => undefined)); const abort = new AbortController(); const done = createStreamLeaseEnd(captures) - .leaseEnded(ended("lse_1"), abort.signal) + .leaseEnded(ended("dev_1"), abort.signal) .then(() => "returned"); abort.abort(); const outcome = await Promise.race([ diff --git a/src/stream/captures.ts b/src/stream/captures.ts index 5ab17cd8e..be4cf12d4 100644 --- a/src/stream/captures.ts +++ b/src/stream/captures.ts @@ -1,12 +1,11 @@ -// why: a capture belongs to the lease or the device it films, not to a connection -- the lease is the thing -// that says this machine still owns that device, and the device is the thing that says it still runs. A -// viewer going away stops a capture through its own request; this covers what that cannot, a lease that -// ends somewhere else (released elsewhere, expired, the device lost) while somebody is still watching, or -// while a capture is still starting. +// why: a capture belongs to the device it films, not to a connection -- the device is the thing that says +// it still runs. A viewer going away stops a capture through its own request; this covers what that +// cannot, a device that stops running or whose lease ends (released elsewhere, expired, the device lost) +// while somebody is still watching, or while a capture is still starting. import type { ModuleLeaseEndParticipant } from "../modules/index.js"; export interface CaptureSlot { - // why: aborted when the lease ends, so a capture still being opened learns it has no lease to + // why: aborted when the device stops, so a capture still being opened learns it has nothing to // film before it hands a picture out. readonly ended: AbortSignal; attach(close: () => Promise): void; @@ -19,9 +18,6 @@ export interface CaptureSlot { } export interface CaptureRegistry { - begin(leaseId: string): CaptureSlot; - stop(leaseId: string): Promise; - leases(): string[]; // why: a device's stream belongs to the device, not to a lease, because a device nobody leases streams // too; its captures end when the device stops running or its lease ends, whoever the viewer is. beginOnDevice(deviceId: string): CaptureSlot; @@ -41,7 +37,6 @@ interface Slot { interface SlotTable { begin(key: string): CaptureSlot; stop(key: string): Promise; - keys(): string[]; } function createSlotTable(): SlotTable { @@ -57,17 +52,17 @@ function createSlotTable(): SlotTable { await slot.closing; }; - const forget = (leaseId: string, slot: Slot) => { - const forLease = running.get(leaseId); - forLease?.delete(slot); - if (forLease?.size === 0) running.delete(leaseId); + const forget = (key: string, slot: Slot) => { + const forKey = running.get(key); + forKey?.delete(slot); + if (forKey?.size === 0) running.delete(key); }; return { // why: registered before the capture starts, not after its first frame -- a lease that ends in // between must find something to stop, or the capture would come up filming a device that is // already on its way to the next holder. - begin: (leaseId) => { + begin: (key) => { let settle = () => undefined as void; const settled = new Promise((resolve) => (settle = resolve)); const slot: Slot = { @@ -77,9 +72,9 @@ function createSlotTable(): SlotTable { settle, settled, }; - const forLease = running.get(leaseId) ?? new Set(); - forLease.add(slot); - running.set(leaseId, forLease); + const forKey = running.get(key) ?? new Set(); + forKey.add(slot); + running.set(key, forKey); return { // why: a capture can finish opening after its viewer left or its lease ended; whoever let // go of the slot is waiting on `settled`, and closes it the moment it attaches. @@ -90,36 +85,28 @@ function createSlotTable(): SlotTable { ended: slot.controller.signal, abandon: () => { slot.settle(); - forget(leaseId, slot); + forget(key, slot); }, release: async () => { await closeSlot(slot); - forget(leaseId, slot); + forget(key, slot); }, }; }, - keys: () => [...running.keys()], - stop: async (leaseId) => { - const forLease = running.get(leaseId); - if (forLease === undefined) return 0; - running.delete(leaseId); - for (const slot of forLease) slot.controller.abort(); - await Promise.allSettled([...forLease].map(closeSlot)); - return forLease.size; + stop: async (key) => { + const forKey = running.get(key); + if (forKey === undefined) return 0; + running.delete(key); + for (const slot of forKey) slot.controller.abort(); + await Promise.allSettled([...forKey].map(closeSlot)); + return forKey.size; }, }; } export function createCaptureRegistry(): CaptureRegistry { - const leases = createSlotTable(); const devices = createSlotTable(); - return { - begin: leases.begin, - beginOnDevice: devices.begin, - leases: leases.keys, - stop: leases.stop, - stopOnDevice: devices.stop, - }; + return { beginOnDevice: devices.begin, stopOnDevice: devices.stop }; } export function createStreamLeaseEnd(captures: CaptureRegistry): ModuleLeaseEndParticipant { @@ -129,7 +116,7 @@ export function createStreamLeaseEnd(captures: CaptureRegistry): ModuleLeaseEndP // the wait if leasing gives up first. leaseEnded: async (ended, signal) => { await Promise.race([ - Promise.all([captures.stop(ended.leaseId), captures.stopOnDevice(ended.deviceId)]), + captures.stopOnDevice(ended.deviceId), new Promise((resolve) => { if (signal.aborted) resolve(); else signal.addEventListener("abort", () => resolve(), { once: true }); diff --git a/src/stream/create.test.ts b/src/stream/create.test.ts index cef17808e..7be66bb2e 100644 --- a/src/stream/create.test.ts +++ b/src/stream/create.test.ts @@ -12,19 +12,16 @@ function worker( openGate: Promise = Promise.resolve(), ) { const clock = new FakeClock(); - const registry = new TestRegistry( - [{ deviceId: "dev_1", id: "lse_1", ownerId: "tok_agent", requesterId: "gw:inst:agent-1" }], - [ - { id: "dev_1", platform: "ios" }, - { id: "dev_ready", platform: "ios", state: "ready" }, - { id: "dev_leased", platform: "ios", state: "leased" }, - { id: "dev_shutdown", platform: "ios", state: "shutdown" }, - { id: "dev_provisioning", platform: "ios", state: "provisioning" }, - { id: "dev_reclaiming", platform: "ios", state: "reclaiming" }, - { id: "dev_quarantined", platform: "ios", state: "quarantined" }, - { id: "dev_absent", physical: true, platform: "ios", state: "absent" }, - ], - ); + const registry = new TestRegistry([ + { id: "dev_1", platform: "ios" }, + { id: "dev_ready", platform: "ios", state: "ready" }, + { id: "dev_leased", platform: "ios", state: "leased" }, + { id: "dev_shutdown", platform: "ios", state: "shutdown" }, + { id: "dev_provisioning", platform: "ios", state: "provisioning" }, + { id: "dev_reclaiming", platform: "ios", state: "reclaiming" }, + { id: "dev_quarantined", platform: "ios", state: "quarantined" }, + { id: "dev_absent", physical: true, platform: "ios", state: "absent" }, + ]); const feeds: ScriptedFeed[] = []; let opened = 0; const handlers = new Map void)[]>(); @@ -74,7 +71,7 @@ const OPERATOR = { principal: "tok_op", role: "operator" }; const deviceRequest = (worker: string, device: string) => new Request(`http://daemon/v1/stream/workers/${worker}/devices/${device}`); -const relayed = () => new Request("http://simlock-worker/stream/leases/lse_1"); +const relayed = () => new Request("http://simlock-worker/stream/devices/dev_1"); async function settledWithin(promise: Promise): Promise { for (let flush = 0; flush < 10; flush += 1) await new Promise((resolve) => setImmediate(resolve)); @@ -95,7 +92,7 @@ describe("createWorkerStream", () => { it("gives a capture 30 s to send its first frame", async () => { const { clock, feeds, stream } = worker([], false); - const served = stream.relayTarget(relayed(), "gw:inst:agent-1", "lse_1"); + const served = stream.relayTarget(relayed(), "gw:inst:agent-1"); await settledWithin(served); clock.advance(29_999); expect(await settledWithin(served)).toBe("pending"); @@ -107,7 +104,7 @@ describe("createWorkerStream", () => { it("ends a stream whose capture sends nothing for 30 s", async () => { const { clock, feeds, stream } = worker(); - const served = await stream.relayTarget(relayed(), "gw:inst:agent-1", "lse_1"); + const served = await stream.relayTarget(relayed(), "gw:inst:agent-1"); const reader = served.body!.getReader(); await reader.read(); const next = reader.read().then( @@ -140,26 +137,12 @@ describe("createWorkerStream", () => { }); }); - it("serves a relayed request for the requester the gateway names, checked against this worker's registry", async () => { - const { stream } = worker(); - const request = () => new Request("http://simlock-worker/stream/leases/lse_1"); - const served = await stream.relayTarget(request(), "gw:inst:agent-1", "lse_1"); - expect(served.status).toBe(200); - await served.body?.cancel(); - const refused = await stream.relayTarget(request(), "gw:inst:agent-2", "lse_1"); - expect(refused.status).toBe(404); - }); - it("stops a relayed capture when its lease ends", async () => { const { feeds, stream } = worker(); - const served = await stream.relayTarget( - new Request("http://simlock-worker/stream/leases/lse_1"), - "gw:inst:agent-1", - "lse_1", - ); + const served = await stream.relayTarget(relayed(), "gw:inst:agent-1"); expect(served.status).toBe(200); await stream.leaseEnd.leaseEnded( - { leaseId: "lse_1" } as Parameters[0], + { deviceId: "dev_1", leaseId: "lse_1" } as Parameters[0], new AbortController().signal, ); expect(feeds[0]?.closes).toBe(1); @@ -241,7 +224,6 @@ describe("createWorkerStream", () => { const relays: string[] = []; const gateway = createGatewayStream({ fleet: { - holder: () => undefined, knowsWorker: () => true, relay: async (workerId) => { relays.push(workerId); @@ -282,7 +264,6 @@ describe("createWorkerStream", () => { const relays: { workerId: string; url: string; tenant: string }[] = []; const gateway = createGatewayStream({ fleet: { - holder: () => undefined, knowsWorker: (workerId) => workerId in answers, relay: async (workerId, call) => { relays.push({ tenant: call.tenant, url: call.request.url, workerId }); @@ -338,12 +319,6 @@ describe("createWorkerStream", () => { registry.devices = registry.devices.map((device) => device.id === "dev_ready" ? { ...device, state: "leased" } : device, ); - registry.leases.push({ - deviceId: "dev_ready", - id: "lse_2", - ownerId: "tok_agent", - requesterId: "agent-2", - }); feeds[0]?.keyframe([9]); expect((await reader.read()).done).toBe(false); expect(feeds[0]?.closes).toBe(0); diff --git a/src/stream/create.ts b/src/stream/create.ts index a1c5f5bc6..cfe0049cd 100644 --- a/src/stream/create.ts +++ b/src/stream/create.ts @@ -46,7 +46,7 @@ export interface WorkerStream { } // why: the mount, the relay target and the lease-end participant sit on one link and one registry, so -// a stream asked for on this host and one relayed by a gateway go through the same lease check, and a +// a stream asked for on this host and one relayed by a gateway go through the same device check, and a // lease that ends has one place to stop its captures whichever door the picture came in by. export function createWorkerStream(options: WorkerStreamOptions): WorkerStream { const captures = createCaptureRegistry(); diff --git a/src/stream/definition.ts b/src/stream/definition.ts index 6e8193b91..4094d9c76 100644 --- a/src/stream/definition.ts +++ b/src/stream/definition.ts @@ -22,46 +22,6 @@ export const streamModule: OptionalModuleDefinition = { }, http: { routes: [ - { - method: "get", - path: "/v1/stream/leases/{id}", - description: - "Watch the screen of a leased device. Served only when capabilities.stream.enabled is true. A gateway keeps a worker's refusal with its code.", - statuses: { - 200: { - description: "The picture, as a binary body of frames; its codec and size are headers.", - contentTypes: [ - "application/vnd.simlock.stream+h264", - "application/vnd.simlock.stream+mjpeg", - ], - headers: { - "simlock-stream-codec": "The codec of the body: h264 or mjpeg.", - "simlock-stream-fps": "Frames per second.", - "simlock-stream-height": "Picture height in pixels.", - "simlock-stream-width": "Picture width in pixels.", - "x-simlock-lease": "The lease being watched.", - }, - }, - 404: { - description: "No such lease, or no such stream path.", - errorCodes: ["UNKNOWN_LEASE"], - }, - 405: { description: "A stream is read, not written.", errorCodes: ["BAD_REQUEST"] }, - 415: { - description: "The leased device cannot be streamed.", - errorCodes: ["STREAM_NOT_SUPPORTED"], - }, - 500: { description: "The stream failed unexpectedly.", errorCodes: ["INTERNAL"] }, - 502: { - description: "A gateway's worker answered success with no picture.", - errorCodes: ["STREAM_UNAVAILABLE"], - }, - 503: { - description: "The stream is not available, or a gateway cannot reach the worker.", - errorCodes: ["STREAM_UNAVAILABLE", "WORKER_UNREACHABLE"], - }, - }, - }, { method: "get", path: "/v1/stream/workers/{workerId}/devices/{deviceId}", diff --git a/src/stream/gateway-link.test.ts b/src/stream/gateway-link.test.ts index fab8ed699..65a0aaa3e 100644 --- a/src/stream/gateway-link.test.ts +++ b/src/stream/gateway-link.test.ts @@ -4,14 +4,13 @@ import type { StreamCaller } from "./caller.js"; import { createGatewayStreamLink, type FleetPort } from "./gateway-link.js"; import { StreamRefusalError } from "./refusal.js"; -const AGENT: StreamCaller = { principal: "tok_agent", role: "agent" }; -const STRANGER: StreamCaller = { principal: "tok_other", role: "agent" }; +const OPERATOR: StreamCaller = { principal: "tok_op", role: "operator" }; +const DEVICE = { deviceId: "dev_9", workerId: "w1" }; interface Relayed { readonly workerId: string; readonly url: string; readonly tenant: string; - readonly leaseId: string | undefined; } class CodedError extends Error { @@ -27,18 +26,9 @@ function fleet(answer: () => Response): { port: FleetPort; relayed: Relayed[] } const relayed: Relayed[] = []; return { port: { - holder: (leaseId) => - leaseId === "w1.lse_9" - ? { ownerId: "tok_agent", requesterId: "agent-1", workerId: "w1", workerLeaseId: "lse_9" } - : undefined, knowsWorker: () => true, relay: async (workerId, call) => { - relayed.push({ - leaseId: call.leaseId, - tenant: call.tenant, - url: call.request.url, - workerId, - }); + relayed.push({ tenant: call.tenant, url: call.request.url, workerId }); return answer(); }, requesterPrefix: "gw:inst:", @@ -69,42 +59,19 @@ async function refusalOf(served: Promise): Promise const signal = () => new AbortController().signal; describe("the gateway's stream link", () => { - it("relays to the worker holding the lease, by the worker's own lease id, as the lease's namespaced requester", async () => { + it("relays to the worker named, for the operator's namespaced principal, and describes the picture from its headers", async () => { const { port, relayed } = fleet(() => new Response("bytes", { headers: streamHeaders() })); - const stream = await createGatewayStreamLink({ fleet: port }).serve( - "w1.lse_9", - AGENT, - signal(), - ); + const stream = await createGatewayStreamLink({ fleet: port }).serve(DEVICE, OPERATOR, signal()); expect(relayed).toEqual([ { - leaseId: "lse_9", - tenant: "gw:inst:agent-1", - url: "http://simlock-worker/stream/leases/lse_9", + tenant: "gw:inst:tok_op", + url: "http://simlock-worker/stream/devices/dev_9", workerId: "w1", }, ]); expect(stream.meta).toEqual({ codec: "h264", fps: 30, height: 720, width: 330 }); }); - it("answers a lease the fleet does not hold with 404 UNKNOWN_LEASE, relaying nothing", async () => { - const { port, relayed } = fleet(() => new Response("bytes", { headers: streamHeaders() })); - const refusal = await refusalOf( - createGatewayStreamLink({ fleet: port }).serve("w1.lse_ended", AGENT, signal()), - ); - expect([refusal.status, refusal.code]).toEqual([404, "UNKNOWN_LEASE"]); - expect(relayed).toEqual([]); - }); - - it("answers a caller who does not own the lease exactly as an unknown lease, relaying nothing", async () => { - const { port, relayed } = fleet(() => new Response("bytes", { headers: streamHeaders() })); - const refusal = await refusalOf( - createGatewayStreamLink({ fleet: port }).serve("w1.lse_9", STRANGER, signal()), - ); - expect([refusal.status, refusal.code]).toEqual([404, "UNKNOWN_LEASE"]); - expect(relayed).toEqual([]); - }); - it("forwards the worker's own refusal code and status", async () => { const { port } = fleet(() => Response.json( @@ -113,7 +80,7 @@ describe("the gateway's stream link", () => { ), ); const refusal = await refusalOf( - createGatewayStreamLink({ fleet: port }).serve("w1.lse_9", AGENT, signal()), + createGatewayStreamLink({ fleet: port }).serve(DEVICE, OPERATOR, signal()), ); expect([refusal.status, refusal.code, refusal.message]).toEqual([ 415, @@ -127,7 +94,7 @@ describe("the gateway's stream link", () => { Response.json({ error: { code: "