From 5d21e4a8a1f2909a811bb8f5f1fe6e70efdad3a0 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 22 Oct 2026 17:51:22 +0200 Subject: [PATCH 01/33] =?UTF-8?q?test:=20=E2=8B=AF=20=E2=86=92=20Update?= =?UTF-8?q?=E2=80=A6=20=E2=86=92=20Update=20sends=20{}=20and=20shows=20Upd?= =?UTF-8?q?ating=20=20to=20=20(#839)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- e2e/console/update.spec.ts | 70 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 e2e/console/update.spec.ts diff --git a/e2e/console/update.spec.ts b/e2e/console/update.spec.ts new file mode 100644 index 00000000..f81522a8 --- /dev/null +++ b/e2e/console/update.spec.ts @@ -0,0 +1,70 @@ +import type { Page } from "@playwright/test"; + +import { disposeFleet, expect, type RunningDaemon, signIn, startFleet, test } from "./fixtures.js"; + +const IOS = { availableOsVersions: ["18.4"], knownModels: ["iPhone 16"] }; + +type WorkerFields = Record; + +// why: the real fleet never has a newer release to offer, so the list says what the update fields +// would, as actions.spec.ts does for `drained`. +async function showWorkerFields(page: Page, fields: () => WorkerFields): Promise { + await page.route( + (url) => url.pathname === "/v1/workers", + async (route) => { + const response = await route.fetch(); + const body = (await response.json()) as { workers: Record[] }; + await route.fulfill({ + json: { ...body, workers: body.workers.map((view) => ({ ...view, ...fields() })) }, + response, + }); + }, + ); +} + +async function openFleet(page: Page, gateway: RunningDaemon): Promise { + await page.goto(new URL("/fleet", gateway.url).toString()); + await signIn(page, gateway.tokens.operator); + await expect(page.getByRole("heading", { level: 1, name: "Fleet" })).toBeVisible(); +} + +test.describe("the console updates a worker", () => { + test("Update… sends an empty body and the toast names the version", async ({ page }) => { + const fleet = await startFleet([{ driverScript: { ios: IOS }, label: "upd-a" }]); + try { + await showWorkerFields(page, () => ({ + latestVersion: "0.4.0", + lastUpdateFailure: null, + update: null, + version: "0.3.0", + })); + const posts: string[] = []; + await page.route("**/v1/workers/*/update", async (route) => { + posts.push(route.request().postData() ?? ""); + await route.fulfill({ + json: { + started: true, + upToDate: false, + update: { stage: "waiting", leases: 0, from: "0.3.0", to: "0.4.0" }, + version: "0.3.0", + }, + status: 202, + }); + }); + await openFleet(page, fleet.gateway); + const row = page.locator("main tbody tr").filter({ hasText: "upd-a" }); + await expect(row).toContainText("simlock 0.3.0 · 0.4.0 available"); + await row.getByRole("button", { name: "Actions for upd-a" }).click(); + await page.getByRole("menuitem", { name: "Update…" }).click(); + const dialog = page.getByRole("alertdialog", { name: "Update upd-a from 0.3.0 to 0.4.0?" }); + await expect(dialog).toContainText( + "It has no leases, so it installs at once. It restarts by itself.", + ); + await dialog.getByRole("button", { name: "Update", exact: true }).click(); + await expect(page.getByText("Updating upd-a to 0.4.0")).toBeVisible(); + expect(posts).toEqual(["{}"]); + } finally { + await disposeFleet(fleet); + } + }); +}); From 892fba27668e936051156ce1fb82385cd7a096e0 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 22 Oct 2026 17:54:43 +0200 Subject: [PATCH 02/33] =?UTF-8?q?feat(ui):=20a=20worker=20behind=20the=20l?= =?UTF-8?q?atest=20offers=20Update=E2=80=A6=20and=20the=20toast=20names=20?= =?UTF-8?q?the=20version=20(#839)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/src/data/api.ts | 29 ++++++++++-- ui/src/model/actions.ts | 57 ++++++++++++++++++++++- ui/src/model/versions.ts | 35 ++++++++++++++ ui/src/model/worker-page.ts | 9 ++++ ui/src/screens/fleet/index.tsx | 6 ++- ui/src/screens/overview/overview.test.tsx | 1 + ui/src/state/actions.ts | 7 ++- ui/src/state/workers.ts | 8 +++- 8 files changed, 144 insertions(+), 8 deletions(-) create mode 100644 ui/src/model/versions.ts diff --git a/ui/src/data/api.ts b/ui/src/data/api.ts index fc540d02..dac19009 100644 --- a/ui/src/data/api.ts +++ b/ui/src/data/api.ts @@ -30,6 +30,7 @@ interface SendOptions { readonly token?: string; readonly signal?: AbortSignal; readonly timeoutMs?: number; + readonly body?: string; } export async function send( @@ -54,7 +55,11 @@ export async function send( const response = await options.fetch(path, { cache: "no-store", credentials: "same-origin", - headers: options.token === undefined ? {} : { Authorization: `Bearer ${options.token}` }, + headers: { + ...(options.token === undefined ? {} : { Authorization: `Bearer ${options.token}` }), + ...(options.body === undefined ? {} : { "Content-Type": "application/json" }), + }, + ...(options.body === undefined ? {} : { body: options.body }), ...(options.method === undefined ? {} : { method: options.method }), signal: controller.signal, }); @@ -85,7 +90,13 @@ export interface ApiClient { headTimeoutMs?: number, ): Promise>>; healthz(): Promise>; - write(method: "DELETE" | "POST", path: ApiPath): Promise; + // why: the answer is the parsed JSON body, `undefined` when the daemon sent none. + write(method: "DELETE" | "POST", path: ApiPath, options?: WriteOptions): Promise; +} + +export interface WriteOptions { + readonly body?: unknown; + readonly timeoutMs?: number; } export type ApiFailure = "unauthorized" | "not-simlock"; @@ -146,7 +157,19 @@ export function createApiClient(options: ApiClientOptions): ApiClient { if (response.body === null) throw new ApiError(response.status, undefined, "No body"); return { body: response.body, date: response.headers.get("Date") }; }), - write: (method, path) => watched(path, { ...authorized(), method }, async () => undefined), + write: (method, path, writeOptions) => + watched( + path, + { + ...authorized(undefined, writeOptions?.timeoutMs), + method, + ...(writeOptions?.body === undefined ? {} : { body: JSON.stringify(writeOptions.body) }), + }, + async (response) => { + const text = await response.text(); + return text === "" ? undefined : (JSON.parse(text) as unknown); + }, + ), healthz: () => send("/v1/healthz", { fetch: options.fetch }, async (response) => ({ body: response.ok, diff --git a/ui/src/model/actions.ts b/ui/src/model/actions.ts index 954217f6..593fd040 100644 --- a/ui/src/model/actions.ts +++ b/ui/src/model/actions.ts @@ -2,6 +2,7 @@ // wording of each dialog and toast is fixed here, so no surface words it differently. import type { InboxItem } from "./inbox"; import { type TokenRecord, leaseRows } from "./leases"; +import { isBehind, type UpdateFacts } from "./versions"; import { requestedDevice } from "./waiting"; import { type WorkerView, workerName } from "./workers"; @@ -22,7 +23,13 @@ export interface ActionToast { } export interface ItemAction { - readonly id: "release-lease" | "cancel-request" | "drain" | "undrain" | "remove-worker"; + readonly id: + | "release-lease" + | "cancel-request" + | "drain" + | "undrain" + | "remove-worker" + | "update-worker"; readonly itemKey: string; readonly label: string; readonly danger: boolean; @@ -30,6 +37,11 @@ export interface ItemAction { readonly path: `/v1/${string}`; readonly confirm?: ActionConfirm; readonly success: ActionToast; + // why: an action that sends a JSON body, `{}` for an update to the latest version. + readonly body?: Readonly>; + // why: the daemon's answer to the request, when the toast it earns depends on what it says. + readonly outcome?: (answer: unknown) => ActionToast; + readonly timeoutMs?: number; // why: the title of the error toast when the daemon refuses, word for word from the spec. readonly failure: string; // why: an action that asks nothing can be undone from its toast, by running this one. @@ -106,6 +118,42 @@ export interface WorkerActionInput { readonly name: string; readonly connection: WorkerView["connection"]; readonly drained: boolean; + readonly updateFacts?: UpdateFacts; +} + +// why: the daemon's answer to an update request: `daemon.update`'s answer with the worker's id. +interface UpdateAnswer { + readonly update?: { readonly to?: string } | null; +} + +function updateToast(name: string): (answer: unknown) => ActionToast { + return (answer) => ({ + title: `Updating ${name} to ${(answer as UpdateAnswer).update?.to ?? "the latest version"}`, + }); +} + +function updateAction(worker: WorkerActionInput, facts: UpdateFacts): ItemAction { + const from = facts.version === undefined ? "" : ` from ${facts.version}`; + return { + body: {}, + confirm: { + body: "It has no leases, so it installs at once. It restarts by itself.", + busy: "Starting…", + confirm: "Update", + title: `Update ${worker.name}${from} to ${facts.latestVersion ?? "the latest version"}?`, + }, + danger: false, + failure: `Couldn't update ${worker.name}`, + id: "update-worker", + itemKey: `worker/${worker.id}`, + label: "Update…", + method: "POST", + outcome: updateToast(worker.name), + path: `/v1/workers/${encodeURIComponent(worker.id)}/update`, + // why: above the gateway's 90 s uplink limit, so the gateway's answer arrives before the console gives up. + success: { title: `Updating ${worker.name}` }, + timeoutMs: 100_000, + }; } // why: ADR 0037 §4. On a single host the three worker routes answer 501, and when the mode is not @@ -142,6 +190,13 @@ export function workerActions( undo: undrain, }; const actions: ItemAction[] = [worker.drained ? undrain : drain]; + if ( + worker.updateFacts !== undefined && + worker.connection === "connected" && + isBehind(worker.updateFacts) + ) { + actions.unshift(updateAction(worker, worker.updateFacts)); + } if (worker.connection === "disconnected") { actions.push({ confirm: { diff --git a/ui/src/model/versions.ts b/ui/src/model/versions.ts new file mode 100644 index 00000000..a35e989e --- /dev/null +++ b/ui/src/model/versions.ts @@ -0,0 +1,35 @@ +import { isNewerVersion } from "../../../src/contract/index"; +import type { WorkerView } from "./workers"; + +// why: the facts of a worker's update that every surface reads the same way: the version it runs, +// the latest the gateway knows, the update in flight and how many devices hold a lease. +export interface UpdateFacts { + readonly version?: string; + readonly latestVersion: string | null; + readonly update: Exclude; + readonly leased: number; +} + +export function updateFactsOf(worker: WorkerView): UpdateFacts { + return { + latestVersion: worker.latestVersion ?? null, + leased: (worker.devices ?? []).filter((device) => device.state === "leased").length, + update: worker.update ?? null, + ...(worker.version === undefined ? {} : { version: worker.version }), + }; +} + +// why: the "newer" rule is the contract's (`isNewerVersion`); the console never compares itself. +export function isBehind(facts: UpdateFacts): boolean { + return ( + facts.version !== undefined && + facts.latestVersion !== null && + isNewerVersion(facts.latestVersion, facts.version) + ); +} + +export function versionLine(facts: UpdateFacts): string | undefined { + if (facts.version === undefined) return undefined; + const base = `simlock ${facts.version}`; + return isBehind(facts) ? `${base} · ${facts.latestVersion} available` : base; +} diff --git a/ui/src/model/worker-page.ts b/ui/src/model/worker-page.ts index 905e55d8..7ade2513 100644 --- a/ui/src/model/worker-page.ts +++ b/ui/src/model/worker-page.ts @@ -12,6 +12,7 @@ import { levelOf } from "./load-thresholds"; import { holderOf, leasePath, type TokenRecord } from "./leases"; import { devicePath } from "./paths"; import { formatDuration } from "./time"; +import { type UpdateFacts, updateFactsOf, versionLine } from "./versions"; import { connectionStatus, deviceStateStatus, @@ -69,6 +70,8 @@ export interface WorkerRow { readonly to: string; readonly health: Toned; readonly system?: string; + readonly version?: string; + readonly updateFacts: UpdateFacts; readonly use?: WorkerUse; } @@ -91,6 +94,8 @@ export function workerRow(worker: WorkerView, search = ""): WorkerRow { const use = useOf(worker); const system = systemOf(worker); const slots = workerSlots(worker); + const updateFacts = updateFactsOf(worker); + const version = versionLine(updateFacts); return { connection: worker.connection, takesLeases: !worker.drained, @@ -106,7 +111,9 @@ export function workerRow(worker: WorkerView, search = ""): WorkerRow { mono: worker.label === undefined, name: workerName(worker), to: `${workerPath(worker.id)}${search}`, + updateFacts, ...(system === undefined ? {} : { system }), + ...(version === undefined ? {} : { version }), ...(use === undefined ? {} : { use }), }; } @@ -594,6 +601,7 @@ export interface WorkerPage { readonly mono: boolean; readonly health: Toned; readonly system?: string; + readonly updateFacts: UpdateFacts; readonly tabs: readonly { readonly value: WorkerTab; readonly label: string }[]; readonly overview: OverviewTab; readonly host: HostTab; @@ -646,6 +654,7 @@ export function workerPageOf(input: { }, tabs, title: workerName(worker), + updateFacts: updateFactsOf(worker), ...(host === undefined ? {} : { system: `${host.os} ${host.osVersion}` }), ...(starting ? {} : { warm: warmTabOf(worker, now) }), }; diff --git a/ui/src/screens/fleet/index.tsx b/ui/src/screens/fleet/index.tsx index b93ab2e9..fb6a0a3c 100644 --- a/ui/src/screens/fleet/index.tsx +++ b/ui/src/screens/fleet/index.tsx @@ -108,7 +108,11 @@ const COLUMNS: readonly Column[] = [ {row.name} - {row.system === undefined ? null : {row.system}} + {row.system === undefined && row.version === undefined ? null : ( + + {[row.system, row.version].filter((part) => part !== undefined).join(" · ")} + + )} ), header: "Worker", diff --git a/ui/src/screens/overview/overview.test.tsx b/ui/src/screens/overview/overview.test.tsx index cc1b4763..ba3205d4 100644 --- a/ui/src/screens/overview/overview.test.tsx +++ b/ui/src/screens/overview/overview.test.tsx @@ -143,6 +143,7 @@ describe("the overview screen", () => { slots: [], slotsLabel: "No devices", to: `/fleet/w${index}`, + updateFacts: { latestVersion: null, leased: 0, update: null }, })), }} />, diff --git a/ui/src/state/actions.ts b/ui/src/state/actions.ts index 409c813b..7c3791f3 100644 --- a/ui/src/state/actions.ts +++ b/ui/src/state/actions.ts @@ -49,8 +49,11 @@ export function useActions(): Actions { if (inFlight.has(action.itemKey)) return; setInFlight(action.itemKey, true); try { - await api.write(action.method, action.path); - const { description, title, view } = action.success; + const answer = await api.write(action.method, action.path, { + ...(action.body === undefined ? {} : { body: action.body }), + ...(action.timeoutMs === undefined ? {} : { timeoutMs: action.timeoutMs }), + }); + const { description, title, view } = action.outcome?.(answer) ?? action.success; const { undo } = action; notify({ title, diff --git a/ui/src/state/workers.ts b/ui/src/state/workers.ts index fdbd7dd7..127748f2 100644 --- a/ui/src/state/workers.ts +++ b/ui/src/state/workers.ts @@ -62,7 +62,13 @@ export function useWorkerPageMenu(page: WorkerPage): readonly MenuEntry[] { const mode = useDaemonMode(); return entries( workerActions( - { connection: page.connection, drained: page.drained, id: page.id, name: page.title }, + { + connection: page.connection, + drained: page.drained, + id: page.id, + name: page.title, + updateFacts: page.updateFacts, + }, mode, ), ); From fcaa015da6413aeebcbd195ef25c22dd5a439fef Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 22 Oct 2026 17:55:18 +0200 Subject: [PATCH 03/33] test: an action sends its body, write resolves the parsed answer (#839) --- ui/src/data/api.test.ts | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/ui/src/data/api.test.ts b/ui/src/data/api.test.ts index 89904048..06770e8b 100644 --- a/ui/src/data/api.test.ts +++ b/ui/src/data/api.test.ts @@ -180,6 +180,44 @@ describe("createApiClient", () => { expect(writeRefused).not.toHaveBeenCalled(); }); + it("an action sends its body: write sends a JSON body with its content type, resolves the parsed answer or undefined, and waits as long as asked", async () => { + const sent = answering(202, { started: true }); + const writer = createApiClient({ fetch: sent.fetch, token: () => "slk_operator" }); + + const answer = await writer.write("POST", "/v1/workers/wrk_1/update", { + body: { force: true }, + }); + + expect(answer).toEqual({ started: true }); + expect(sent.calls[0]?.init.body).toBe('{"force":true}'); + expect(sent.calls[0]?.init.headers).toEqual({ + Authorization: "Bearer slk_operator", + "Content-Type": "application/json", + }); + await writer.write("DELETE", "/v1/leases/lse_1"); + expect(sent.calls[1]?.init.body).toBeUndefined(); + expect(sent.calls[1]?.init.headers).toEqual({ Authorization: "Bearer slk_operator" }); + + const empty = createApiClient({ + fetch: async () => new Response(null, { status: 204 }), + token: () => "slk_operator", + }); + expect(await empty.write("DELETE", "/v1/leases/lse_1")).toBeUndefined(); + + vi.useFakeTimers(); + const slow = createApiClient({ fetch: hanging, signOut: () => {}, token: () => "slk_op" }); + const outcome = slow + .write("POST", "/v1/workers/wrk_1/update", { body: {}, timeoutMs: 100_000 }) + .then( + () => "answered", + (error: unknown) => error, + ); + await vi.advanceTimersByTimeAsync(99_999); + expect(await Promise.race([outcome, Promise.resolve("pending")])).toBe("pending"); + await vi.advanceTimersByTimeAsync(1); + expect(await outcome).toBeInstanceOf(RequestTimeoutError); + }); + it("a read hands back the response's Date header with its body", async () => { const date = "Fri, 02 Oct 2026 12:00:00 GMT"; const fetch: Fetch = async () => From ae33e1a6d03044df62213590cf23cb45e68f91e6 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 22 Oct 2026 17:56:16 +0200 Subject: [PATCH 04/33] feat(ui): a worker's version line shows beside its page title (#839) --- ui/src/ds/sidebar.css | 18 +++++++++++++++ ui/src/ds/sidebar.tsx | 14 ++++++++++-- ui/src/model/worker-page.test.ts | 33 ++++++++++++++++++++++++++++ ui/src/model/worker-page.ts | 6 ++++- ui/src/screens/fleet/worker-page.tsx | 1 + 5 files changed, 69 insertions(+), 3 deletions(-) diff --git a/ui/src/ds/sidebar.css b/ui/src/ds/sidebar.css index b03b4c73..472f5bb6 100644 --- a/ui/src/ds/sidebar.css +++ b/ui/src/ds/sidebar.css @@ -328,6 +328,24 @@ font-size: var(--text-xl); } +.page-title { + display: flex; + flex: 1; + flex-wrap: wrap; + align-items: baseline; + gap: var(--space-3); +} + +.page-title h1 { + flex: none; +} + +.page-note { + color: var(--text-muted); + font-family: var(--font-mono); + font-size: var(--text-sm); +} + .page-actions { display: flex; gap: var(--space-2); diff --git a/ui/src/ds/sidebar.tsx b/ui/src/ds/sidebar.tsx index c8e28b69..813fa9fb 100644 --- a/ui/src/ds/sidebar.tsx +++ b/ui/src/ds/sidebar.tsx @@ -168,14 +168,24 @@ function NavGroup(props: NavListProps & { readonly label: string }) { export function Page(props: { readonly title: string; + // why: a muted line of plain text that sits beside the title, as a worker's version does. + readonly note?: string; readonly actions?: ReactNode; readonly children: ReactNode; }) { - const { actions, children, title } = props; + const { actions, children, note, title } = props; + const heading =

{title}

; return (
-

{title}

+ {note === undefined ? ( + heading + ) : ( +
+ {heading} + {note} +
+ )} {actions === undefined ? null :
{actions}
}
{children} diff --git a/ui/src/model/worker-page.test.ts b/ui/src/model/worker-page.test.ts index 3b6bc1d6..8050a7f7 100644 --- a/ui/src/model/worker-page.test.ts +++ b/ui/src/model/worker-page.test.ts @@ -142,6 +142,39 @@ describe("a worker's row in the list", () => { }); }); +describe("a worker's version line", () => { + it("shows the version and the latest when newer, on the row and beside the page title, and nothing without a version", () => { + const cases: readonly [ + string, + string | undefined, + string | null | undefined, + string | undefined, + ][] = [ + ["newer", "0.3.0", "0.4.0", "simlock 0.3.0 · 0.4.0 available"], + ["equal", "0.4.0", "0.4.0", "simlock 0.4.0"], + ["older", "0.5.0", "0.4.0", "simlock 0.5.0"], + ["no latest known", "0.3.0", null, "simlock 0.3.0"], + ["latest not reported", "0.3.0", undefined, "simlock 0.3.0"], + ["no version", undefined, "0.4.0", undefined], + [ + "prerelease is behind its release", + "0.4.0-rc.1", + "0.4.0", + "simlock 0.4.0-rc.1 · 0.4.0 available", + ], + ["release is not behind its prerelease", "0.4.0", "0.4.0-rc.1", "simlock 0.4.0"], + ]; + for (const [name, version, latestVersion, line] of cases) { + const view = worker({ + ...(version === undefined ? {} : { version }), + ...(latestVersion === undefined ? {} : { latestVersion }), + }); + expect(workerRow(view).version, `${name}, row`).toBe(line); + expect(inspect(view).version, `${name}, page`).toBe(line); + } + }); +}); + describe("the list's filters and header", () => { const busy = worker({ id: "wrk_busy", diff --git a/ui/src/model/worker-page.ts b/ui/src/model/worker-page.ts index 7ade2513..73b23b43 100644 --- a/ui/src/model/worker-page.ts +++ b/ui/src/model/worker-page.ts @@ -601,6 +601,7 @@ export interface WorkerPage { readonly mono: boolean; readonly health: Toned; readonly system?: string; + readonly version?: string; readonly updateFacts: UpdateFacts; readonly tabs: readonly { readonly value: WorkerTab; readonly label: string }[]; readonly overview: OverviewTab; @@ -616,6 +617,8 @@ export function workerPageOf(input: { readonly leaseIds?: readonly string[]; }): WorkerPage { const { now, tokens, worker } = input; + const updateFacts = updateFactsOf(worker); + const version = versionLine(updateFacts); const leaseIds = new Set(input.leaseIds ?? []); const starting = worker.health === "starting"; const incompatible = worker.connection === "incompatible"; @@ -654,7 +657,8 @@ export function workerPageOf(input: { }, tabs, title: workerName(worker), - updateFacts: updateFactsOf(worker), + updateFacts, + ...(version === undefined ? {} : { version }), ...(host === undefined ? {} : { system: `${host.os} ${host.osVersion}` }), ...(starting ? {} : { warm: warmTabOf(worker, now) }), }; diff --git a/ui/src/screens/fleet/worker-page.tsx b/ui/src/screens/fleet/worker-page.tsx index 15b74d97..064d39fe 100644 --- a/ui/src/screens/fleet/worker-page.tsx +++ b/ui/src/screens/fleet/worker-page.tsx @@ -65,6 +65,7 @@ export function WorkerPageBody({ page }: { readonly page: WorkerPageView }) { return ( } >