diff --git a/.dependency-cruiser.mjs b/.dependency-cruiser.mjs index 7008d9daf..77dc767ff 100644 --- a/.dependency-cruiser.mjs +++ b/.dependency-cruiser.mjs @@ -23,6 +23,14 @@ const components = findComponents("src") .filter((dir) => dir !== "src") .flatMap((dir) => [dir, `dist${dir.slice("src".length)}`]); +// why: the console imports the contract's `isNewerVersion` as a value; the index also exports the +// zod schemas, which would put zod's `eval` in the bundle and break its Content-Security-Policy. +// Maintainer-authorized 2026-10-22 (#825 Decisions, "Console import of the newer rule"): this one file only. +const entryFiles = (component) => + component === "src/contract" + ? "index|testing|definition|version-order" + : "index|testing|definition"; + const pastIndex = components.map((component) => ({ name: "past-index", comment: `${component} is entered through its index`, @@ -30,7 +38,7 @@ const pastIndex = components.map((component) => ({ from: { pathNot: `^${escape(component)}/` }, to: { path: `^${escape(component)}/`, - pathNot: `^${escape(component)}/(index|testing|definition)\\.(ts|js)$`, + pathNot: `^${escape(component)}/(${entryFiles(component)})\\.(ts|js)$`, }, })); diff --git a/docs/internal/agent-rules/architecture.md b/docs/internal/agent-rules/architecture.md index 5aa8dec37..0b7a24dab 100644 --- a/docs/internal/agent-rules/architecture.md +++ b/docs/internal/agent-rules/architecture.md @@ -112,7 +112,9 @@ grounds for rejecting a change even if it works. through a third: a cycle means they are one module or a decision sits in the wrong one; a unit test enforces it. The breaches that predate the check are listed in `.dependency-cruiser-known-violations.json` and - `.dependency-cruiser-known-cycles.json`; those lists only shrink. A single-file + `.dependency-cruiser-known-cycles.json`; those lists only shrink. The one + authorized exemption is the console's import of `src/contract/version-order.ts` + by its own path (#825 Decisions, `.dependency-cruiser.mjs`). A single-file component's surface is what the file exports, and a component whose surface has grown past one file becomes a directory with an index, not a second file others reach into. Each module's manual, diff --git a/e2e/console/update.spec.ts b/e2e/console/update.spec.ts new file mode 100644 index 000000000..daeefeb57 --- /dev/null +++ b/e2e/console/update.spec.ts @@ -0,0 +1,122 @@ +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", + })); + await page.route( + (url) => url.pathname === "/v1/status", + async (route) => { + const response = await route.fetch(); + const body = (await response.json()) as { daemon: Record }; + await route.fulfill({ + json: { ...body, daemon: { ...body.daemon, latestVersion: "0.4.0", version: "0.3.0" } }, + response, + }); + }, + ); + 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 expect(page.locator(".update-notice")).toHaveText("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); + } + }); + + test("Update now → Install now sends force, a 200 upToDate answer names the version, and a 409 shows its message", async ({ + page, + }) => { + const fleet = await startFleet([{ driverScript: { ios: IOS }, label: "upd-b" }]); + try { + await showWorkerFields(page, () => ({ + latestVersion: "0.4.0", + lastUpdateFailure: null, + update: { stage: "waiting", leases: 2, from: "0.3.0", to: "0.4.0" }, + version: "0.3.0", + })); + const answers = [ + { json: { started: false, upToDate: true, update: null, version: "0.4.0" }, status: 200 }, + { + json: { error: { code: "CONFLICT", message: "Another update is running." } }, + status: 409, + }, + ]; + const posts: string[] = []; + await page.route("**/v1/workers/*/update", async (route) => { + posts.push(route.request().postData() ?? ""); + await route.fulfill(answers[posts.length - 1] ?? { status: 500 }); + }); + await openFleet(page, fleet.gateway); + const row = page.locator("main tbody tr").filter({ hasText: "upd-b" }); + const toasts = page.getByRole("region", { name: "Notifications" }); + for (const toast of ["upd-b already runs 0.4.0", "Another update is running."]) { + await row.getByRole("button", { name: "Actions for upd-b" }).click(); + await page.getByRole("menuitem", { name: "Update now" }).click(); + const dialog = page.getByRole("alertdialog", { name: "Install 0.4.0 on upd-b now?" }); + await expect(dialog).toContainText("Its 2 leases keep running through the restart."); + await dialog.getByRole("button", { name: "Install now" }).click(); + await expect(toasts.getByText(toast, { exact: true })).toBeVisible(); + } + expect(posts).toEqual(['{"force":true}', '{"force":true}']); + } finally { + await disposeFleet(fleet); + } + }); +}); diff --git a/e2e/console/workers.spec.ts b/e2e/console/workers.spec.ts index 5e3238f0a..d6e1dc02b 100644 --- a/e2e/console/workers.spec.ts +++ b/e2e/console/workers.spec.ts @@ -293,16 +293,21 @@ function expectedRow(view: WorkerView): { readonly bars: number; } { const name = view.label ?? view.id; + const system = [ + view.host === undefined ? "" : `${view.host.os} ${view.host.osVersion}`, + view.version === undefined ? "" : `simlock ${view.version}`, + ] + .filter((part) => part !== "") + .join(" · "); if (view.connection === "incompatible") { return { bars: 0, slots: "No devices", - text: [name, "incompatible", "", "—", "—", "—", "—", ""], + text: [`${name}${system}`, "incompatible", "", "—", "—", "—", "—", ""], }; } const limit = (view.capacity?.ios.limit ?? 0) + (view.capacity?.android.limit ?? 0); const leased = view.leases.length; - const system = view.host === undefined ? "" : `${view.host.os} ${view.host.osVersion}`; return { bars: 3, slots: leased === 0 ? `${limit} free` : `${leased} leased, ${limit - leased} free`, diff --git a/src/contract/AGENTS.md b/src/contract/AGENTS.md index 890324419..c42a026ec 100644 --- a/src/contract/AGENTS.md +++ b/src/contract/AGENTS.md @@ -9,20 +9,20 @@ Imported by: daemon, gateway, simlock-client, every frontend, core, leasing, his ## Files -| File | Role | -| ------------------ | ----------------------------------------------------------------------------------------------------- | -| `operations.ts` | Every operation: name, role, effect, input, output, `authorize`; `OPERATIONS` | -| `schemas.ts` | Wire shapes, re-declared by hand from core; `fit*` bounders | -| `roles.ts` | `Role`, `AuthorizeContext`, `ownsLease` | -| `errors.ts` | `ErrorDetailsMap`, `ERROR_TABLE`, `SimlockError`, `fromWireError` | -| `pushes.ts` | `PUSH_SCHEMAS`: every push kind | -| `protocol.ts` | `PROTOCOL_VERSION_RANGE`, negotiation, `hello` schemas | -| `worker-view.ts` | `workerViewFields`, view refresh interval and events | -| `os-range.ts` | OS constraint grammar: parse, `satisfies`, `compareVersions` | -| `version-order.ts` | `isNewerVersion(candidate, running)`: the one "newer" rule (semver order) every update surface shares | -| `duration.ts` | `parseDurationMs`, for the CLI and HTTP | -| `relay.ts` | Bounded relay ids, channel ids, headers | -| `console-url.ts` | The console address `status.get` reports | +| File | Role | +| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `operations.ts` | Every operation: name, role, effect, input, output, `authorize`; `OPERATIONS` | +| `schemas.ts` | Wire shapes, re-declared by hand from core; `fit*` bounders | +| `roles.ts` | `Role`, `AuthorizeContext`, `ownsLease` | +| `errors.ts` | `ErrorDetailsMap`, `ERROR_TABLE`, `SimlockError`, `fromWireError` | +| `pushes.ts` | `PUSH_SCHEMAS`: every push kind | +| `protocol.ts` | `PROTOCOL_VERSION_RANGE`, negotiation, `hello` schemas | +| `worker-view.ts` | `workerViewFields`, view refresh interval and events | +| `os-range.ts` | OS constraint grammar: parse, `satisfies`, `compareVersions` | +| `version-order.ts` | `isNewerVersion(candidate, running)`: the one "newer" rule (semver order) every update surface shares; the console imports it by this path, not the index (the index carries zod) | +| `duration.ts` | `parseDurationMs`, for the CLI and HTTP | +| `relay.ts` | Bounded relay ids, channel ids, headers | +| `console-url.ts` | The console address `status.get` reports | ## Operations diff --git a/ui/AGENTS.md b/ui/AGENTS.md index b15c49940..81bd2d294 100644 --- a/ui/AGENTS.md +++ b/ui/AGENTS.md @@ -2,7 +2,7 @@ Does: the operator's web console — a React page the daemon serves from `dist/ui`. Entry: `src/main.tsx` → `App` (`src/app/app.tsx`). -Uses: the daemon's `/v1` HTTP API and event stream; contract types from `src/contract/` (type-only). +Uses: the daemon's `/v1` HTTP API and event stream; contract types from `src/contract/` (type-only), and `version-order.ts`'s `isNewerVersion` as a value, imported by its own path (the index would pull the schemas, and zod's `eval`, into the bundle); this one `past-index` exemption in `.dependency-cruiser.mjs` is maintainer-authorized (#825 Decisions). Surface: [console.mdx](../website/src/content/docs/docs/worker/console.mdx). Look and voice: [DESIGN.md](../docs/internal/DESIGN.md). ## Layers @@ -48,9 +48,12 @@ asks it. The live connection asks `session.lost()` when it loses the stream, in the same check: reconnect at once, back off as for an unreachable daemon, or stop while the session ends. `model/proxy-refusal.ts` words a refusal. -`data/mode.ts` keeps the daemon's mode (`daemon.mode` of `GET /v1/status`, the one field the console -reads from that route) once the live connection starts and again after each reconnect; a read that -fails leaves none. `useDaemonMode` (`state/live.tsx`) gives it to state (ADR 0037 §4). +`data/mode.ts` keeps the daemon's mode, `daemon.version` and `daemon.latestVersion` (the fields of +`GET /v1/status` the console reads) once the live connection starts and again after each reconnect; a +read that fails leaves none. The same read repeats every 10 minutes (the daily check emits no event); +a failed repeat keeps the last values, and the timer stops with the live connection. `useDaemonMode` +and `useDaemonFacts` (`state/live.tsx`) give them to state and to the sidebar's update notice +(ADR 0037 §4, ADR 0042). ## Actions diff --git a/ui/src/app/shell.test.tsx b/ui/src/app/shell.test.tsx new file mode 100644 index 000000000..f8bcba1ea --- /dev/null +++ b/ui/src/app/shell.test.tsx @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; + +import type { DaemonFacts, DaemonMode } from "../data/mode"; +import { inConsole } from "../state/test-support"; +import { UpdateNotice } from "./shell"; + +function textOf(markup: string): string { + let text = ""; + let inTag = false; + for (const char of markup) { + if (char === "<") inTag = true; + else if (char === ">") inTag = false; + else if (!inTag) text += char; + } + return text; +} + +function footer(options: { + readonly mode: DaemonMode | undefined; + readonly daemon: DaemonFacts; + readonly collapsed?: boolean; +}): string { + return textOf( + inConsole(, { + daemon: () => options.daemon, + mode: () => options.mode, + }), + ); +} + +describe("the sidebar footer", () => { + it("tells a gateway's operator its own latest version when it is newer, and nothing otherwise", () => { + const cases = [ + { + name: "newer", + mode: "gateway", + daemon: { version: "0.3.0", latestVersion: "0.4.0" }, + collapsed: false, + shown: "simlock 0.3.0 · 0.4.0 available", + }, + { + name: "equal", + mode: "gateway", + daemon: { version: "0.4.0", latestVersion: "0.4.0" }, + collapsed: false, + shown: "", + }, + { + name: "none known", + mode: "gateway", + daemon: { version: "0.3.0", latestVersion: null }, + collapsed: false, + shown: "", + }, + { + name: "collapsed", + mode: "gateway", + daemon: { version: "0.3.0", latestVersion: "0.4.0" }, + collapsed: true, + shown: "", + }, + { + name: "worker", + mode: "worker", + daemon: { version: "0.3.0", latestVersion: "0.4.0" }, + collapsed: false, + shown: "", + }, + { + name: "mode unknown", + mode: undefined, + daemon: { version: "0.3.0", latestVersion: "0.4.0" }, + collapsed: false, + shown: "", + }, + ] as const; + + expect(cases.map((entry) => ({ name: entry.name, shown: footer(entry) }))).toEqual( + cases.map((entry) => ({ name: entry.name, shown: entry.shown })), + ); + }); +}); diff --git a/ui/src/app/shell.tsx b/ui/src/app/shell.tsx index 83b78d137..3b1061576 100644 --- a/ui/src/app/shell.tsx +++ b/ui/src/app/shell.tsx @@ -1,7 +1,9 @@ import { useEffect } from "react"; import { AppFrame, Button, CommandPalette, Icon, Sidebar, Status, StatusDot } from "../ds"; +import { isBehind, versionLine } from "../model/versions"; import { NotFoundScreen } from "../screens/not-found"; +import { useDaemonFacts, useDaemonMode } from "../state/live"; import { usePath } from "../state/router"; import { useSearchPalette, useSearchSession } from "../state/search"; import { @@ -55,11 +57,21 @@ export function Shell() { ); } +// why: the gateway's own version is the operator's to act on, so a worker's console and a +// collapsed sidebar stay quiet; the "newer" rule is the contract's, through `versionLine`. +export function UpdateNotice({ collapsed }: { readonly collapsed: boolean }) { + const mode = useDaemonMode(); + const facts = { ...useDaemonFacts(), leased: 0, update: null }; + if (collapsed || mode !== "gateway" || !isBehind(facts)) return null; + return

