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("