{versionLine(facts)}

; +} + function SidebarFoot({ collapsed }: { readonly collapsed: boolean }) { const signedIn = useSignedIn(); const signOut = useSignOut(); return ( <> + {signedIn.name === undefined || collapsed ? null : (

Signed in as {signedIn.name}

diff --git a/ui/src/data/api.test.ts b/ui/src/data/api.test.ts index 89904048f..06770e8b6 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 () => diff --git a/ui/src/data/api.ts b/ui/src/data/api.ts index fc540d027..dac19009c 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/data/live/test-support.ts b/ui/src/data/live/test-support.ts index 6ad5e445e..4d22b37cd 100644 --- a/ui/src/data/live/test-support.ts +++ b/ui/src/data/live/test-support.ts @@ -9,7 +9,7 @@ import { type Visibility, } from "./connection"; -const fakeClock: Clock = { +export const fakeClock: Clock = { now: () => Date.now(), setTimeout: (run, ms) => setTimeout(run, ms), clearTimeout: (handle) => clearTimeout(handle as ReturnType), diff --git a/ui/src/data/mode.test.ts b/ui/src/data/mode.test.ts index 94cfb94ed..f78ac25a5 100644 --- a/ui/src/data/mode.test.ts +++ b/ui/src/data/mode.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { ApiError, type ApiResponse } from "./api"; import { watchMode } from "./mode"; -import { connect, settle } from "./live/test-support"; +import { connect, fakeClock, settle } from "./live/test-support"; afterEach(() => { vi.useRealTimers(); @@ -20,7 +20,7 @@ describe("the daemon's mode", () => { : Promise.resolve({ body: { workers: [] } as unknown, date: null }); const seen: unknown[] = []; - const store = watchMode({ api: daemon, connection }); + const store = watchMode({ api: daemon, clock: fakeClock, connection }); store.subscribe(() => seen.push(store.mode())); await settle(); expect(store.mode()).toBe("gateway"); @@ -48,3 +48,69 @@ describe("the daemon's mode", () => { expect(seen).toEqual(["gateway", "worker", undefined, "gateway"]); }); }); + +describe("the daemon's version facts", () => { + it("are read again every 10 minutes, kept when a re-read fails, and no longer read once the store is disposed", async () => { + const { connection, daemon } = connect(); + let status: () => Promise> = () => + Promise.resolve({ + body: { daemon: { latestVersion: null, mode: "gateway", version: "0.3.0" } }, + date: null, + }); + let statusReads = 0; + daemon.reads = (path) => + path === "/v1/status" + ? ((statusReads += 1), status()) + : Promise.resolve({ body: { workers: [] } as unknown, date: null }); + // why: a stream that never opens cannot reconnect, so only the 10-minute timer re-reads. + daemon.holdStreams = true; + const store = watchMode({ api: daemon, clock: fakeClock, connection }); + await settle(); + expect(store.daemon()).toEqual({ latestVersion: null, version: "0.3.0" }); + + status = () => + Promise.resolve({ + body: { daemon: { latestVersion: "0.4.0", mode: "gateway", version: "0.3.0" } }, + date: null, + }); + await vi.advanceTimersByTimeAsync(599_000); + expect({ reads: statusReads, facts: store.daemon() }).toEqual({ + facts: { latestVersion: null, version: "0.3.0" }, + reads: 1, + }); + await vi.advanceTimersByTimeAsync(1_000); + expect({ reads: statusReads, facts: store.daemon(), mode: store.mode() }).toEqual({ + facts: { latestVersion: "0.4.0", version: "0.3.0" }, + mode: "gateway", + reads: 2, + }); + + status = () => Promise.reject(new ApiError(503, "INTERNAL", "Busy")); + await vi.advanceTimersByTimeAsync(600_000); + expect({ reads: statusReads, facts: store.daemon(), mode: store.mode() }).toEqual({ + facts: { latestVersion: "0.4.0", version: "0.3.0" }, + mode: "gateway", + reads: 3, + }); + + const reading = (version: unknown) => () => + Promise.resolve({ + body: { daemon: { latestVersion: "0.4.0", mode: "gateway", version } }, + date: null, + }); + status = reading("0.4.0"); + await vi.advanceTimersByTimeAsync(600_000); + expect(store.daemon()).toEqual({ latestVersion: "0.4.0", version: "0.4.0" }); + + status = reading(4); + await vi.advanceTimersByTimeAsync(600_000); + expect({ reads: statusReads, facts: store.daemon() }).toEqual({ + facts: { latestVersion: "0.4.0" }, + reads: 5, + }); + + store.dispose(); + await vi.advanceTimersByTimeAsync(1_200_000); + expect(statusReads).toBe(5); + }); +}); diff --git a/ui/src/data/mode.ts b/ui/src/data/mode.ts index 00584e6bf..fa758144d 100644 --- a/ui/src/data/mode.ts +++ b/ui/src/data/mode.ts @@ -1,50 +1,100 @@ import type { ApiClient } from "./api"; -import type { LiveConnection } from "./live/connection"; +import type { Clock, LiveConnection } from "./live/connection"; export type DaemonMode = "gateway" | "worker"; +// why: what the footer reads of the daemon beside its mode: the version it runs and the latest the +// gateway knows (`null` when it knows none). +export interface DaemonFacts { + readonly version?: string; + readonly latestVersion: string | null; +} + export interface ModeStore { readonly mode: () => DaemonMode | undefined; + readonly daemon: () => DaemonFacts; readonly subscribe: (listener: () => void) => () => void; + readonly dispose: () => void; } interface StatusBody { - readonly daemon?: { readonly mode?: unknown }; + readonly daemon?: { + readonly mode?: unknown; + readonly version?: unknown; + readonly latestVersion?: unknown; + }; +} + +const NO_FACTS: DaemonFacts = { latestVersion: null }; +const RE_READ_MS = 600_000; + +interface Reading { + readonly mode: DaemonMode | undefined; + readonly facts: DaemonFacts; } -function modeOf(body: StatusBody): DaemonMode | undefined { - const mode = body.daemon?.mode; - return mode === "gateway" || mode === "worker" ? mode : undefined; +function readingOf(body: StatusBody): Reading { + const { mode, version, latestVersion } = body.daemon ?? {}; + return { + facts: { + latestVersion: typeof latestVersion === "string" ? latestVersion : null, + ...(typeof version === "string" ? { version } : {}), + }, + mode: mode === "gateway" || mode === "worker" ? mode : undefined, + }; } // why: ADR 0037 §4. The console reads `daemon.mode` from `GET /v1/status` once it is signed in and -// again after every reconnect, and nothing else from that route. A read that fails or names a mode -// this console does not know leaves none, so nothing is ever offered on a guess. +// again after every reconnect. A read that fails or names a mode this console does not know leaves +// none, so nothing is ever offered on a guess. ADR 0042: the daemon's version and `latestVersion` +// come from the same read, which also repeats every 10 minutes because the daily check emits no +// event; that repeat keeps the last values when it fails, only the sign-in and reconnect reads +// clear them. export function watchMode(options: { readonly api: Pick; readonly connection: Pick; + readonly clock: Pick; }): ModeStore { - const { api, connection } = options; + const { api, clock, connection } = options; const listeners = new Set<() => void>(); - let current: DaemonMode | undefined; - const read = async () => { - let next: DaemonMode | undefined; + let current: Reading = { facts: NO_FACTS, mode: undefined }; + let timer: unknown; + const read = async (keepOnFailure: boolean) => { + let next: Reading; try { - next = modeOf((await api.get("/v1/status")).body); + next = readingOf((await api.get("/v1/status")).body); } catch { - next = undefined; + if (keepOnFailure) return; + next = { facts: NO_FACTS, mode: undefined }; + } + if ( + next.mode === current.mode && + next.facts.version === current.facts.version && + next.facts.latestVersion === current.facts.latestVersion + ) { + return; } - if (next === current) return; current = next; for (const listener of listeners) listener(); }; - void read(); + const again = () => { + timer = clock.setTimeout(() => { + void read(true); + again(); + }, RE_READ_MS); + }; + void read(false); + again(); // why: the first stream is the sign-in's own, which the read above already covers. connection.onStreamOpened((opened) => { - if (opened.closedForMs !== undefined) void read(); + if (opened.closedForMs !== undefined) void read(false); }); return { - mode: () => current, + daemon: () => current.facts, + dispose() { + clock.clearTimeout(timer); + }, + mode: () => current.mode, subscribe(listener) { listeners.add(listener); return () => { diff --git a/ui/src/ds/sidebar.css b/ui/src/ds/sidebar.css index b03b4c733..052baf97c 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); @@ -401,3 +419,9 @@ padding: 0 var(--space-3); } } + +.update-notice { + margin: 0; + color: var(--text-muted); + font-size: var(--text-sm); +} diff --git a/ui/src/ds/sidebar.tsx b/ui/src/ds/sidebar.tsx index c8e28b69f..813fa9fb5 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/actions.test.ts b/ui/src/model/actions.test.ts index 664a6f180..85f79d0dd 100644 --- a/ui/src/model/actions.test.ts +++ b/ui/src/model/actions.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { inboxActions, leaseActions, requestActions, workerActions } from "./actions"; import { inboxItems } from "./inbox"; import type { LeaseRecord } from "./leases"; +import type { UpdateFacts } from "./versions"; import type { WaitingRequest } from "./waiting"; import type { WorkerView } from "./workers"; @@ -134,6 +135,21 @@ function checksInbox(): void { worker({ connection: "disconnected", id: "wrk_gone" }), worker({ connection: "incompatible", id: "wrk_old" }), worker({ connection: "incompatible", drained: true, id: "wrk_old_drained" }), + worker({ + devices: [ + { id: "dev_a", spec: { model: "iPhone 16", platform: "ios" }, state: "leased" }, + { id: "dev_b", spec: { model: "iPhone 16", platform: "ios" }, state: "ready" }, + ] as unknown as WorkerView["devices"], + id: "wrk_failed", + lastUpdateFailure: { at: NOW, from: "0.3.0", message: "x", stage: "installing", to: "0.4.0" }, + latestVersion: "0.4.0", + version: "0.3.0", + }), + worker({ + connection: "disconnected", + id: "wrk_failed_gone", + lastUpdateFailure: { at: NOW, from: "0.3.0", message: "x", stage: "installing", to: "0.4.0" }, + }), worker({ devices: [ { @@ -161,8 +177,15 @@ function checksInbox(): void { expect(shape(of("wrk_gone/disconnected").menu)).toEqual(["drain", "remove-worker!"]); expect(of("wrk_old/incompatible").fix).toBeUndefined(); expect(of("wrk_old_drained/incompatible").fix).toBeUndefined(); - expect(shape(of("wrk_old_drained/incompatible").menu)).toEqual(["undrain"]); - expect(shape(of("wrk_old/incompatible").menu)).toEqual(["drain"]); + expect(shape(of("wrk_old_drained/incompatible").menu)).toEqual(["undrain", "update-worker"]); + expect(shape(of("wrk_old/incompatible").menu)).toEqual(["drain", "update-worker"]); + const failed = of("wrk_failed/update-failed"); + expect(failed.fix?.id).toBe("update-worker"); + expect(failed.fix?.confirm?.body).toBe( + "It takes no new leases and waits for its 1 lease to end. It restarts by itself.", + ); + expect(shape(failed.menu)).toEqual(["drain", "update-worker"]); + expect(of("wrk_failed_gone/update-failed").fix).toBeUndefined(); expect(of("request/req_1/waiting").fix).toBeUndefined(); expect(shape(of("request/req_1/waiting").menu)).toEqual(["cancel-request!"]); const heldLease = of("lease/lse_1/ending"); @@ -176,6 +199,228 @@ function checksInbox(): void { expect(inboxActions(drainedItem, { ...context, mode: undefined })).toEqual({ menu: [] }); } +function facts(overrides: Partial = {}): UpdateFacts { + return { latestVersion: "0.4.0", leased: 0, update: null, version: "0.3.0", ...overrides }; +} + +const STARTED = { forced: false, from: "0.3.0", startedAt: 1, to: "0.4.0" } as const; + +describe("the update actions a worker offers", () => { + it("offers Update… and Update now as the table says, and keeps Drain and Undrain", () => { + const cases: readonly { + readonly name: string; + readonly connection?: WorkerView["connection"]; + readonly mode?: "gateway" | "worker"; + readonly facts: UpdateFacts; + readonly offered: readonly string[]; + }[] = [ + { name: "a worker's mode", mode: "worker", facts: facts(), offered: [] }, + { + name: "disconnected", + connection: "disconnected", + facts: facts(), + offered: ["drain", "remove-worker"], + }, + { + name: "waiting", + facts: facts({ update: { ...STARTED, leases: 2, stage: "waiting" } }), + offered: ["drain", "force-update"], + }, + { + name: "disconnected and waiting", + connection: "disconnected", + facts: facts({ update: { ...STARTED, leases: 2, stage: "waiting" } }), + offered: ["drain", "remove-worker"], + }, + { + name: "installing", + facts: facts({ update: { ...STARTED, stage: "installing" } }), + offered: ["drain"], + }, + { + name: "restarting", + facts: facts({ update: { ...STARTED, stage: "restarting" } }), + offered: ["drain"], + }, + { + name: "incompatible whatever the latest", + connection: "incompatible", + facts: facts({ latestVersion: null }), + offered: ["drain", "update-worker"], + }, + { name: "newer", facts: facts(), offered: ["drain", "update-worker"] }, + { name: "equal", facts: facts({ latestVersion: "0.3.0" }), offered: ["drain"] }, + { name: "older", facts: facts({ latestVersion: "0.2.0" }), offered: ["drain"] }, + { name: "no latest", facts: facts({ latestVersion: null }), offered: ["drain"] }, + { + name: "no version", + facts: { latestVersion: "0.4.0", leased: 0, update: null }, + offered: ["drain"], + }, + ]; + for (const row of cases) { + const offered = workerActions( + { + connection: row.connection ?? "connected", + drained: false, + id: "wrk_1", + name: "mac-mini-1", + updateFacts: row.facts, + }, + row.mode ?? "gateway", + ).map((action) => action.id); + expect(offered, row.name).toEqual(row.offered); + } + const drained = workerActions( + { connection: "connected", drained: true, id: "wrk_1", name: "m", updateFacts: facts() }, + "gateway", + ); + expect(drained.map((action) => action.id)).toEqual(["undrain", "update-worker"]); + }); +}); + +describe("the Update… dialog", () => { + it("words its title and body for leases, none, an incompatible worker, no version and no latest, and sends {} to the worker's update route", () => { + const update = (connection: WorkerView["connection"], updateFacts: UpdateFacts) => + workerActions( + { connection, drained: false, id: "wrk/1", name: "mac-mini-1", updateFacts }, + "gateway", + ).find((action) => action.id === "update-worker"); + const cases: readonly [string, WorkerView["connection"], UpdateFacts, string, string][] = [ + [ + "2 leased devices", + "connected", + facts({ leased: 2 }), + "Update mac-mini-1 from 0.3.0 to 0.4.0?", + "It takes no new leases and waits for its 2 leases to end. It restarts by itself.", + ], + [ + "1 leased device", + "connected", + facts({ leased: 1 }), + "Update mac-mini-1 from 0.3.0 to 0.4.0?", + "It takes no new leases and waits for its 1 lease to end. It restarts by itself.", + ], + [ + "no leases", + "connected", + facts(), + "Update mac-mini-1 from 0.3.0 to 0.4.0?", + "It has no leases, so it installs at once. It restarts by itself.", + ], + [ + "incompatible", + "incompatible", + facts({ leased: 2 }), + "Update mac-mini-1 from 0.3.0 to 0.4.0?", + "The gateway can't talk to it until then. It finishes its leases first.", + ], + [ + "incompatible, no version", + "incompatible", + { latestVersion: "0.4.0", leased: 0, update: null }, + "Update mac-mini-1 to 0.4.0?", + "The gateway can't talk to it until then. It finishes its leases first.", + ], + [ + "incompatible, no latest", + "incompatible", + facts({ latestVersion: null }), + "Update mac-mini-1 from 0.3.0 to the latest version?", + "The gateway can't talk to it until then. It finishes its leases first.", + ], + ]; + for (const [name, connection, given, title, body] of cases) { + const action = update(connection, given); + expect(action?.confirm, name).toEqual({ body, busy: "Starting…", confirm: "Update", title }); + expect([action?.method, action?.path, action?.body], name).toEqual([ + "POST", + "/v1/workers/wrk%2F1/update", + {}, + ]); + } + }); +}); + +describe("the Update now dialog", () => { + it("counts the update's leases in its body and sends {\"force\":true} to the worker's update route", () => { + const cases: readonly [number, string][] = [ + [2, "Its 2 leases keep running through the restart."], + [1, "Its lease keeps running through the restart."], + ]; + for (const [leases, body] of cases) { + const action = workerActions( + { + connection: "connected", + drained: false, + id: "wrk/1", + name: "mac-mini-1", + updateFacts: facts({ update: { ...STARTED, leases, stage: "waiting" } }), + }, + "gateway", + ).find((candidate) => candidate.id === "force-update"); + expect(action?.confirm, String(leases)).toEqual({ + body, + busy: "Starting…", + confirm: "Install now", + title: "Install 0.4.0 on mac-mini-1 now?", + }); + expect([action?.method, action?.path, action?.body]).toEqual([ + "POST", + "/v1/workers/wrk%2F1/update", + { force: true }, + ]); + } + }); +}); + +describe("the toast an update earns", () => { + it("words the daemon's answer: started, following, upToDate and Update now accepted, and a refusal's title", () => { + const running = { ...STARTED, stage: "installing" } as const; + const offered = (updateFacts: UpdateFacts) => { + const [, action] = workerActions( + { connection: "connected", drained: false, id: "wrk_1", name: "mac-mini-1", updateFacts }, + "gateway", + ); + if (action?.outcome === undefined) throw new Error("no update action"); + return { ...action, outcome: action.outcome }; + }; + const update = offered(facts()); + const force = offered(facts({ update: { ...STARTED, leases: 2, stage: "waiting" } })); + const upToDate = { started: false, update: null, upToDate: true, version: "0.3.0" }; + const cases: readonly [typeof update, string, unknown, string][] = [ + [ + update, + "started", + { started: true, update: running, upToDate: false, version: "0.3.0" }, + "Updating mac-mini-1 to 0.4.0", + ], + [ + update, + "following", + { started: false, update: running, upToDate: false, version: "0.3.0" }, + "mac-mini-1 is already updating to 0.4.0", + ], + [update, "upToDate", upToDate, "mac-mini-1 already runs 0.3.0"], + [update, "no body", undefined, "Updating mac-mini-1 to the latest version"], + [ + force, + "force accepted", + { started: true, update: running, upToDate: false }, + "mac-mini-1 installs 0.4.0 now", + ], + [force, "force upToDate", upToDate, "mac-mini-1 already runs 0.3.0"], + ]; + for (const [action, name, answer, title] of cases) { + expect(action.outcome(answer), name).toEqual({ title }); + } + expect(force.id).toBe("force-update"); + expect(force.success).toEqual({ title: "mac-mini-1 installs 0.4.0 now" }); + expect([update.timeoutMs, force.timeoutMs]).toEqual([100_000, 100_000]); + expect([update.failure, force.failure]).toEqual(Array(2).fill("Couldn't update mac-mini-1")); + }); +}); + describe("the actions each kind of item offers", () => { it("offers exactly the table's actions: dangerous ones last and marked, worker actions only on a gateway, and the Inbox's one fix", () => { checksLease(); diff --git a/ui/src/model/actions.ts b/ui/src/model/actions.ts index 954217f61..74f73c801 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, updateFactsOf } from "./versions"; import { requestedDevice } from "./waiting"; import { type WorkerView, workerName } from "./workers"; @@ -22,7 +23,14 @@ 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" + | "force-update"; readonly itemKey: string; readonly label: string; readonly danger: boolean; @@ -30,6 +38,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 +119,115 @@ 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 started?: boolean; + readonly upToDate?: boolean; + readonly version?: string; + readonly update?: { readonly to?: string } | null; +} + +function updateToast(name: string): (answer: unknown) => ActionToast { + return (raw) => { + // why: the answer is the daemon's JSON, or nothing when it sent no body; either words a toast. + const answer = (typeof raw === "object" && raw !== null ? raw : {}) as UpdateAnswer; + const to = answer.update?.to ?? "the latest version"; + if (answer.upToDate === true) return { title: `${name} already runs ${answer.version ?? ""}` }; + return { + title: + answer.started === false + ? `${name} is already updating to ${to}` + : `Updating ${name} to ${to}`, + }; + }; +} + +// why: above the gateway's 90 s uplink limit, so the gateway's answer arrives before the console +// gives up. +const UPDATE_TIMEOUT_MS = 100_000; + +function updateBase(worker: WorkerActionInput) { + return { + danger: false, + failure: `Couldn't update ${worker.name}`, + itemKey: `worker/${worker.id}`, + method: "POST", + path: `/v1/workers/${encodeURIComponent(worker.id)}/update`, + timeoutMs: UPDATE_TIMEOUT_MS, + } as const; +} + +function plural(count: number, noun: string): string { + return `${count} ${noun}${count === 1 ? "" : "s"}`; +} + +function updateBody(worker: WorkerActionInput, facts: UpdateFacts): string { + if (worker.connection === "incompatible") { + return "The gateway can't talk to it until then. It finishes its leases first."; + } + return facts.leased > 0 + ? `It takes no new leases and waits for its ${plural(facts.leased, "lease")} to end. It restarts by itself.` + : "It has no leases, so it installs at once. It restarts by itself."; +} + +function updateAction(worker: WorkerActionInput, facts: UpdateFacts): ItemAction { + const from = facts.version === undefined ? "" : ` from ${facts.version}`; + return { + ...updateBase(worker), + body: {}, + confirm: { + body: updateBody(worker, facts), + busy: "Starting…", + confirm: "Update", + title: `Update ${worker.name}${from} to ${facts.latestVersion ?? "the latest version"}?`, + }, + id: "update-worker", + label: "Update…", + outcome: updateToast(worker.name), + success: { title: `Updating ${worker.name}` }, + }; +} + +function forceAction( + worker: WorkerActionInput, + update: { readonly to: string; readonly leases: number }, +): ItemAction { + const { leases, to } = update; + return { + ...updateBase(worker), + body: { force: true }, + confirm: { + body: `Its ${leases === 1 ? "lease keeps" : `${leases} leases keep`} running through the restart.`, + busy: "Starting…", + confirm: "Install now", + title: `Install ${to} on ${worker.name} now?`, + }, + id: "force-update", + label: "Update now", + // why: a worker that already runs the version answers upToDate, and the toast says so rather + // than that something installs. + outcome: (raw) => + (raw as UpdateAnswer | undefined)?.upToDate === true + ? updateToast(worker.name)(raw) + : { title: `${worker.name} installs ${to} now` }, + success: { title: `${worker.name} installs ${to} now` }, + }; +} + +// why: the table of #839: an update in flight offers only Update now, and only while it waits; a +// worker that shows `incompatible` is offered Update… whatever the latest version, since the +// gateway cannot compare versions with it; a connected one only when the latest is newer. +function updateEntry(worker: WorkerActionInput): ItemAction | undefined { + const facts = worker.updateFacts; + if (facts === undefined || worker.connection === "disconnected") return undefined; + if (facts.update !== null) { + return facts.update.stage === "waiting" ? forceAction(worker, facts.update) : undefined; + } + if (worker.connection === "incompatible" || isBehind(facts)) return updateAction(worker, facts); + return undefined; } // why: ADR 0037 §4. On a single host the three worker routes answer 501, and when the mode is not @@ -142,6 +264,8 @@ export function workerActions( undo: undrain, }; const actions: ItemAction[] = [worker.drained ? undrain : drain]; + const entry = updateEntry(worker); + if (entry !== undefined) actions.push(entry); if (worker.connection === "disconnected") { actions.push({ confirm: { @@ -164,6 +288,11 @@ export function workerActions( return actions; } +const FIX_OF: Readonly>> = { + drained: "undrain", + "update failed": "update-worker", +}; + export interface InboxActionContext { readonly mode: DaemonModeName | undefined; readonly now: number; @@ -171,9 +300,9 @@ export interface InboxActionContext { readonly workers: readonly WorkerView[]; } -// why: only a drained worker's item has a fix button, and its fix is Undrain; every other action of -// an item is in its menu. Device conditions (a quarantined or stalled device) are about a device, so -// they offer no worker action. +// why: only a drained worker's item (its fix is Undrain) and a failed update's (its fix is Update…) +// have a fix button; every other action of an item is in its menu. Device conditions (a quarantined +// or stalled device) are about a device, so they offer no worker action. export function inboxActions( item: InboxItem, context: InboxActionContext, @@ -204,6 +333,7 @@ export function inboxActions( case "disconnected": case "incompatible": case "drained": + case "update failed": case "over RAM budget": { const menu = workerActions( { @@ -211,10 +341,12 @@ export function inboxActions( drained: item.worker.drained, id: item.worker.id, name: workerName(item.worker), + updateFacts: updateFactsOf(item.worker), }, context.mode, ); - const fix = item.condition === "drained" ? menu.find((a) => a.id === "undrain") : undefined; + const fixId = FIX_OF[item.condition]; + const fix = fixId === undefined ? undefined : menu.find((a) => a.id === fixId); return fix === undefined ? { menu } : { fix, menu }; } } diff --git a/ui/src/model/fleet.ts b/ui/src/model/fleet.ts index 7c31205c7..fe0dab132 100644 --- a/ui/src/model/fleet.ts +++ b/ui/src/model/fleet.ts @@ -3,6 +3,14 @@ import { levelOf } from "./load-thresholds"; import type { Tone, Toned } from "./tones"; import type { WorkerDevice, WorkerView } from "./workers"; +export type UpdateState = NonNullable; + +// why: the stage as the row and the page's banner word it. +export function stageLine(update: UpdateState): string { + if (update.stage !== "waiting") return update.stage; + return `waiting for ${update.leases} ${update.leases === 1 ? "lease" : "leases"}`; +} + const RANK: Readonly> = { error: 3, idle: 0, ok: 1, warn: 2 }; // why: a list row has room for one square, so of everything wrong with a worker the worst is @@ -23,6 +31,9 @@ function stateFindings(worker: WorkerView): Toned[] { const findings: Toned[] = []; if (worker.connection !== "connected") findings.push({ tone: "error", word: worker.connection }); if (worker.health === "failed") findings.push({ tone: "error", word: "failed" }); + // why: an update means the worker takes no new leases, so it reads before the drain it may also have. + if (worker.update !== undefined && worker.update !== null) + findings.push({ tone: "warn", word: "updating" }); if (worker.drained) findings.push({ tone: "warn", word: "drained" }); if (worker.health === "starting") findings.push({ tone: "warn", word: "starting" }); if (worker.capacity?.ramBudget?.overLimit === true) { diff --git a/ui/src/model/inbox.test.ts b/ui/src/model/inbox.test.ts index 993f64b9b..58dd17225 100644 --- a/ui/src/model/inbox.test.ts +++ b/ui/src/model/inbox.test.ts @@ -443,6 +443,39 @@ describe("the Inbox groups", () => { }); }); +describe("the Inbox's update failed item", () => { + it("is a warn item in Needs a look, counted as a problem, with its drawer sentence and facts, and gone when lastUpdateFailure is null", () => { + const failure = { + at: NOW - 120_000, + from: "0.3.0", + message: "EACCES: permission denied", + stage: "installing", + to: "0.4.0", + } as const; + const failed = worker({ id: "wrk/1", label: "mac-mini-1", lastUpdateFailure: failure }); + const page = pageOf([failed, worker({ id: "wrk_2", drained: true })], "wrk/1/update-failed"); + + expect(page.rows.map((row) => [row.group, row.key, row.tone, row.word, row.subject])).toEqual([ + ["Needs a look", "wrk/1/update-failed", "warn", "update failed", "mac-mini-1"], + ["Needs a look", "wrk_2/drained", "warn", "drained", "wrk_2"], + ]); + expect(page.problems).toBe(2); + const shown = shownOf(page); + expect(shown.what).toBe( + "The update of mac-mini-1 to 0.4.0 failed while installing: EACCES: permission denied. It runs 0.3.0 again.", + ); + expect(shown.facts).toEqual([ + { label: "From", value: "0.3.0" }, + { label: "To", value: "0.4.0" }, + { label: "Stage", value: "installing" }, + { label: "When", value: "2 min ago" }, + ]); + expect( + pageOf([worker({ drained: true, lastUpdateFailure: null })]).rows.map((row) => row.key), + ).toEqual(["wrk_1/drained"]); + }); +}); + describe("the Inbox page", () => { it("lists a row per item, naming the worker or the device on its worker", () => { const page = pageOf([ diff --git a/ui/src/model/inbox.ts b/ui/src/model/inbox.ts index 13f7d97e0..0a20d936f 100644 --- a/ui/src/model/inbox.ts +++ b/ui/src/model/inbox.ts @@ -24,7 +24,7 @@ import { export type InboxItem = | { readonly key: string; - readonly condition: "disconnected" | "incompatible" | "drained"; + readonly condition: "disconnected" | "incompatible" | "drained" | "update failed"; readonly worker: WorkerView; } | { @@ -118,6 +118,9 @@ function workerItems(worker: WorkerView): InboxItem[] { items.push({ condition: worker.connection, key: key(worker.connection), worker }); } if (worker.drained) items.push({ condition: "drained", key: key("drained"), worker }); + if (worker.lastUpdateFailure !== undefined && worker.lastUpdateFailure !== null) { + items.push({ condition: "update failed", key: key("update-failed"), worker }); + } const ramBudget = worker.capacity?.ramBudget; if (ramBudget?.overLimit === true) { items.push({ @@ -318,6 +321,12 @@ const SAY: Say = { quarantined: (item) => `${item.device.spec.model} is quarantined and cannot be leased until it is dealt with.`, stalled: (item) => `${item.device.spec.model} has stayed in one state longer than expected.`, + "update failed": (item) => { + const failure = item.worker.lastUpdateFailure; + return failure === undefined || failure === null + ? "" + : `The update of ${workerName(item.worker)} to ${failure.to} failed while ${failure.stage}: ${failure.message}. It runs ${failure.from} again.`; + }, waiting: (item) => `${item.request.requesterId} has waited ${formatDuration(item.waitedMs)} for a device.`, }; @@ -368,6 +377,18 @@ function causeFacts(device: { ]; } +function updateFailureFacts(worker: WorkerView, now: number): readonly InboxFact[] { + const failure = worker.lastUpdateFailure; + return failure === undefined || failure === null + ? [] + : [ + { label: "From", value: failure.from }, + { label: "To", value: failure.to }, + { label: "Stage", value: failure.stage }, + { label: "When", value: `${formatDuration(now - failure.at)} ago` }, + ]; +} + function workerFacts( item: Exclude, now: number, @@ -385,6 +406,7 @@ function workerFacts( if (item.condition === "drained") { return [{ label: "Leases still held", value: String(worker.leases?.length ?? 0) }]; } + if (item.condition === "update failed") return updateFailureFacts(worker, now); const { protocol } = worker; return protocol === undefined ? [] diff --git a/ui/src/model/search.test.ts b/ui/src/model/search.test.ts index 7f18c5112..e187df401 100644 --- a/ui/src/model/search.test.ts +++ b/ui/src/model/search.test.ts @@ -107,6 +107,30 @@ describe("the search results", () => { }); }); +describe("the update actions in the search", () => { + it("offers Update… for a worker behind the latest and Update now for one that waits, as its menu does", () => { + const behind = worker("wrk_2", "mac-studio-2", { latestVersion: "0.4.0", version: "0.3.0" }); + const waiting = worker("wrk_3", "mac-mini-3", { + latestVersion: "0.4.0", + update: { + forced: false, + from: "0.3.0", + leases: 1, + stage: "waiting", + startedAt: 1, + to: "0.4.0", + }, + version: "0.3.0", + }); + const current = worker("wrk_4", "mac-pro-4", { latestVersion: "0.3.0", version: "0.3.0" }); + expect(search("update", { workers: [behind, waiting, current] })).toEqual([ + "Actions: Update now — mac-mini-3", + "Actions: Update… — mac-studio-2", + ]); + expect(search("update", { mode: "worker", workers: [behind] })).toEqual([]); + }); +}); + describe("a group that is still loading", () => { it("lists no results, so Actions and Leases appear only once every read has settled", () => { const workers = [worker("wrk_2", "mac-studio-2", { devices: [PHONE] })]; diff --git a/ui/src/model/search.ts b/ui/src/model/search.ts index 2c0bed527..3feed5141 100644 --- a/ui/src/model/search.ts +++ b/ui/src/model/search.ts @@ -7,6 +7,7 @@ import { } from "./actions"; import type { LeaseRowView } from "./leases"; import { devicePath } from "./paths"; +import { updateFactsOf } from "./versions"; import type { WaitingRow } from "./waiting"; import { type WorkerView, workerName, workerPath } from "./workers"; @@ -108,6 +109,7 @@ function actionResults(input: SearchInput): readonly SearchResult[] { drained: worker.drained, id: worker.id, name: workerName(worker), + updateFacts: updateFactsOf(worker), }, input.mode, ).map((action) => actionResult(action, workerName(worker))), diff --git a/ui/src/model/versions.ts b/ui/src/model/versions.ts new file mode 100644 index 000000000..2fea3303d --- /dev/null +++ b/ui/src/model/versions.ts @@ -0,0 +1,35 @@ +import { isNewerVersion } from "../../../src/contract/version-order"; +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.test.ts b/ui/src/model/worker-page.test.ts index 3b6bc1d63..79d3ea78a 100644 --- a/ui/src/model/worker-page.test.ts +++ b/ui/src/model/worker-page.test.ts @@ -142,6 +142,117 @@ 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("a worker's row while it updates", () => { + it("reads amber updating with the stage under it, over drained, under a red finding, and drained again once the update ends", () => { + const base = { forced: false, from: "0.3.0", startedAt: 1, to: "0.4.0" } as const; + const waiting = (leases: number) => ({ ...base, leases, stage: "waiting" as const }); + const cases: readonly [string, Partial, string, string | undefined][] = [ + ["waiting, two", { update: waiting(2) }, "updating", "waiting for 2 leases"], + ["waiting, one", { update: waiting(1) }, "updating", "waiting for 1 lease"], + ["installing", { update: { ...base, stage: "installing" } }, "updating", "installing"], + ["restarting", { update: { ...base, stage: "restarting" } }, "updating", "restarting"], + [ + "drained and updating", + { drained: true, update: waiting(1) }, + "updating", + "waiting for 1 lease", + ], + ["drained, update over", { drained: true, update: null }, "drained", undefined], + [ + "disconnected", + { connection: "disconnected", update: waiting(1) }, + "disconnected", + undefined, + ], + ["failed", { health: "failed", update: waiting(1) }, "failed", undefined], + ]; + for (const [name, overrides, word, stage] of cases) { + const row = workerRow(worker(overrides)); + expect([row.health.word, row.stage], name).toEqual([word, stage]); + } + expect(workerRow(worker({ update: waiting(1) })).health.tone).toBe("warn"); + }); +}); + +describe("a worker page's update banners", () => { + it("words the Updating banner per stage and the failure banner, in the order disconnected, failure, updating, drained, and shows none once both are null", () => { + const base = { forced: false, from: "0.3.0", startedAt: 1, to: "0.4.0" } as const; + const banners = (overrides: Partial) => + inspect(worker(overrides)).banners.map((banner) => [ + banner.text, + banner.tone, + banner.failure ?? false, + ]); + expect(banners({ update: { ...base, leases: 2, stage: "waiting" } })).toEqual([ + ["Updating to 0.4.0 · waiting for 2 leases. It takes no new leases.", "warn", false], + ]); + expect(banners({ update: { ...base, leases: 1, stage: "waiting" } })).toEqual([ + ["Updating to 0.4.0 · waiting for 1 lease. It takes no new leases.", "warn", false], + ]); + expect(banners({ update: { ...base, stage: "installing" } })).toEqual([ + ["Updating to 0.4.0 · installing. It takes no new leases.", "warn", false], + ]); + expect(banners({ update: { ...base, stage: "restarting" } })).toEqual([ + ["Updating to 0.4.0 · restarting. It takes no new leases.", "warn", false], + ]); + const failure = (stage: "installing" | "restarting") => ({ + at: NOW - 60_000, + from: "0.3.0", + message: "EACCES", + stage, + to: "0.4.0", + }); + expect(banners({ lastUpdateFailure: failure("installing") })).toEqual([ + ["Update to 0.4.0 failed (installing): EACCES", "error", true], + ]); + expect(banners({ lastUpdateFailure: failure("restarting") })).toEqual([ + ["Update to 0.4.0 failed (restarting): EACCES", "error", true], + ]); + expect( + banners({ + connection: "disconnected", + drained: true, + lastUpdateFailure: failure("installing"), + update: { ...base, stage: "installing" }, + }).map(([text]) => String(text).split(" ")[0]), + ).toEqual(["Disconnected", "Update", "Updating", "Drained"]); + expect(banners({ lastUpdateFailure: null, update: null })).toEqual([]); + }); +}); + 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 905e55d8e..e62fc7d75 100644 --- a/ui/src/model/worker-page.ts +++ b/ui/src/model/worker-page.ts @@ -4,6 +4,7 @@ import { hasIssue, platformSlots, slotsLabel, + stageLine, workerHealth, workerSlots, } from "./fleet"; @@ -12,6 +13,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 +71,9 @@ export interface WorkerRow { readonly to: string; readonly health: Toned; readonly system?: string; + readonly version?: string; + readonly stage?: string; + readonly updateFacts: UpdateFacts; readonly use?: WorkerUse; } @@ -91,10 +96,14 @@ 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); + const health = workerHealth(worker); + const stage = health.word === "updating" && worker.update ? stageLine(worker.update) : undefined; return { connection: worker.connection, takesLeases: !worker.drained, - health: workerHealth(worker), + health, id: worker.id, meters: loadFigures(worker).map((card) => ({ label: card.label, @@ -106,7 +115,10 @@ 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 }), + ...(stage === undefined ? {} : { stage }), ...(use === undefined ? {} : { use }), }; } @@ -392,14 +404,44 @@ function loadFigures(worker: WorkerView): readonly FigureCard[] { return worker.connection === "connected" ? figureCards(worker) : []; } -// why: a worker that is gone or drained is the first thing an operator should read on its page, so it -// says so above everything else. -function bannersOf(worker: WorkerView, now: number): readonly string[] { +function failureBanner(worker: WorkerView): readonly WorkerBanner[] { + const failure = worker.lastUpdateFailure; + if (failure === undefined || failure === null) return []; + return [ + { + failure: true, + text: `Update to ${failure.to} failed (${failure.stage}): ${failure.message}`, + tone: "error", + }, + ]; +} + +function updatingBanner(worker: WorkerView): readonly WorkerBanner[] { + const { update } = worker; + if (update === undefined || update === null) return []; + return [ + { + text: `Updating to ${update.to} · ${stageLine(update)}. It takes no new leases.`, + tone: "warn", + }, + ]; +} + +// why: a worker that is gone, failed to update, updates or is drained is the first thing an operator +// should read on its page, so it says so above everything else, in that order. +function bannersOf(worker: WorkerView, now: number): readonly WorkerBanner[] { return [ ...(worker.connection === "disconnected" - ? [`Disconnected · last seen ${formatDuration(now - worker.lastSeenAt)} ago`] + ? [ + { + text: `Disconnected · last seen ${formatDuration(now - worker.lastSeenAt)} ago`, + tone: "warn" as const, + }, + ] : []), - ...(worker.drained ? ["Drained"] : []), + ...failureBanner(worker), + ...updatingBanner(worker), + ...(worker.drained ? [{ text: "Drained", tone: "warn" as const }] : []), ]; } @@ -584,16 +626,25 @@ export function writeWorkerTab(search: string, tab: WorkerTab): string { return `?${params.toString()}`; } +export interface WorkerBanner { + readonly text: string; + readonly tone: "warn" | "error"; + // why: the failure banner offers Update again, when the page's menu offers Update…. + readonly failure?: true; +} + export interface WorkerPage { readonly id: string; readonly tab: WorkerTab; - readonly banners: readonly string[]; + readonly banners: readonly WorkerBanner[]; readonly title: string; readonly connection: WorkerView["connection"]; readonly drained: boolean; 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; readonly host: HostTab; @@ -608,6 +659,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"; @@ -646,6 +699,8 @@ export function workerPageOf(input: { }, tabs, title: workerName(worker), + updateFacts, + ...(version === undefined ? {} : { version }), ...(host === undefined ? {} : { system: `${host.os} ${host.osVersion}` }), ...(starting ? {} : { warm: warmTabOf(worker, now) }), }; diff --git a/ui/src/screens/fleet/fleet.css b/ui/src/screens/fleet/fleet.css index 83113f198..4a8fffd2c 100644 --- a/ui/src/screens/fleet/fleet.css +++ b/ui/src/screens/fleet/fleet.css @@ -126,6 +126,14 @@ font-size: var(--text-sm); } +.worker-banner-error { + border-color: var(--status-error); +} + +.worker-banner button { + margin-left: var(--space-3); +} + .device-drawer-body { margin: 0; padding: var(--space-4) var(--space-6); diff --git a/ui/src/screens/fleet/fleet.test.tsx b/ui/src/screens/fleet/fleet.test.tsx index 1148aa0ba..c35726417 100644 --- a/ui/src/screens/fleet/fleet.test.tsx +++ b/ui/src/screens/fleet/fleet.test.tsx @@ -127,6 +127,9 @@ function html(view: WorkerPageView): string { return inConsole(); } +const GATEWAY = { mode: () => "gateway" as const }; +const UPDATE = { forced: false, from: "1.4.0", leases: 2, startedAt: NOW, to: "1.5.0" }; + function text(markup: string): string { return markup .replace(/<[^>]+>/g, " ") @@ -145,6 +148,36 @@ describe("the fleet table", () => { }); }); +describe("a worker's update on the Fleet screen", () => { + it("reads its stage on the row, and the failed worker's page offers Update again", () => { + const updating = { ...WORKER, update: { ...UPDATE, stage: "waiting" as const } }; + const failed = { + ...WORKER, + lastUpdateFailure: { + at: NOW - 60_000, + from: "1.4.0", + message: "EACCES", + stage: "installing" as const, + to: "1.5.0", + }, + latestVersion: "1.5.0", + }; + const idle = html(page(WORKER)); + + expect(text(inConsole())).toContain( + "waiting for 2 leases", + ); + expect(text(inConsole())).not.toContain("waiting for"); + const again = text(inConsole(, GATEWAY)); + expect(again).toContain("Update to 1.5.0 failed (installing): EACCES Update again"); + expect(inConsole()).toContain( + 'simlock 1.4.0 · 1.5.0 available', + ); + expect(text(idle)).not.toContain("Update again"); + expect(text(inConsole())).not.toContain("Update again"); + }); +}); + describe("a worker's page", () => { it("is titled with the worker's name and reads its health word and operating system beneath", () => { const markup = html(page(WORKER)); diff --git a/ui/src/screens/fleet/index.tsx b/ui/src/screens/fleet/index.tsx index b93ab2e98..33f86e4f5 100644 --- a/ui/src/screens/fleet/index.tsx +++ b/ui/src/screens/fleet/index.tsx @@ -108,13 +108,22 @@ 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", }, { - cell: (row) => {row.health.word}, + cell: (row) => ( + + {row.health.word} + {row.stage === undefined ? null : {row.stage}} + + ), header: "State", }, { cell: (row) => , header: "Slots" }, diff --git a/ui/src/screens/fleet/worker-page.tsx b/ui/src/screens/fleet/worker-page.tsx index 15b74d975..702cbc9dc 100644 --- a/ui/src/screens/fleet/worker-page.tsx +++ b/ui/src/screens/fleet/worker-page.tsx @@ -1,5 +1,6 @@ import { ActionBar, + Button, Card, type Column, DataTable, @@ -62,9 +63,11 @@ function MissingWorker() { export function WorkerPageBody({ page }: { readonly page: WorkerPageView }) { const entries = useWorkerPageMenu(page); + const again = entries.find((entry) => entry.key === "update-worker"); return ( } >