From 5f2a5acb31f814f6c31dbdd4b02ba0ac149371f6 Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Sat, 29 Aug 2026 19:43:55 +0000 Subject: [PATCH 1/9] fix(webapp): My machine says why a size is missing, instead of "Unavailable" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On canary a member's My-machine panel showed CPU, Memory, Disk and Price as "Unavailable" and the machine-type select showed the raw id `cx33@hel1`. The fetch was not failing and the catalog was not empty: `GET /machine-types` is what an organization may create NOW, not what its machines run on. `HetznerProvider.listMachineTypes` drops deprecated types, drops locations that report no availability, and keeps only the ids in HETZNER_MACHINE_TYPES; `core/app.ts` drops whole providers whose access is `credential-required`. A live machine on a type that has since left the catalog is normal and documented (hetzner-config.ts), and `machines.find` then finds nothing. The dialog had no answer for that and no reason to give, because it kept only `response.machineTypes` and discarded `failures` and `providerStatuses` — the two fields of the same response that say why the catalog cannot describe the machine. `CreateWorkspaceDialog` already reads all three. The panel now keeps the whole response. When the catalog holds the machine's type it prints the size exactly as before. When it does not, the four spec rows give way to one line naming the real reason: a provider failure and its message, a provider that needs an organization compute credential, or a type the catalog no longer offers. The type id stays on the "Machine type" row, because it is the one fact that is known. Second defect on the same lines: the catch typed its argument `Error` without checking, so a rejection that is not an Error set the banner to `undefined` and rendered an empty `role="alert"` paragraph. It goes through the shared `caughtErrorMessage` helper now. Tests mount the dialog with the exact envelope the control plane serves (machineTypes decorated with providerId and supportsVolumes, beside failures and providerStatuses) and pin the size rows, each reason line, and the non-Error rejection. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01J6fUBY1B27EzvDwbhfBf52 --- packages/webapp/src/MyMachineDialog.tsx | 66 ++++++++++++-- packages/webapp/test/my-machine.test.tsx | 111 ++++++++++++++++++++++- 2 files changed, 169 insertions(+), 8 deletions(-) diff --git a/packages/webapp/src/MyMachineDialog.tsx b/packages/webapp/src/MyMachineDialog.tsx index e2b9f8e4..04471578 100644 --- a/packages/webapp/src/MyMachineDialog.tsx +++ b/packages/webapp/src/MyMachineDialog.tsx @@ -6,6 +6,7 @@ import type { WorkspaceMemberView, } from '@blitzos/schema'; import type { ControlPlaneClient } from './api'; +import { caughtErrorMessage } from './error-message'; import { ConfirmationDialog } from './ConfirmationDialog'; import { monthlyPriceLabel } from './MachineCatalogGrid'; import { MachineTypeSelect } from './MachineTypeSelect'; @@ -47,6 +48,42 @@ function Detail({ label, value }: { label: string; value: string }) { return
{label}
{value}
; } +/** The empty catalog, before the first answer arrives. */ +const NO_CATALOG: ListMachineTypesResponse = { machineTypes: [], failures: [] }; + +/** + * Why the catalog cannot describe this machine's type. + * + * `GET /machine-types` is what an organization may create NOW, not what its + * machines run on: the Hetzner adapter drops deprecated types, drops locations + * that report no availability, and keeps only the ids in + * `HETZNER_MACHINE_TYPES`, while `core/app.ts` drops whole providers whose + * access is `credential-required`. A live machine on a type that has since + * left the catalog is normal and documented (`hetzner-config.ts`), and it is + * what this panel used to render as four bare "Unavailable" rows. + * + * The same response carries the reason in `failures` and `providerStatuses`, + * which this dialog used to discard. `CreateWorkspaceDialog` reads both. + */ +function catalogGap( + catalog: ListMachineTypesResponse, + machineTypeId: string, +): string { + if (catalog.failures.length > 0) { + const listed = catalog.failures + .map(({ providerId, error }) => `${providerId}: ${error}`) + .join('; '); + return `The machine catalog came back incomplete (${listed}), so the size of ${machineTypeId} cannot be shown. The machine itself is unaffected.`; + } + const needCredential = (catalog.providerStatuses ?? []) + .filter(({ access }) => access === 'credential-required') + .map(({ providerId }) => providerId); + if (needCredential.length > 0) { + return `Machine sizes come from the compute provider, and ${needCredential.join(', ')} needs an organization compute credential, so the size of ${machineTypeId} cannot be shown. The machine itself is unaffected.`; + } + return `The catalog no longer offers ${machineTypeId}, so this machine's size cannot be shown. The machine itself is unaffected.`; +} + /** The volume's location, so a type change can refuse what cannot reach it. * Derived from the machine's current type, because the volume was created in * that type's location. */ @@ -94,16 +131,26 @@ export function MyMachineDialog({ onClose: () => void; }) { const closeButton = useRef(null); - const [machines, setMachines] = useState([]); + // The WHOLE answer, not just its machine list: `failures` and + // `providerStatuses` are what explain a catalog that cannot describe this + // machine, and dropping them is what left the panel saying "Unavailable". + const [catalog, setCatalog] = useState(NO_CATALOG); const [error, setError] = useState(null); const [pendingTypeId, setPendingTypeId] = useState(null); + const machines: MachineType[] = catalog.machineTypes; useEffect(() => { closeButton.current?.focus(); }, []); useEffect(() => { let cancelled = false; void listMachineTypes() - .then((response) => { if (!cancelled) setMachines(response.machineTypes); }) - .catch((caught: Error) => { if (!cancelled) setError(caught.message); }); + .then((response) => { if (!cancelled) setCatalog(response); }) + // A rejection is not always an Error. Reading `.message` off one that is + // not put `undefined` in the alert, which renders an empty banner. + .catch((caught) => { + if (!cancelled) { + setError(caughtErrorMessage(caught, 'The machine catalog could not be loaded.')); + } + }); return () => { cancelled = true; }; }, [listMachineTypes]); @@ -167,16 +214,21 @@ export function MyMachineDialog({ label="Machine type" value={type?.name ?? machine?.machineTypeId ?? workspace.defaultMachineTypeId} /> - - - - + {type !== undefined && <> + + + + + } + {type === undefined && machine !== null && ( +

{catalogGap(catalog, machine.machineTypeId)}

+ )} {machine?.error != null && (

{machine.error}

)} diff --git a/packages/webapp/test/my-machine.test.tsx b/packages/webapp/test/my-machine.test.tsx index bfdd9a9c..1c2934c5 100644 --- a/packages/webapp/test/my-machine.test.tsx +++ b/packages/webapp/test/my-machine.test.tsx @@ -1,5 +1,9 @@ import { act } from 'react'; -import type { MachineType, WorkspaceMemberView } from '@blitzos/schema'; +import type { + ListMachineTypesResponse, + MachineType, + WorkspaceMemberView, +} from '@blitzos/schema'; import { describe, expect, it, vi } from 'vitest'; import type { ControlPlaneClient } from '../src/api.js'; import { MyMachineDialog } from '../src/MyMachineDialog.js'; @@ -22,6 +26,29 @@ const machineTypes: MachineType[] = [ }, ]; +/** + * `GET /machine-types` as `core/app.ts` serves it: the registry's + * `{ machineTypes, failures }` — each entry decorated with `providerId` and + * `supportsVolumes` by `core/compute/registry.ts` — spread beside + * `providerStatuses`. The suite fixture above predates the third field. + */ +const catalogResponse: ListMachineTypesResponse = { + machineTypes: [{ + id: 'cx33@hel1', + providerId: 'hetzner', + supportsVolumes: true, + name: 'cx33', + cpuCores: 4, + memGb: 8, + diskGb: 80, + arch: 'x86', + location: 'hel1', + monthlyPrice: { amount: 9.99, currency: 'USD' }, + }], + failures: [], + providerStatuses: [{ providerId: 'hetzner', access: 'deployment' }], +}; + const ada: WorkspaceMemberView = { membershipId: 'membership-1', name: 'Ada Owner', @@ -125,6 +152,88 @@ describe('MyMachineDialog', () => { await view.unmount(); }); + it('reads the size out of the response the control plane actually serves', async () => { + const view = await render(dialog({ + workspace: { + ...workspace, + members: [ada, { ...me, machine: { ...me.machine!, machineTypeId: 'cx33@hel1' } }], + }, + listMachineTypes: async () => catalogResponse, + })); + await settle(); + + expect(view.container.textContent).toContain('4 vCPU'); + expect(view.container.textContent).toContain('8 GB'); + expect(view.container.textContent).toContain('80 GB'); + expect(view.container.textContent).toContain('$9.99/mo'); + expect(view.container.textContent).not.toContain('Unavailable'); + await view.unmount(); + }); + + /** + * The canary defect. The catalog is what an organization may create NOW: + * the Hetzner adapter drops deprecated types, drops locations reporting no + * availability, and keeps only the allowlisted ids, so a live machine's type + * can be absent from a catalog that is otherwise healthy. The panel used to + * answer that with four bare "Unavailable" rows and no reason at all. + */ + it('says why a size is missing instead of printing “Unavailable” four times', async () => { + const view = await render(dialog({ + workspace: { + ...workspace, + members: [ada, { ...me, machine: { ...me.machine!, machineTypeId: 'cx33@hel1' } }], + }, + // A healthy catalog that no longer offers this machine's type. + listMachineTypes: async () => ({ machineTypes, failures: [] }), + })); + await settle(); + + expect(view.container.textContent).toContain('The catalog no longer offers cx33@hel1'); + expect(view.container.textContent).not.toContain('Unavailable'); + // The type id stays readable, because it is the one fact that is known. + expect(view.container.textContent).toContain('cx33@hel1'); + await view.unmount(); + }); + + it('names the provider whose credential emptied the catalog', async () => { + const view = await render(dialog({ + listMachineTypes: async () => ({ + machineTypes: [], + failures: [], + providerStatuses: [{ providerId: 'hetzner', access: 'credential-required' }], + }), + })); + await settle(); + + expect(view.container.textContent).toContain('hetzner needs an organization compute credential'); + await view.unmount(); + }); + + it('reports a provider failure the catalog answered 200 with', async () => { + const view = await render(dialog({ + listMachineTypes: async () => ({ + machineTypes: [], + failures: [{ providerId: 'hetzner', error: 'rate limited' }], + }), + })); + await settle(); + + expect(view.container.textContent).toContain('hetzner: rate limited'); + await view.unmount(); + }); + + it('shows a message when the catalog rejects with something that is not an Error', async () => { + const view = await render(dialog({ + // A rejection that is not an Error, which is what a stray throw produces. + listMachineTypes: () => Promise.reject('boom'), + })); + await settle(); + + const alert = view.container.querySelector('[role="alert"]'); + expect(alert?.textContent).toBe('The machine catalog could not be loaded.'); + await view.unmount(); + }); + it('tells a viewer they hold no machine', async () => { const view = await render(dialog({ workspace: { From c02c2b5d0b060aec2469d59285f19c9afbab28da Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Sat, 29 Aug 2026 20:02:47 +0000 Subject: [PATCH 2/9] feat: the persistent volume says how full it is, measured by the guest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The volume row said "Attached", which told a member what they could already see. What they could not see is whether the disk they keep everything on is about to run out. Nothing in the control plane knew either: a provider reports a volume's size and never its usage. Only the guest can measure it. Guest → control plane (new cross-runtime contract `machine-stats`): - `box/rootfs/usr/local/bin/blitz-machine-stats report` reads `origin` and `box-credential.json` out of the state directory exactly as `blitz-rules` does, measures `df --output=pcent` on that directory — the volume's mount point when there is one — and posts `{ diskUsedPercent }`. Every failure is expected offline behaviour: it says why on stderr and exits 0, so a disk figure can never take a box down. A df line that does not match `NN%` is not sent at all, because `Number('')` is 0 and 0% is the one wrong answer that looks like a right one. - The s6 longrun `machine-stats` (dependency: `register`, like `watch`) loops it every 600 s. The loop lives in the service so the script stays one shot and stays testable. It reaches the field with the NEXT box image. - `POST /workspaces/self/machine-stats` (`core/machine-stats.ts`) is box-authenticated like the other `/workspaces/self/*` routes and writes the caller's own machine row. It accepts an integer 0-100 and 400s everything else — a float, a numeric string, a null, a missing field — because a wrong figure overwrites the last true one and the column cannot say "this is a guess". Extra keys are tolerated so a newer guest still lands its percentage. No revision bump: a disk figure moving one point must not wake every poller. - Fixtures `packages/schema/fixtures/machine-stats/` are the accept rule, with conformance tests on both sides — the control plane over the real route with a real box credential, the guest by running the real script against a local origin and checking what it posts. Storage and wire: - Migration 0045 adds `machines.disk_used_percent` (nullable, CHECK 0-100) and `machines.disk_reported_at`. Neither takes a default: null is the honest pending state for every guest that predates the reporter, and 0 would read as an empty disk. - `MachineView.volumeUsedPercent: number | null` in `wire-machines.ts` and `packages/schema` together, pinned by `wire-drift.test.ts` with both a reported and an unreported machine. The projection answers null for a machine with no volume: such a machine measures its VM's root disk, which is not the durable thing the field names. UI, in both places volume state shows: - `src/VolumeMeter.tsx` renders the three states as three different facts: attached and measured → the bar with "62% full"; attached and unmeasured → the empty TRACK with "usage not reported yet"; no volume → "Not attached". The word "Attached" is gone. The fill turns red at 90%, because a meter that never warns is decoration. - The My-machine panel and the details Members rows both use it. A member row that holds a machine now reports the disk it has instead of a disabled checkbox; a draft row with no machine keeps the checkbox, which is still a real choice there. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01J6fUBY1B27EzvDwbhfBf52 --- CLAUDE.md | 1 + packages/box/Dockerfile | 2 +- .../test/machine-stats-conformance.test.ts | 212 ++++++++++++++++++ .../machine-stats/dependencies.d/register | 1 + .../etc/s6-overlay/s6-rc.d/machine-stats/run | 15 ++ .../etc/s6-overlay/s6-rc.d/machine-stats/type | 1 + .../s6-rc.d/user/contents.d/machine-stats | 1 + .../rootfs/usr/local/bin/blitz-machine-stats | 109 +++++++++ packages/control-plane/core/app.ts | 4 + packages/control-plane/core/machine-stats.ts | 67 ++++++ packages/control-plane/core/wire-machines.ts | 13 ++ packages/control-plane/core/wire.ts | 1 + .../control-plane/core/workspace-records.ts | 12 + .../migrations/0045_machine_disk_usage.sql | 17 ++ .../scripts/lib/worker-source.mjs | 3 +- .../control-plane/test/core-imports.test.ts | 3 +- .../test/machine-stats-conformance.test.ts | 177 +++++++++++++++ .../control-plane/test/wire-drift.test.ts | 17 ++ .../schema/fixtures/machine-stats/README.md | 27 +++ .../invalid-fractional-percent.json | 4 + .../invalid-missing-percent.json | 4 + .../invalid-negative-percent.json | 4 + .../machine-stats/invalid-null-percent.json | 4 + .../machine-stats/invalid-over-hundred.json | 4 + .../machine-stats/invalid-string-percent.json | 4 + .../machine-stats/valid-extra-key.json | 7 + .../fixtures/machine-stats/valid-full.json | 4 + .../fixtures/machine-stats/valid-mid.json | 4 + .../fixtures/machine-stats/valid-zero.json | 4 + packages/schema/src/workspace.ts | 13 ++ packages/webapp/src/MyMachineDialog.tsx | 14 +- packages/webapp/src/VolumeMeter.tsx | 61 +++++ .../webapp/src/WorkspaceMembersEditor.tsx | 18 +- .../webapp/src/workspace-details-dialog.css | 17 ++ .../test/WorkspaceDetailsDialog.test.tsx | 15 +- packages/webapp/test/my-machine.test.tsx | 53 ++++- 36 files changed, 898 insertions(+), 19 deletions(-) create mode 100644 packages/box/actor/test/machine-stats-conformance.test.ts create mode 100644 packages/box/rootfs/etc/s6-overlay/s6-rc.d/machine-stats/dependencies.d/register create mode 100644 packages/box/rootfs/etc/s6-overlay/s6-rc.d/machine-stats/run create mode 100644 packages/box/rootfs/etc/s6-overlay/s6-rc.d/machine-stats/type create mode 100644 packages/box/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/machine-stats create mode 100755 packages/box/rootfs/usr/local/bin/blitz-machine-stats create mode 100644 packages/control-plane/core/machine-stats.ts create mode 100644 packages/control-plane/migrations/0045_machine_disk_usage.sql create mode 100644 packages/control-plane/test/machine-stats-conformance.test.ts create mode 100644 packages/schema/fixtures/machine-stats/README.md create mode 100644 packages/schema/fixtures/machine-stats/invalid-fractional-percent.json create mode 100644 packages/schema/fixtures/machine-stats/invalid-missing-percent.json create mode 100644 packages/schema/fixtures/machine-stats/invalid-negative-percent.json create mode 100644 packages/schema/fixtures/machine-stats/invalid-null-percent.json create mode 100644 packages/schema/fixtures/machine-stats/invalid-over-hundred.json create mode 100644 packages/schema/fixtures/machine-stats/invalid-string-percent.json create mode 100644 packages/schema/fixtures/machine-stats/valid-extra-key.json create mode 100644 packages/schema/fixtures/machine-stats/valid-full.json create mode 100644 packages/schema/fixtures/machine-stats/valid-mid.json create mode 100644 packages/schema/fixtures/machine-stats/valid-zero.json create mode 100644 packages/webapp/src/VolumeMeter.tsx diff --git a/CLAUDE.md b/CLAUDE.md index a793b461..4eedba38 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,6 +74,7 @@ conformance tests on BOTH sides. Never hand-edit one side of a contract. | connection pull v1 | CP producer `core/connections/pull-wire.ts`, routes in `core/connections/pull-routes.ts` (`GET /workspaces/self/connections`, `POST /workspaces/self/connections/:name/token`) ↔ Go consumer `broker/internal/workspace/connections.go`, printed by `blitz-cred list\|get\|env`. Carries BOTH credential planes: the member's own connection grant and the workspace credential store (plans/MEMBER-MACHINES.md §4) | `fixtures/connection-pull/` | `test/connection-pull-conformance.test.ts` + `test/pull-credentials.test.ts` + `test/member-machines.test.ts` (CP), `broker/internal/workspace/connections_test.go` + `broker/cmd/blitz-cred/main_test.go` (box) | | entitlements | CP `core/entitlements.ts` (`PUT /orgs/:id/entitlements` writer, `GET /orgs/:id/usage`, the 402 seat-limit refusal and its HS256 handoff token) ↔ the PRIVATE billing service, which owns plans, writes the integers, and verifies the token — core never learns a plan name | `fixtures/entitlements/` | `test/entitlements-fixtures.test.ts` (CP); the billing service copies the corpus and pins it on its side | | recipe invocation files | `core/bootstrap.ts` writer (recipe launches emit `/var/lib/blitz/recipe/prompt.txt` + `invocation.env`) ↔ guest readers: `blitz-term` through the shared parser `box/rootfs/usr/local/libexec/blitz-recipe-invocation`, plus the bootstrap-emitted chat sender's raw `prompt.txt` read (the sender never parses `invocation.env` — model/effort/permission are interpolated into its source at render time) | `fixtures/recipe-invocation/` | `test/recipe-invocation-fixtures.test.ts` (CP), `box/actor/test/recipe-invocation-guest.test.ts` (guest: shared parser vs corpus + blitz-term delivery semantics) | +| machine-stats | guest producer `box/rootfs/usr/local/bin/blitz-machine-stats` (s6 longrun `machine-stats`, one report every 10 min) ↔ CP consumer `core/machine-stats.ts` (`POST /workspaces/self/machine-stats`), which fills `machines.disk_used_percent` and surfaces as `MachineView.volumeUsedPercent` | `fixtures/machine-stats/` | `test/machine-stats-conformance.test.ts` (CP), `box/actor/test/machine-stats-conformance.test.ts` (guest: the real script against a local origin) | | box config v1 | CP `core/box-config.ts` producer (`GET /workspaces/self/box-config`) and consumer (`POST /workspaces/self/box-update-result`) ↔ host updater bash/python emitted by `core/bootstrap.ts` (`blitz-box-update`; cloud-VM path only — the microVM provider has its own guest lifecycle and no update path yet) | `fixtures/box-config/` | `test/box-config-conformance.test.ts` (CP), `test/box-update-conformance.test.mjs` (runs real `python3` over the emitted parser/producer, `bash -n` over the emitted scripts), `test/box-update-host.test.mjs` (runs the emitted updater in real bash against a live CP over real curl) | Retired: the `workspace environment` contract (`GET /workspaces/self/environment` diff --git a/packages/box/Dockerfile b/packages/box/Dockerfile index 8dd65e16..6a86dbef 100644 --- a/packages/box/Dockerfile +++ b/packages/box/Dockerfile @@ -141,7 +141,7 @@ COPY packages/box/rootfs/ / RUN set -eux; \ chmod 0755 /usr/local/bin/blitz /usr/local/bin/claude /usr/local/bin/codex \ /usr/local/bin/blitz-cred-claude /usr/local/bin/blitz-cred-codex \ - /usr/local/bin/blitz-rules; \ + /usr/local/bin/blitz-rules /usr/local/bin/blitz-machine-stats; \ chmod 0755 /usr/local/libexec/blitz-* /etc/s6-overlay/s6-rc.d/*/run; \ install -d -m 0755 /workspace /var/lib/blitz /srv/blitz-files; \ ln -s /workspace /srv/blitz-files/workspace; \ diff --git a/packages/box/actor/test/machine-stats-conformance.test.ts b/packages/box/actor/test/machine-stats-conformance.test.ts new file mode 100644 index 00000000..20c3d51c --- /dev/null +++ b/packages/box/actor/test/machine-stats-conformance.test.ts @@ -0,0 +1,212 @@ +import { spawn } from "node:child_process"; +import { mkdtempSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; +import type { AddressInfo } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; + +/** Producer side of the `machine-stats` cross-runtime contract. The + * control-plane consumer is pinned against the same fixtures in + * packages/control-plane/test/machine-stats-conformance.test.ts; here the real + * `blitz-machine-stats report` script runs against a local origin and what it + * posts is checked against the corpus accept rule. */ + +interface StatsFixture { + request: Record; + accepts: boolean; +} + +const scriptPath = fileURLToPath( + new URL("../../rootfs/usr/local/bin/blitz-machine-stats", import.meta.url), +); +const fixturesDirectory = fileURLToPath( + new URL("../../../schema/fixtures/machine-stats/", import.meta.url), +); + +function fixtureNames(): string[] { + return readdirSync(fixturesDirectory).filter((name) => name.endsWith(".json")).sort(); +} + +function readFixture(name: string): StatsFixture { + // SAFETY: The machine-stats fixtures are trusted local test data authored to + // the { request, accepts } shape; the control-plane consumer test pins the + // same corpus. + return JSON.parse(readFileSync(join(fixturesDirectory, name), "utf8")) as StatsFixture; +} + +/** The corpus accept rule, restated here so the producer is checked against + * the same sentence the consumer implements: an object whose `diskUsedPercent` + * is an integer 0-100. The fixture list is the proof that this restatement and + * the control plane's agree. */ +function accepts(body: unknown): boolean { + if (typeof body !== "object" || body === null || Array.isArray(body)) return false; + const percent = (body as { diskUsedPercent?: unknown }).diskUsedPercent; + return typeof percent === "number" && Number.isInteger(percent) + && percent >= 0 && percent <= 100; +} + +interface BoxState { + stateDir: string; +} + +function makeState(origin: string | null, token: string | null): BoxState { + const stateDir = mkdtempSync(join(tmpdir(), "stats-state-")); + if (origin !== null) writeFileSync(join(stateDir, "origin"), `${origin}\n`); + if (token !== null) { + writeFileSync( + join(stateDir, "box-credential.json"), + `${JSON.stringify({ box_id: "box", access_token: token, refresh_token: "refresh" })}\n`, + ); + } + return { stateDir }; +} + +interface RunResult { + status: number | null; + stderr: string; +} + +function runReport(state: BoxState): Promise { + return new Promise((resolve) => { + const child = spawn("sh", [scriptPath, "report"], { + env: { ...process.env, BLITZ_STATE_DIR: state.stateDir }, + }); + let stderr = ""; + child.stderr.on("data", (chunk) => { + stderr += String(chunk); + }); + child.on("close", (status) => resolve({ status, stderr: stderr.trim() })); + }); +} + +interface Posted { + authorization: string | undefined; + contentType: string | undefined; + body: unknown; +} + +const servers: Server[] = []; + +function startServer(handler: (req: IncomingMessage, res: ServerResponse) => void): Promise { + const server = createServer(handler); + servers.push(server); + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + // SAFETY: The callback runs after binding an explicit TCP host/port, so + // address() is an AddressInfo, never a pipe string or null. + const address = server.address() as AddressInfo; + resolve(`http://127.0.0.1:${address.port}`); + }); + }); +} + +/** A stand-in control plane that records the one report it is sent. */ +function collector(status: number, posted: Posted[]): Promise { + return startServer((req, res) => { + if (req.url !== "/workspaces/self/machine-stats" || req.method !== "POST") { + res.writeHead(404); + res.end(); + return; + } + if (req.headers.authorization !== "Bearer good-token") { + res.writeHead(401); + res.end(); + return; + } + let raw = ""; + req.on("data", (chunk) => { + raw += String(chunk); + }); + req.on("end", () => { + let body: unknown = null; + try { + body = JSON.parse(raw); + } catch { + body = raw; + } + posted.push({ + authorization: req.headers.authorization, + contentType: req.headers["content-type"], + body, + }); + res.writeHead(status); + res.end(); + }); + }); +} + +afterEach(async () => { + await Promise.all( + servers.splice(0).map((server) => new Promise((resolve) => server.close(() => resolve()))), + ); +}); + +describe("blitz-machine-stats producer contract", () => { + it("pins the shared machine-stats fixture corpus", () => { + expect(fixtureNames()).toEqual([ + "invalid-fractional-percent.json", + "invalid-missing-percent.json", + "invalid-negative-percent.json", + "invalid-null-percent.json", + "invalid-over-hundred.json", + "invalid-string-percent.json", + "valid-extra-key.json", + "valid-full.json", + "valid-mid.json", + "valid-zero.json", + ]); + }); + + it("agrees with the corpus about which bodies the control plane takes", () => { + for (const name of fixtureNames()) { + const fixture = readFixture(name); + expect(accepts(fixture.request), name).toBe(fixture.accepts); + } + }); + + it("posts a body the control plane accepts, with the box credential", async () => { + const posted: Posted[] = []; + const origin = await collector(204, posted); + const state = makeState(origin, "good-token"); + + const result = await runReport(state); + + expect(result.status, result.stderr).toBe(0); + expect(posted).toHaveLength(1); + const report = posted[0]; + expect(report?.authorization).toBe("Bearer good-token"); + expect(report?.contentType).toBe("application/json"); + expect(accepts(report?.body)).toBe(true); + // The one key the contract names, and no other: an integer percentage + // measured off a real filesystem, this test's own temporary directory. + expect(Object.keys(report?.body as Record)).toEqual(["diskUsedPercent"]); + }); + + it("stays quiet and succeeds when there is nothing to report with", async () => { + const posted: Posted[] = []; + const origin = await collector(204, posted); + + for (const state of [makeState(null, "good-token"), makeState(origin, null)]) { + const result = await runReport(state); + expect(result.status, result.stderr).toBe(0); + } + expect(posted).toHaveLength(0); + }); + + it("fails open on a refused or broken control plane", async () => { + const posted: Posted[] = []; + const refusing = await collector(204, posted); + // Wrong token: the collector answers 401 before it records anything. + expect((await runReport(makeState(refusing, "wrong-token"))).status).toBe(0); + expect(posted).toHaveLength(0); + + const failing = await collector(500, posted); + expect((await runReport(makeState(failing, "good-token"))).status).toBe(0); + + // Nothing is listening on this port, so the fetch itself fails. + const dead = makeState("http://127.0.0.1:1", "good-token"); + expect((await runReport(dead)).status).toBe(0); + }); +}); diff --git a/packages/box/rootfs/etc/s6-overlay/s6-rc.d/machine-stats/dependencies.d/register b/packages/box/rootfs/etc/s6-overlay/s6-rc.d/machine-stats/dependencies.d/register new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/packages/box/rootfs/etc/s6-overlay/s6-rc.d/machine-stats/dependencies.d/register @@ -0,0 +1 @@ + diff --git a/packages/box/rootfs/etc/s6-overlay/s6-rc.d/machine-stats/run b/packages/box/rootfs/etc/s6-overlay/s6-rc.d/machine-stats/run new file mode 100644 index 00000000..107a4548 --- /dev/null +++ b/packages/box/rootfs/etc/s6-overlay/s6-rc.d/machine-stats/run @@ -0,0 +1,15 @@ +#!/command/with-contenv bash +# Report the state directory's disk usage to the control plane, every ten +# minutes, forever. +# +# The loop lives here rather than in the script so the script stays one shot +# and one thing: `blitz-machine-stats report` measures once, posts once, and +# exits 0 whatever happened. A disk figure is worth nothing next to the box it +# describes, so nothing in this service may take the box down with it — hence +# the `|| true` and the sleep that follows every outcome alike. +set -uo pipefail + +while true; do + /usr/local/bin/blitz-machine-stats report || true + sleep 600 +done diff --git a/packages/box/rootfs/etc/s6-overlay/s6-rc.d/machine-stats/type b/packages/box/rootfs/etc/s6-overlay/s6-rc.d/machine-stats/type new file mode 100644 index 00000000..5883cff0 --- /dev/null +++ b/packages/box/rootfs/etc/s6-overlay/s6-rc.d/machine-stats/type @@ -0,0 +1 @@ +longrun diff --git a/packages/box/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/machine-stats b/packages/box/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/machine-stats new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/packages/box/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/machine-stats @@ -0,0 +1 @@ + diff --git a/packages/box/rootfs/usr/local/bin/blitz-machine-stats b/packages/box/rootfs/usr/local/bin/blitz-machine-stats new file mode 100755 index 00000000..2dd20490 --- /dev/null +++ b/packages/box/rootfs/usr/local/bin/blitz-machine-stats @@ -0,0 +1,109 @@ +#!/bin/sh +set -eu + +usage() { + echo 'usage: blitz-machine-stats report' >&2 + exit 2 +} + +action=${1:-} +[ "$action" = report ] || usage +shift +[ "$#" -eq 0 ] || usage + +# The state directory is the mount point of the member's persistent volume when +# there is one, so measuring it measures the disk the member keeps files on. +# The conformance test overrides it to point at a scratch directory. +state_dir=${BLITZ_STATE_DIR:-/var/lib/blitz} + +exec node - "$state_dir" <<'NODE' +'use strict'; +const fs = require('node:fs'); +const path = require('node:path'); +const { execFileSync } = require('node:child_process'); + +const [stateDir] = process.argv.slice(2); +const TIMEOUT_MS = 10_000; + +// Every failure below is expected: a box with no credential yet, an origin +// that is unreachable, a df that will not answer. Say why on stderr and exit 0. +// A disk figure is worth exactly nothing next to the service it reports on, so +// nothing here may ever fail a boot or restart a supervised process. +function giveUp(reason) { + console.error(`blitz-machine-stats: no report (${reason})`); + process.exit(0); +} + +function read(name) { + try { + return fs.readFileSync(path.join(stateDir, name), 'utf8'); + } catch (error) { + giveUp(`no ${name} (${error && error.code ? error.code : 'unreadable'})`); + } +} + +function origin() { + const value = read('origin').split('\n', 1)[0].trim(); + if (value === '') giveUp('empty origin'); + return value.replace(/\/+$/, ''); +} + +function accessToken() { + let parsed; + try { + parsed = JSON.parse(read('box-credential.json')); + } catch { + giveUp('unparseable credential'); + } + const token = parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? parsed.access_token + : undefined; + if (typeof token !== 'string' || token.length === 0) giveUp('credential has no access_token'); + return token; +} + +// `df --output=pcent DIR` prints a header line and then one percentage, e.g. +// " 62%". The control plane takes an integer 0-100 and refuses anything else +// (packages/schema/fixtures/machine-stats/), so a line that does not parse is +// not sent at all. +function diskUsedPercent() { + let output; + try { + output = execFileSync('df', ['--output=pcent', stateDir], { + encoding: 'utf8', + timeout: TIMEOUT_MS, + }); + } catch (error) { + giveUp(`df failed (${error && error.message ? error.message : 'unknown'})`); + } + const figure = String(output).trim().split('\n').pop().trim(); + // Anchored, so an empty line cannot become 0%: Number('') is 0, and 0% is + // the one wrong answer that looks like a right one. + const matched = /^(\d{1,3})%$/.exec(figure); + const percent = matched === null ? -1 : Number(matched[1]); + if (percent < 0 || percent > 100) giveUp(`df said ${JSON.stringify(figure)}`); + return percent; +} + +async function main() { + const target = `${origin()}/workspaces/self/machine-stats`; + const token = accessToken(); + const diskUsed = diskUsedPercent(); + let response; + try { + response = await fetch(target, { + method: 'POST', + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ diskUsedPercent: diskUsed }), + signal: AbortSignal.timeout(TIMEOUT_MS), + }); + } catch (error) { + giveUp(`fetch failed (${error && error.message ? error.message : 'network'})`); + } + if (!response.ok) giveUp(`http ${response.status}`); + console.error(`blitz-machine-stats: reported ${diskUsed}%`); + process.exit(0); +} + +void main(); +NODE diff --git a/packages/control-plane/core/app.ts b/packages/control-plane/core/app.ts index 74f772a5..af10c71e 100644 --- a/packages/control-plane/core/app.ts +++ b/packages/control-plane/core/app.ts @@ -7,6 +7,7 @@ import { addEntitlementsRoutes, SeatLimitReached, seatLimitEnvelope } from "./en import { frameworkHttpError, HttpError } from "./http.js"; import { addFilesRoutes } from "./files/routes.js"; import { addMachineRoutes } from "./machines.js"; +import { addMachineStatsRoutes } from "./machine-stats.js"; import { addIdentityRoutes } from "./identity/routes.js"; import { addOAuthRoutes } from "./oauth.js"; import { addOperatorTokenRoutes, findOperatorTokenPrincipal } from "./operator-tokens.js"; @@ -105,6 +106,9 @@ export function installControlPlaneRoutes( // same reason; its one session route (/workspaces/:id/box-update) collides // with nothing. addBoxConfigRoutes(router, runtimeFactory, requireMembershipPrincipal); + // Box-authenticated too, and registered here for the same prefix reason: the + // guest's own disk report (packages/schema/fixtures/machine-stats/). + addMachineStatsRoutes(router, runtimeFactory); // Registered before addWorkspaceRoutes: /workspaces/:id/members and // /workspaces/:id/credentials are literal paths under the same prefix. addWorkspaceMemberRoutes(router, runtimeFactory, requireMembershipPrincipal); diff --git a/packages/control-plane/core/machine-stats.ts b/packages/control-plane/core/machine-stats.ts new file mode 100644 index 00000000..bcd6b11a --- /dev/null +++ b/packages/control-plane/core/machine-stats.ts @@ -0,0 +1,67 @@ +import { changed } from "./db.js"; +import { HttpError, isNumber, isRecord, readJson, type JsonValue } from "./http.js"; +import { authenticateBox } from "./oauth.js"; +import type { CoreContext, CoreRouter, RuntimeFactory } from "./runtime.js"; +import type { MachineStatsRequest } from "./wire.js"; + +// The machine-stats contract (see packages/schema/fixtures/machine-stats/): +// the guest measures the filesystem holding its state directory every ten +// minutes and posts `{ diskUsedPercent }` here with its box credential. The +// producer is `box/rootfs/usr/local/bin/blitz-machine-stats`; both sides are +// pinned against that corpus. Edit the accept rule below and the guest's copy +// of it together, never one alone. + +/** The body cap. One integer in one object needs nothing like this much room; + * the slack is there so a forward-compatible guest can add a field without + * this route refusing the whole report. */ +const MAX_BODY_BYTES = 4 * 1024; + +/** + * Accepts iff the body is an object whose `diskUsedPercent` is an integer + * between 0 and 100 inclusive. + * + * Everything else is a 400, including a float and a numeric string. A guest + * that cannot measure its disk must send nothing at all — a wrong number + * would overwrite the last true one, and the column has no way to say "this + * figure is a guess". Unknown extra keys are tolerated, because a newer guest + * reporting more than this control plane knows about must still land its + * percentage. + */ +export function parseMachineStats(value: JsonValue): MachineStatsRequest { + if (!isRecord(value)) throw new HttpError(400, "request body must be an object"); + const percent = value.diskUsedPercent; + if (!isNumber(percent) || !Number.isInteger(percent)) { + throw new HttpError(400, "diskUsedPercent must be an integer"); + } + if (percent < 0 || percent > 100) { + throw new HttpError(400, "diskUsedPercent must be between 0 and 100"); + } + return { diskUsedPercent: percent }; +} + +export function addMachineStatsRoutes( + router: CoreRouter, + runtimeFactory: RuntimeFactory, +): void { + // Box-authenticated, like every other /workspaces/self/* route: the machine + // reports on itself, so the row it writes is the one its credential names + // and no id crosses the wire. + router.post("/workspaces/self/machine-stats", async (context: CoreContext) => { + const runtime = runtimeFactory(context); + const box = await authenticateBox(context.req.raw, runtime.db); + if (box === null) throw new HttpError(401, "invalid box access token"); + if (box.workspaceId === null) { + throw new HttpError(403, "only workspace machines report machine stats"); + } + const input = parseMachineStats(await readJson(context.req.raw, MAX_BODY_BYTES)); + // No `updated_at` bump and no workspace revision bump: a disk figure that + // moves one point must not wake every poller in the organization, and the + // next poll carries it anyway. + await changed(runtime.db, { + q: `UPDATE machines SET disk_used_percent = ?1, disk_reported_at = ?2 + WHERE id = ?3 RETURNING id`, + v: [input.diskUsedPercent, Date.now(), box.id], + }); + return context.body(null, 204); + }); +} diff --git a/packages/control-plane/core/wire-machines.ts b/packages/control-plane/core/wire-machines.ts index a08f6f09..78f75afb 100644 --- a/packages/control-plane/core/wire-machines.ts +++ b/packages/control-plane/core/wire-machines.ts @@ -33,12 +33,25 @@ export interface MachineView { /** This machine's type. The workspace holds only a default. */ machineTypeId: string; volumeId: string | null; + /** How full the machine's persistent volume is, 0-100, as the guest last + * measured it. Null means the question has no answer yet: there is no + * volume, or no guest has reported one (every box image before the reporter + * shipped). Null is never 0 — an unmeasured disk is not an empty one. */ + volumeUsedPercent: number | null; membershipId: string; error: string | null; createdAt: number; updatedAt: number; } +/** The guest's own disk report (`POST /workspaces/self/machine-stats`). + * `diskUsedPercent` is an integer 0-100, the used percentage of the filesystem + * holding the state directory. Anything else is a 400: a machine reporting + * nonsense about its disk must not overwrite the last true figure. */ +export interface MachineStatsRequest { + diskUsedPercent: number; +} + export interface WorkspaceMemberView { membershipId: string; name: string; diff --git a/packages/control-plane/core/wire.ts b/packages/control-plane/core/wire.ts index 2bcae433..fdcf9565 100644 --- a/packages/control-plane/core/wire.ts +++ b/packages/control-plane/core/wire.ts @@ -181,6 +181,7 @@ export { type AddWorkspaceMemberRequest, type MachineResponse, type MachineState, + type MachineStatsRequest, type MachineView, type ProvisionMemberMachineRequest, type PutWorkspaceCredentialRequest, diff --git a/packages/control-plane/core/workspace-records.ts b/packages/control-plane/core/workspace-records.ts index 8108f391..0aa3d938 100644 --- a/packages/control-plane/core/workspace-records.ts +++ b/packages/control-plane/core/workspace-records.ts @@ -60,6 +60,8 @@ export interface MachineRow { broker_box_id: string | null; box_update_requested: number; box_image_reported: string | null; + disk_used_percent: number | null; + disk_reported_at: number | null; error: string | null; created_at: number; updated_at: number; @@ -111,12 +113,22 @@ function machineTypeIdForRow(row: MachineRow): string { : row.machine_type_id; } +/** The guest's last disk report, but only for a machine that has a volume to + * report on. A machine with no volume measures its VM's root disk, which is + * the provider's business and not a durable thing anybody keeps files on, so + * the field keeps the name it is given and answers null. The 0-100 range is + * the column's own CHECK (migration 0045), not something to re-decide here. */ +function volumeUsedPercentForRow(row: MachineRow): number | null { + return row.volume_id === null ? null : row.disk_used_percent; +} + export function machineView(row: MachineRow): MachineView { return { id: row.id, state: row.state, machineTypeId: machineTypeIdForRow(row), volumeId: row.volume_id, + volumeUsedPercent: volumeUsedPercentForRow(row), membershipId: row.membership_id, error: row.error, createdAt: row.created_at, diff --git a/packages/control-plane/migrations/0045_machine_disk_usage.sql b/packages/control-plane/migrations/0045_machine_disk_usage.sql new file mode 100644 index 00000000..1acbd13f --- /dev/null +++ b/packages/control-plane/migrations/0045_machine_disk_usage.sql @@ -0,0 +1,17 @@ +-- What the guest says about its own disk. +-- +-- The persistent volume is the durable half of a member's machine +-- (plans/MEMBER-MACHINES.md §1), and nothing in the control plane could say +-- how full it was: a provider reports a size, never a usage. Only the guest +-- can measure it, so the guest reports it (POST /workspaces/self/machine-stats) +-- and it lands here. +-- +-- Both columns are nullable and stay null forever for a machine whose guest +-- predates the reporter. That is the honest pending state the UI shows, and it +-- is why neither column takes a default: 0 would read as "empty disk". +ALTER TABLE machines ADD COLUMN disk_used_percent INTEGER + CHECK (disk_used_percent IS NULL OR (disk_used_percent BETWEEN 0 AND 100)); + +-- When that percentage was measured, in epoch ms. A stale figure is worth +-- less than a fresh one, and only this column can tell them apart. +ALTER TABLE machines ADD COLUMN disk_reported_at INTEGER; diff --git a/packages/control-plane/scripts/lib/worker-source.mjs b/packages/control-plane/scripts/lib/worker-source.mjs index dae5d20e..2e2af257 100644 --- a/packages/control-plane/scripts/lib/worker-source.mjs +++ b/packages/control-plane/scripts/lib/worker-source.mjs @@ -45,6 +45,7 @@ export const CORE_MANIFEST = Object.freeze([ "core/identity/google.ts", "core/identity/invites.ts", "core/identity/members.ts", "core/identity/orgs.ts", "core/identity/routes.ts", "core/janitors.ts", "core/machines.ts", + "core/machine-stats.ts", "core/oauth-state.ts", "core/oauth.ts", "core/operator-tokens.ts", @@ -226,7 +227,7 @@ export const BLITZDEV_CONFIG = Object.freeze({ }, // One VM per (workspace, member). The volume is the durable half: a type // change destroys the VM and keeps the disk. - { name: "machines", fields: [{ name: "id", type: "text", sqlType: "text", primary: true, noUpdate: true, usage: "record_uid" }, { name: "workspace_id", type: "text", sqlType: "text", notNull: true, foreignKey: { table: "workspaces", column: "id" } }, { name: "membership_id", type: "text", sqlType: "text", notNull: true, foreignKey: { table: "memberships", column: "id" } }, { name: "state", type: "text", sqlType: "text", notNull: true, check: "state IN ('provisioning', 'running', 'stopped', 'error', 'destroying', 'destroyed')" }, { name: "machine_type_id", type: "text", sqlType: "text", notNull: true }, { name: "compute_credential_source", type: "text", sqlType: "text", notNull: true, default: { l: "deployment" }, check: "compute_credential_source IN ('org', 'deployment')" }, { name: "vm_id", type: "text", sqlType: "text" }, { name: "volume_id", type: "text", sqlType: "text" }, { name: "ssh_host", type: "text", sqlType: "text" }, { name: "ssh_port", type: "integer", sqlType: "integer" }, { name: "ssh_user", type: "text", sqlType: "text" }, { name: "ssh_host_public_key", type: "text", sqlType: "text" }, { name: "phone_home_hash", type: "text", sqlType: "text" }, { name: "phone_home_used", type: "bool", sqlType: "integer", notNull: true, default: { l: 0 }, check: "phone_home_used IN (0, 1)" }, { name: "tunnel_id", type: "text", sqlType: "text" }, { name: "tunnel_hostname", type: "text", sqlType: "text" }, { name: "dns_record_id", type: "text", sqlType: "text" }, { name: "broker_box_id", type: "text", sqlType: "text", foreignKey: { table: "broker_boxes", column: "box_id", onDelete: "SET NULL" } }, { name: "box_update_requested", type: "bool", sqlType: "integer", notNull: true, default: { l: 0 }, check: "box_update_requested IN (0, 1)" }, { name: "box_image_reported", type: "text", sqlType: "text" }, { name: "error", type: "text", sqlType: "text" }, { name: "created_at", type: "integer", sqlType: "integer", notNull: true }, { name: "updated_at", type: "integer", sqlType: "integer", notNull: true }], indexes: [{ name: "identity", unique: true, fields: ["workspace_id", "membership_id"] }, { name: "workspace", fields: ["workspace_id", "created_at"] }, { name: "state", fields: ["state", "updated_at"] }], extensions: [DENY_ALL_RULES] }, + { name: "machines", fields: [{ name: "id", type: "text", sqlType: "text", primary: true, noUpdate: true, usage: "record_uid" }, { name: "workspace_id", type: "text", sqlType: "text", notNull: true, foreignKey: { table: "workspaces", column: "id" } }, { name: "membership_id", type: "text", sqlType: "text", notNull: true, foreignKey: { table: "memberships", column: "id" } }, { name: "state", type: "text", sqlType: "text", notNull: true, check: "state IN ('provisioning', 'running', 'stopped', 'error', 'destroying', 'destroyed')" }, { name: "machine_type_id", type: "text", sqlType: "text", notNull: true }, { name: "compute_credential_source", type: "text", sqlType: "text", notNull: true, default: { l: "deployment" }, check: "compute_credential_source IN ('org', 'deployment')" }, { name: "vm_id", type: "text", sqlType: "text" }, { name: "volume_id", type: "text", sqlType: "text" }, { name: "ssh_host", type: "text", sqlType: "text" }, { name: "ssh_port", type: "integer", sqlType: "integer" }, { name: "ssh_user", type: "text", sqlType: "text" }, { name: "ssh_host_public_key", type: "text", sqlType: "text" }, { name: "phone_home_hash", type: "text", sqlType: "text" }, { name: "phone_home_used", type: "bool", sqlType: "integer", notNull: true, default: { l: 0 }, check: "phone_home_used IN (0, 1)" }, { name: "tunnel_id", type: "text", sqlType: "text" }, { name: "tunnel_hostname", type: "text", sqlType: "text" }, { name: "dns_record_id", type: "text", sqlType: "text" }, { name: "broker_box_id", type: "text", sqlType: "text", foreignKey: { table: "broker_boxes", column: "box_id", onDelete: "SET NULL" } }, { name: "box_update_requested", type: "bool", sqlType: "integer", notNull: true, default: { l: 0 }, check: "box_update_requested IN (0, 1)" }, { name: "box_image_reported", type: "text", sqlType: "text" }, { name: "disk_used_percent", type: "integer", sqlType: "integer", check: "disk_used_percent IS NULL OR (disk_used_percent BETWEEN 0 AND 100)" }, { name: "disk_reported_at", type: "integer", sqlType: "integer" }, { name: "error", type: "text", sqlType: "text" }, { name: "created_at", type: "integer", sqlType: "integer", notNull: true }, { name: "updated_at", type: "integer", sqlType: "integer", notNull: true }], indexes: [{ name: "identity", unique: true, fields: ["workspace_id", "membership_id"] }, { name: "workspace", fields: ["workspace_id", "created_at"] }, { name: "state", fields: ["state", "updated_at"] }], extensions: [DENY_ALL_RULES] }, { name: "workspace_members", fields: [{ name: "workspace_id", type: "text", sqlType: "text", notNull: true, foreignKey: { table: "workspaces", column: "id" } }, { name: "membership_id", type: "text", sqlType: "text", notNull: true, foreignKey: { table: "memberships", column: "id" } }, { name: "role", type: "text", sqlType: "text", notNull: true, check: "role IN ('admin', 'member', 'viewer')" }, { name: "added_by_membership_id", type: "text", sqlType: "text", foreignKey: { table: "memberships", column: "id" } }, { name: "added_at", type: "integer", sqlType: "integer", notNull: true }], indexes: [{ name: "identity", unique: true, fields: ["workspace_id", "membership_id"] }, { name: "membership", fields: ["membership_id", "workspace_id"] }], extensions: [DENY_ALL_RULES] }, { name: "workspace_credentials", fields: [{ name: "id", type: "text", sqlType: "text", primary: true, noUpdate: true, usage: "record_uid" }, { name: "workspace_id", type: "text", sqlType: "text", notNull: true, foreignKey: { table: "workspaces", column: "id" } }, { name: "name", type: "text", sqlType: "text", notNull: true }, { name: "label", type: "text", sqlType: "text" }, { name: "ciphertext", type: "text", sqlType: "text", notNull: true }, { name: "created_by_membership_id", type: "text", sqlType: "text", notNull: true, foreignKey: { table: "memberships", column: "id" } }, { name: "created_at", type: "integer", sqlType: "integer", notNull: true }, { name: "updated_at", type: "integer", sqlType: "integer", notNull: true }, { name: "revoked_at", type: "integer", sqlType: "integer" }], indexes: [{ name: "workspace", fields: ["workspace_id", "created_at"] }], extensions: [DENY_ALL_RULES] }, { diff --git a/packages/control-plane/test/core-imports.test.ts b/packages/control-plane/test/core-imports.test.ts index f44a3311..b1cfed01 100644 --- a/packages/control-plane/test/core-imports.test.ts +++ b/packages/control-plane/test/core-imports.test.ts @@ -64,6 +64,7 @@ const expected = [ "identity/routes.ts", "index.ts", "janitors.ts", + "machine-stats.ts", "machines.ts", "oauth.ts", "operator-tokens.ts", @@ -129,6 +130,6 @@ describe("portable core imports", () => { (values: string[]) => values.every((value) => value.startsWith("./") || value.startsWith("../")), ); } - expect(expected).toHaveLength(104); + expect(expected).toHaveLength(105); }); }); diff --git a/packages/control-plane/test/machine-stats-conformance.test.ts b/packages/control-plane/test/machine-stats-conformance.test.ts new file mode 100644 index 00000000..9aff6d0d --- /dev/null +++ b/packages/control-plane/test/machine-stats-conformance.test.ts @@ -0,0 +1,177 @@ +import { env } from "cloudflare:workers"; +import { beforeEach, describe, expect, it } from "vitest"; +import { + appRequest, + harness, + operatorSession, + phoneHomeUrl, + resetDatabase, + type BoxCredential, +} from "./helpers.js"; + +// Control-plane side of the `machine-stats` cross-runtime contract: this +// Worker consumes the guest's disk report. The producer +// (box/rootfs/usr/local/bin/blitz-machine-stats) is pinned against the same +// corpus in packages/box/actor/test/machine-stats-conformance.test.ts. + +interface StatsFixture { + request: Record; + accepts: boolean; +} + +const fixtureSources = import.meta.glob( + "../../schema/fixtures/machine-stats/*.json", + { eager: true, import: "default", query: "?raw" }, +); + +function fixtures(): Array<[string, StatsFixture]> { + return Object.entries(fixtureSources) + .map(([path, source]): [string, string] => [path.slice(path.lastIndexOf("/") + 1), source]) + // SAFETY: The machine-stats fixtures are trusted local test data authored + // to the { request, accepts } shape; the guest test pins the same corpus. + .map(([name, source]): [string, StatsFixture] => [name, JSON.parse(source) as StatsFixture]) + .sort(([left], [right]) => left.localeCompare(right)); +} + +type Harness = ReturnType; + +async function readyWorkspaceBox( + { app, providers }: Harness, + cookie: string, +): Promise<{ workspaceId: string; box: BoxCredential }> { + const created = await appRequest(app, "/workspaces", { + method: "POST", + headers: { Cookie: cookie, "Content-Type": "application/json" }, + body: JSON.stringify({ machineTypeId: "small" }), + }); + expect(created.status).toBe(201); + const { workspace } = await created.json<{ workspace: { id: string } }>(); + const callback = new URL(phoneHomeUrl(providers, workspace.id)); + const enrolled = await appRequest(app, callback.pathname, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ pub_key_ed25519: "ssh-ed25519 AAAAC3Nzatest host" }), + }); + expect(enrolled.status).toBe(200); + return { workspaceId: workspace.id, box: await enrolled.json() }; +} + +function report( + app: Harness["app"], + box: BoxCredential, + body: unknown, +): Promise { + return appRequest(app, "/workspaces/self/machine-stats", { + method: "POST", + headers: { + Authorization: `Bearer ${box.access_token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); +} + +async function storedStats( + workspaceId: string, +): Promise<{ disk_used_percent: number | null; disk_reported_at: number | null }> { + const row = await env.DB + .prepare("SELECT disk_used_percent, disk_reported_at FROM machines WHERE workspace_id = ?1") + .bind(workspaceId) + .first<{ disk_used_percent: number | null; disk_reported_at: number | null }>(); + if (row === null) throw new Error("machine row missing"); + return row; +} + +describe("machine-stats control-plane conformance", () => { + beforeEach(async () => { + await resetDatabase(); + }); + + it("pins the shared machine-stats fixture corpus", () => { + expect(fixtures().map(([name]) => name)).toEqual([ + "invalid-fractional-percent.json", + "invalid-missing-percent.json", + "invalid-negative-percent.json", + "invalid-null-percent.json", + "invalid-over-hundred.json", + "invalid-string-percent.json", + "valid-extra-key.json", + "valid-full.json", + "valid-mid.json", + "valid-zero.json", + ]); + }); + + it("answers every fixture exactly as the corpus says it must", async () => { + const h = harness(); + const cookie = await operatorSession(); + const { workspaceId, box } = await readyWorkspaceBox(h, cookie); + + for (const [name, fixture] of fixtures()) { + const response = await report(h.app, box, fixture.request); + expect(response.status, name).toBe(fixture.accepts ? 204 : 400); + if (fixture.accepts) { + const stored = await storedStats(workspaceId); + expect(stored.disk_used_percent, name).toBe(fixture.request.diskUsedPercent); + expect(stored.disk_reported_at, name).toBeGreaterThan(0); + } + } + }); + + it("leaves the last true figure alone when a report is refused", async () => { + const h = harness(); + const cookie = await operatorSession(); + const { workspaceId, box } = await readyWorkspaceBox(h, cookie); + + expect((await report(h.app, box, { diskUsedPercent: 62 })).status).toBe(204); + expect((await report(h.app, box, { diskUsedPercent: 900 })).status).toBe(400); + + expect((await storedStats(workspaceId)).disk_used_percent).toBe(62); + }); + + it("writes only the reporting machine's own row", async () => { + const h = harness(); + const cookie = await operatorSession(); + const first = await readyWorkspaceBox(h, cookie); + const second = await readyWorkspaceBox(h, cookie); + + expect((await report(h.app, first.box, { diskUsedPercent: 12 })).status).toBe(204); + + expect((await storedStats(first.workspaceId)).disk_used_percent).toBe(12); + expect((await storedStats(second.workspaceId)).disk_used_percent).toBeNull(); + }); + + it("refuses an unauthenticated report", async () => { + const h = harness(); + const response = await appRequest(h.app, "/workspaces/self/machine-stats", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ diskUsedPercent: 10 }), + }); + expect(response.status).toBe(401); + }); + + it("carries the reported figure onto the wire as volumeUsedPercent", async () => { + const h = harness(); + // The fake places no volume unless a suite asks it to, and the wire field + // is about the volume, so this suite asks. + h.providers.volumeLocation = () => "test"; + const cookie = await operatorSession(); + const { workspaceId, box } = await readyWorkspaceBox(h, cookie); + expect((await report(h.app, box, { diskUsedPercent: 62 })).status).toBe(204); + + const poll = await appRequest(h.app, "/workspaces", { headers: { Cookie: cookie } }); + expect(poll.status).toBe(200); + const { workspaces } = await poll.json<{ + workspaces: Array<{ + id: string; + members: Array<{ machine: { volumeId: string | null; volumeUsedPercent: number | null } | null }>; + }>; + }>(); + const machine = workspaces.find(({ id }) => id === workspaceId)?.members[0]?.machine; + // The fake provider gives this machine a volume, so the percentage is + // about something a member keeps files on and travels. + expect(machine?.volumeId).not.toBeNull(); + expect(machine?.volumeUsedPercent).toBe(62); + }); +}); diff --git a/packages/control-plane/test/wire-drift.test.ts b/packages/control-plane/test/wire-drift.test.ts index b3f64fe8..a89c67c1 100644 --- a/packages/control-plane/test/wire-drift.test.ts +++ b/packages/control-plane/test/wire-drift.test.ts @@ -142,12 +142,26 @@ const machine: SharedShape = { state: "running", machineTypeId: "mv-2c2g@lab", volumeId: volume.id, + volumeUsedPercent: 62, membershipId: "membership", error: null, createdAt: 1_700_000_000_000, updatedAt: 1_700_000_005_000, }; +// A machine whose guest has not reported yet answers null, which covers +// different ground than an integer does. +const unreportedMachine: SharedShape = { + ...machine, + id: "machine-unreported", + volumeUsedPercent: null, +}; + +const machineStats: SharedShape< + wire.MachineStatsRequest, + schema.MachineStatsRequest +> = { diskUsedPercent: 62 }; + const workspaceMember: SharedShape< wire.WorkspaceMemberView, schema.WorkspaceMemberView @@ -660,6 +674,8 @@ const connectionsResponse: SharedShape< const fullFieldValues = [ machine, + unreportedMachine, + machineStats, workspaceMember, viewerMember, workspaceCredential, @@ -752,6 +768,7 @@ describe("local wire copies", () => { expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); diff --git a/packages/schema/fixtures/machine-stats/README.md b/packages/schema/fixtures/machine-stats/README.md new file mode 100644 index 00000000..847b4a57 --- /dev/null +++ b/packages/schema/fixtures/machine-stats/README.md @@ -0,0 +1,27 @@ +# Machine-stats fixtures + +The guest measures the filesystem holding its state directory and posts the +result to `POST /workspaces/self/machine-stats` with its box credential. The +payload crosses TS ↔ sh/node: the producer is +`packages/box/rootfs/usr/local/bin/blitz-machine-stats`, the consumer is +`packages/control-plane/core/machine-stats.ts`. This corpus is the accept rule +both sides are pinned to. + +Each fixture pairs a candidate request body (`request`) with whether the +control-plane consumer must accept it (`accepts`). + +The accept rule: a JSON object whose `diskUsedPercent` is an integer between 0 +and 100 inclusive. A float, a numeric string, a null and a missing field are +all refused with 400 — a machine that cannot measure its disk must send +nothing, because a wrong figure overwrites the last true one and the column +has no way to say "this one is a guess". Unknown extra keys are accepted, so a +newer guest that reports more than this control plane understands still lands +its percentage. + +Conformance tests: + +- `packages/control-plane/test/machine-stats-conformance.test.ts` — the + consumer, over the real route with a real box credential. +- `packages/box/actor/test/machine-stats-conformance.test.ts` — the producer: + the real reporter script runs against a local origin, and what it posts is + checked against the same accept rule. diff --git a/packages/schema/fixtures/machine-stats/invalid-fractional-percent.json b/packages/schema/fixtures/machine-stats/invalid-fractional-percent.json new file mode 100644 index 00000000..fc94a215 --- /dev/null +++ b/packages/schema/fixtures/machine-stats/invalid-fractional-percent.json @@ -0,0 +1,4 @@ +{ + "request": { "diskUsedPercent": 62.5 }, + "accepts": false +} diff --git a/packages/schema/fixtures/machine-stats/invalid-missing-percent.json b/packages/schema/fixtures/machine-stats/invalid-missing-percent.json new file mode 100644 index 00000000..b064e219 --- /dev/null +++ b/packages/schema/fixtures/machine-stats/invalid-missing-percent.json @@ -0,0 +1,4 @@ +{ + "request": { "note": "a report with no measurement in it" }, + "accepts": false +} diff --git a/packages/schema/fixtures/machine-stats/invalid-negative-percent.json b/packages/schema/fixtures/machine-stats/invalid-negative-percent.json new file mode 100644 index 00000000..d804d009 --- /dev/null +++ b/packages/schema/fixtures/machine-stats/invalid-negative-percent.json @@ -0,0 +1,4 @@ +{ + "request": { "diskUsedPercent": -1 }, + "accepts": false +} diff --git a/packages/schema/fixtures/machine-stats/invalid-null-percent.json b/packages/schema/fixtures/machine-stats/invalid-null-percent.json new file mode 100644 index 00000000..5956a16b --- /dev/null +++ b/packages/schema/fixtures/machine-stats/invalid-null-percent.json @@ -0,0 +1,4 @@ +{ + "request": { "diskUsedPercent": null }, + "accepts": false +} diff --git a/packages/schema/fixtures/machine-stats/invalid-over-hundred.json b/packages/schema/fixtures/machine-stats/invalid-over-hundred.json new file mode 100644 index 00000000..d3fda155 --- /dev/null +++ b/packages/schema/fixtures/machine-stats/invalid-over-hundred.json @@ -0,0 +1,4 @@ +{ + "request": { "diskUsedPercent": 101 }, + "accepts": false +} diff --git a/packages/schema/fixtures/machine-stats/invalid-string-percent.json b/packages/schema/fixtures/machine-stats/invalid-string-percent.json new file mode 100644 index 00000000..a302a143 --- /dev/null +++ b/packages/schema/fixtures/machine-stats/invalid-string-percent.json @@ -0,0 +1,4 @@ +{ + "request": { "diskUsedPercent": "62" }, + "accepts": false +} diff --git a/packages/schema/fixtures/machine-stats/valid-extra-key.json b/packages/schema/fixtures/machine-stats/valid-extra-key.json new file mode 100644 index 00000000..c08bdaff --- /dev/null +++ b/packages/schema/fixtures/machine-stats/valid-extra-key.json @@ -0,0 +1,7 @@ +{ + "request": { + "diskUsedPercent": 41, + "note": "unknown keys are tolerated for forward compatibility" + }, + "accepts": true +} diff --git a/packages/schema/fixtures/machine-stats/valid-full.json b/packages/schema/fixtures/machine-stats/valid-full.json new file mode 100644 index 00000000..b7a6ffdd --- /dev/null +++ b/packages/schema/fixtures/machine-stats/valid-full.json @@ -0,0 +1,4 @@ +{ + "request": { "diskUsedPercent": 100 }, + "accepts": true +} diff --git a/packages/schema/fixtures/machine-stats/valid-mid.json b/packages/schema/fixtures/machine-stats/valid-mid.json new file mode 100644 index 00000000..85810ec8 --- /dev/null +++ b/packages/schema/fixtures/machine-stats/valid-mid.json @@ -0,0 +1,4 @@ +{ + "request": { "diskUsedPercent": 62 }, + "accepts": true +} diff --git a/packages/schema/fixtures/machine-stats/valid-zero.json b/packages/schema/fixtures/machine-stats/valid-zero.json new file mode 100644 index 00000000..9be2802d --- /dev/null +++ b/packages/schema/fixtures/machine-stats/valid-zero.json @@ -0,0 +1,4 @@ +{ + "request": { "diskUsedPercent": 0 }, + "accepts": true +} diff --git a/packages/schema/src/workspace.ts b/packages/schema/src/workspace.ts index 87d5d8b5..c3a84197 100644 --- a/packages/schema/src/workspace.ts +++ b/packages/schema/src/workspace.ts @@ -59,12 +59,25 @@ export interface MachineView { /** This machine's type. The workspace holds only a default. */ machineTypeId: string; volumeId: string | null; + /** How full the machine's persistent volume is, 0-100, as the guest last + * measured it. Null means the question has no answer yet: there is no + * volume, or no guest has reported one (every box image before the reporter + * shipped). Null is never 0 — an unmeasured disk is not an empty one. */ + volumeUsedPercent: number | null; membershipId: string; error: string | null; createdAt: number; updatedAt: number; } +/** The guest's own disk report (`POST /workspaces/self/machine-stats`). + * `diskUsedPercent` is an integer 0-100, the used percentage of the filesystem + * holding the state directory. Anything else is a 400: a machine reporting + * nonsense about its disk must not overwrite the last true figure. */ +export interface MachineStatsRequest { + diskUsedPercent: number; +} + export interface WorkspaceMemberView { membershipId: string; name: string; diff --git a/packages/webapp/src/MyMachineDialog.tsx b/packages/webapp/src/MyMachineDialog.tsx index 04471578..fdbb2f9d 100644 --- a/packages/webapp/src/MyMachineDialog.tsx +++ b/packages/webapp/src/MyMachineDialog.tsx @@ -11,6 +11,7 @@ import { ConfirmationDialog } from './ConfirmationDialog'; import { monthlyPriceLabel } from './MachineCatalogGrid'; import { MachineTypeSelect } from './MachineTypeSelect'; import { ModalOverlay } from './ModalOverlay'; +import { VolumeMeter } from './VolumeMeter'; import { machineActionsFor, type MachineAction } from './WorkspaceMembersEditor'; import type { CloudWorkspaceModel } from './workspace-store'; @@ -220,10 +221,15 @@ export function MyMachineDialog({ } - +
+
Persistent volume
+
+ +
+
{type === undefined && machine !== null && ( diff --git a/packages/webapp/src/VolumeMeter.tsx b/packages/webapp/src/VolumeMeter.tsx new file mode 100644 index 00000000..67d7c7df --- /dev/null +++ b/packages/webapp/src/VolumeMeter.tsx @@ -0,0 +1,61 @@ +/** + * How full a member's persistent volume is, in one list-row-sized bar. + * + * Three states, and they are three different facts rather than three ways of + * saying one: + * + * - no volume at all — "Not attached". Nothing to measure, so no bar. + * - a volume the guest has measured — the bar, filled, with "62% full". + * - a volume nobody has measured yet — an empty TRACK, never an empty bar, + * with "usage not reported yet". Every box image from before the reporter + * shipped is in this state, and drawing 0% for it would be a lie: an + * unmeasured disk is not an empty one. + * + * The old row said "Attached", which was true and useless — the member could + * already see they had a disk; what they could not see was whether it was + * about to run out. + */ + +/** Where the bar stops being informational and starts being a warning. A disk + * this full is the reason anybody looks at this row. */ +const FULL_ENOUGH_TO_WARN = 90; + +export function VolumeMeter({ + volumeId, + usedPercent, +}: { + volumeId: string | null; + /** 0-100 as the guest last measured it, or null for "not measured yet". */ + usedPercent: number | null; +}) { + if (volumeId === null) { + return Not attached; + } + if (usedPercent === null) { + return ( + + + usage not reported yet + + ); + } + // A guest can only report 0-100 (the control plane refuses the rest), but the + // width of a bar is not the place to find that out. + const percent = Math.min(100, Math.max(0, usedPercent)); + return ( + = FULL_ENOUGH_TO_WARN ? 'volume-meter volume-meter--warn' : 'volume-meter'}> + + + + {String(percent)}% full + + ); +} diff --git a/packages/webapp/src/WorkspaceMembersEditor.tsx b/packages/webapp/src/WorkspaceMembersEditor.tsx index fc9dd7ea..1a7016ee 100644 --- a/packages/webapp/src/WorkspaceMembersEditor.tsx +++ b/packages/webapp/src/WorkspaceMembersEditor.tsx @@ -7,6 +7,7 @@ import type { import { useEffect, useRef, useState } from 'react'; import type { MemberView } from './api'; import { DriveAvatar } from './files/DriveAvatar'; +import { VolumeMeter } from './VolumeMeter'; import { MachineTypeSelect, WORKSPACE_DEFAULT_MACHINE_TYPE } from './MachineTypeSelect'; import { WebAppSelectMenu } from './WebAppSelectMenu'; @@ -188,8 +189,9 @@ function MemberRow({ // control over something that does not exist. const showMachine = role !== 'viewer'; // The volume is created with the machine, so the toggle is a choice only - // while there is no machine. On a row that has one it reports the disk that - // exists rather than offering to change it, which this route cannot do. + // while there is no machine. On a row that has one the meter reports the disk + // that exists — how full it is — rather than offering to change it, which + // this route cannot do. const volumeDecided = machine !== null; // The pinned creator row of a draft has no choice to make: the workspace // creator's own machine is provisioned before any member row is read. @@ -228,18 +230,22 @@ function MemberRow({ onChange={onMachineTypeChange} /> )} - {showVolume && ( + {showVolume && (machine === null ? ( - )} + ) : ( + // The disk exists, so the row reports it instead of offering a choice + // this route cannot make: how full it is, or that there is none. + + ))} {showMachine && actions.length > 0 && ( { await settle(); // Ada's machine already holds a volume, so her row reports the disk that - // exists rather than offering a choice this route cannot make. - const settled = view.container.querySelector( + // exists — how full it is — rather than offering a choice this route + // cannot make. + expect(view.container.querySelector( '[aria-label="Persistent volume for Ada Owner"]', - ); - expect(settled?.checked).toBe(true); - expect(settled?.disabled).toBe(true); + )).toBeNull(); + const meter = view.container.querySelector('.workspace-member-row [role="meter"]'); + expect(meter?.getAttribute('aria-valuenow')).toBe('62'); + expect(view.container.textContent).toContain('62% full'); + expect(view.container.textContent).not.toContain('Attached'); const toggle = view.container.querySelector( '[aria-label="Persistent volume for Grace Viewer"]', @@ -485,6 +489,7 @@ describe('machineActionsFor', () => { state, machineTypeId: 'cx23@fsn1', volumeId: 'volume-one', + volumeUsedPercent: null, membershipId: 'membership-1', error: null, createdAt: 1, diff --git a/packages/webapp/test/my-machine.test.tsx b/packages/webapp/test/my-machine.test.tsx index 1c2934c5..6d370cd0 100644 --- a/packages/webapp/test/my-machine.test.tsx +++ b/packages/webapp/test/my-machine.test.tsx @@ -67,6 +67,7 @@ const me: WorkspaceMemberView = { state: 'running', machineTypeId: 'cx23@fsn1', volumeId: 'volume-one', + volumeUsedPercent: 62, membershipId: 'membership-2', error: null, createdAt: 1_700_000_000_000, @@ -114,7 +115,9 @@ describe('MyMachineDialog', () => { expect(view.container.textContent).toContain('2 vCPU'); expect(view.container.textContent).toContain('4 GB'); expect(view.container.textContent).toContain('$6.49/mo'); - expect(view.container.textContent).toContain('Attached'); + // The volume row is a meter, never the bare word "Attached": what a member + // needs from it is how much room is left. + expect(view.container.textContent).toContain('62% full'); const stop = buttons(view.container).find((button) => button.textContent === 'Stop'); expect(stop?.disabled).toBe(false); @@ -234,6 +237,54 @@ describe('MyMachineDialog', () => { await view.unmount(); }); + it('reports the volume as a meter, a pending state, or nothing at all', async () => { + const withMachine = (machine: WorkspaceMemberView['machine']) => ({ + ...workspace, + members: [ada, { ...me, machine }], + }); + + const reported = await render(dialog({ workspace: withMachine(me.machine) })); + await settle(); + expect(reported.container.querySelector('[role="meter"]')?.getAttribute('aria-valuenow')) + .toBe('62'); + expect(reported.container.querySelector('.volume-meter-fill')?.getAttribute('style')) + .toContain('62%'); + await reported.unmount(); + + // A guest from before the reporter shipped. The track is there and empty; + // 0% would claim a measurement nobody made. + const pending = await render(dialog({ + workspace: withMachine({ ...me.machine!, volumeUsedPercent: null }), + })); + await settle(); + expect(pending.container.textContent).toContain('usage not reported yet'); + expect(pending.container.querySelector('[role="meter"]')).toBeNull(); + expect(pending.container.querySelector('.volume-meter-track')).not.toBeNull(); + await pending.unmount(); + + const none = await render(dialog({ + workspace: withMachine({ ...me.machine!, volumeId: null, volumeUsedPercent: null }), + })); + await settle(); + expect(none.container.textContent).toContain('Not attached'); + expect(none.container.querySelector('.volume-meter-track')).toBeNull(); + await none.unmount(); + }); + + it('warns on its own colour once the volume is nearly full', async () => { + const view = await render(dialog({ + workspace: { + ...workspace, + members: [ada, { ...me, machine: { ...me.machine!, volumeUsedPercent: 94 } }], + }, + })); + await settle(); + + expect(view.container.querySelector('.volume-meter--warn')).not.toBeNull(); + expect(view.container.textContent).toContain('94% full'); + await view.unmount(); + }); + it('tells a viewer they hold no machine', async () => { const view = await render(dialog({ workspace: { From 0c3732a0699dd6597ea9c4c718d870b53a806b4d Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Sat, 29 Aug 2026 20:10:37 +0000 Subject: [PATCH 3/9] style(webapp): one settings-surface style system, in one stylesheet Six heading treatments across four stylesheets is why the settings surfaces drifted apart. `src/settings-surface.css` is now the single place the settings look is defined, under one `cfg-` prefix, with the rules stated at the top of the file so the next change extends it instead of inventing a seventh. The two anchors are tokenized rather than retyped: `--cfg-title-*` holds the colour, size and tracking the "Agent rules" heading carried in the Settings tab, and `--cfg-desc-*` holds its paragraph's. Every other value resolves to a token already in tokens.css. Nothing wears the classes yet; the following commits move each surface onto them and delete the one-off it replaces. Co-Authored-By: Claude Fable 5 --- packages/webapp/src/main.tsx | 1 + packages/webapp/src/settings-surface.css | 318 +++++++++++++++++++++++ 2 files changed, 319 insertions(+) create mode 100644 packages/webapp/src/settings-surface.css diff --git a/packages/webapp/src/main.tsx b/packages/webapp/src/main.tsx index 1a776ef3..c947f07c 100644 --- a/packages/webapp/src/main.tsx +++ b/packages/webapp/src/main.tsx @@ -14,6 +14,7 @@ import './drive-shell.css'; import './strip-rail.css'; import './files.css'; import './confirmation-dialog.css'; +import './settings-surface.css'; import './workspace-details-dialog.css'; import './loading-skeleton.css'; import './create-workspace-dialog.css'; diff --git a/packages/webapp/src/settings-surface.css b/packages/webapp/src/settings-surface.css new file mode 100644 index 00000000..d28ba1f1 --- /dev/null +++ b/packages/webapp/src/settings-surface.css @@ -0,0 +1,318 @@ +/** + * SETTINGS SURFACE STYLE — the one system for every settings-shaped screen. + * ========================================================================== + * + * This file is canon. Every settings surface — the workspace-details dialog + * and its three tabs, "My machine", the account settings page and its panels, + * and the section headings of the create-workspace dialog — dresses itself + * from the `cfg-` vocabulary below. Do not add a second set of section + * headings, micro-labels or dividers somewhere else: extend this file, or use + * what is already here. The scatter this replaced was six different heading + * treatments across four stylesheets. + * + * THE RULES (anchors — do not relitigate) + * --------------------------------------- + * 1. THE FRAME NEVER RESIZES. A tabbed settings dialog has ONE fixed height, + * sized to its tallest tab. The body scrolls; switching tabs never moves + * the frame. (Enforced in workspace-details-dialog.css, which sets + * `height`, not `max-height`, on `.workspace-details-dialog`.) + * + * 2. SECTION HEADERS ARE SENTENCE CASE, NEVER ALL-CAPS, AND ALWAYS INK WHITE. + * The colour and size are the ones the "Agent rules" heading carried in the + * Settings tab, lifted verbatim out of the old blueprint heading CSS and + * tokenized below as `--cfg-title-*`. Section descriptions take the colour + * and size of that heading's paragraph, tokenized as `--cfg-desc-*`. + * + * 3. FIELD MICRO-LABELS ARE SENTENCE CASE TOO. The "NAME" / "DEFAULT MACHINE + * TYPE" tracking-out-uppercase style is gone. A label is a quiet line of + * text above its control, not a sign. + * + * 4. EXACTLY ONE THIN DIVIDER BETWEEN TWO ADJACENT SECTIONS, AND DIVIDERS + * APPEAR NOWHERE ELSE. The line is drawn by `.cfg-section ~ .cfg-section` + * and by nothing else — never by a heading, never by a definition list, + * never by a one-off `border-top` on the first row of something. Card + * outlines and list-row separators are structure, not dividers, and are not + * covered by this rule. If you want a line, you want a section boundary. + * + * 5. NO NEW COLOURS. Everything here resolves to a token already in + * tokens.css: --ink, --muted, --faint, --rule, --accent, --ansi-red, + * --paper, --r-control, --font-ui. + * + * THE VOCABULARY + * -------------- + * .cfg-section One section. Stack them; the system draws the line. + * .cfg-section-head Title + description, grouped. + * .cfg-title Section title. Sentence case, ink, anchor size. + * .cfg-desc Section description. Muted, anchor size. + * .cfg-label Field micro-label, standalone (label + htmlFor). + * .cfg-field Field row: micro-label above its control. + * .cfg-field--inline Checkbox/switch row: control then label, one line. + * .cfg-field--compact In a row of fields, this one takes only what it needs. + * .cfg-help Inline help under a control, or a note in a section. + * .cfg-actions The row a Save / Add / lifecycle verb sits in. + * .cfg-meta Read-only fact list (a
of label/value rows). + * .cfg-danger Danger zone: what it destroys, and the verb. + * .cfg-danger-copy The danger zone's name + consequence. + * .cfg-danger-action The destructive button. Outlined red, filled on hover. + * .cfg-footer A settings dialog's footer bar. + */ + +:root { + /* Anchor 2, extracted from `.blueprint-selection__heading h2`, which is what + * the "Agent rules" heading wore. */ + --cfg-title-color: var(--ink); + --cfg-title-font: 700 15px/1.6 var(--font-ui); + --cfg-title-tracking: -.01em; + /* Anchor 2, extracted from `.blueprint-selection__heading p`. */ + --cfg-desc-color: var(--muted); + --cfg-desc-size: 11.5px; + /* Anchor 4: the only line the system draws. */ + --cfg-divider: 1px solid var(--rule); + /* One control height for every settings input, so a field row and the + * button beside it agree. */ + --cfg-control-height: 36px; +} + +/* ---------------------------------------------------------------- section */ + +.cfg-section { + display: grid; + min-width: 0; + align-content: start; + gap: 10px; +} + +/* Anchor 4. `~` rather than `+` so a stray actions row or notice between two + * sections does not lose the boundary — every section after the first gets + * exactly one line above it, and nothing else in the system draws one. */ +.cfg-section ~ .cfg-section { + margin-top: 18px; + padding-top: 18px; + border-top: var(--cfg-divider); +} + +.cfg-section-head { + display: grid; + min-width: 0; + gap: 3px; +} + +.cfg-title { + margin: 0; + color: var(--cfg-title-color); + font: var(--cfg-title-font); + letter-spacing: var(--cfg-title-tracking); + text-transform: none; +} + +.cfg-desc { + margin: 0; + color: var(--cfg-desc-color); + font-size: var(--cfg-desc-size); + line-height: 1.5; +} + +/* ----------------------------------------------------------------- fields */ + +.cfg-label, +.cfg-field { + min-width: 0; + color: var(--faint); + font: 600 11px/1.5 var(--font-ui); + letter-spacing: normal; + text-transform: none; +} + +.cfg-field { + display: grid; + flex: 1 1 220px; + gap: 6px; +} + +.cfg-field--compact { + flex: 0 1 140px; +} + +.cfg-field input, +.cfg-field select, +.cfg-field textarea { + width: 100%; + min-width: 0; + min-height: var(--cfg-control-height); + padding: 0 10px; + border: 1px solid var(--rule); + border-radius: var(--r-control); + color: var(--ink); + background: var(--paper); + outline: 0; + font: 12.5px var(--font-ui); + letter-spacing: normal; + text-transform: none; +} + +.cfg-field textarea { + padding: 8px 10px; + line-height: 1.6; +} + +.cfg-field input:focus, +.cfg-field select:focus, +.cfg-field textarea:focus { + border-color: var(--accent); +} + +.cfg-field select:disabled, +.cfg-field input:disabled { + color: var(--faint); +} + +/* A checkbox reads left to right: the box, then the sentence it agrees to. */ +.cfg-field--inline { + display: flex; + align-items: center; + gap: 8px; +} + +.cfg-field--inline input { + width: auto; + min-height: 0; + margin: 0; + accent-color: var(--accent); +} + +/* ------------------------------------------------------------------- help */ + +.cfg-help { + margin: 0; + color: var(--faint); + font: 11px/1.55 var(--font-ui); +} + +/* ---------------------------------------------------------------- actions */ + +.cfg-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; +} + +.cfg-actions button:disabled { + cursor: not-allowed; + opacity: .5; +} + +/* ------------------------------------------------------------- meta lists */ + +/* Read-only facts. Anchor 4: no rules between the rows, and none above the + * first one either — the section boundary is the only line on the surface. */ +.cfg-meta { + display: grid; + min-width: 0; + margin: 0; +} + +.cfg-meta > div { + display: grid; + min-width: 0; + grid-template-columns: minmax(120px, 1fr) minmax(0, 1.25fr); + gap: 12px; + padding: 5px 0; +} + +.cfg-meta dt { + color: var(--faint); + font-size: 11px; +} + +.cfg-meta dd { + min-width: 0; + margin: 0; + overflow: hidden; + color: var(--ink); + font-size: 11.5px; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* ------------------------------------------------------------ danger zone */ + +.cfg-danger { + display: flex; + min-width: 0; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.cfg-danger-copy { + display: grid; + min-width: 0; + gap: 3px; +} + +.cfg-danger-copy strong { + color: var(--ink); + font-size: 12.5px; + font-weight: 600; +} + +.cfg-danger-copy span { + color: var(--faint); + font-size: 11.5px; +} + +.cfg-danger-action { + display: inline-flex; + flex: none; + min-height: var(--cfg-control-height); + align-items: center; + justify-content: center; + padding: 0 16px; + border: 1px solid color-mix(in oklab, var(--ansi-red) 55%, var(--rule)); + border-radius: var(--r-control); + color: var(--ansi-red); + background: transparent; + cursor: pointer; + font: 650 11.5px/1 var(--font-ui); +} + +.cfg-danger-action:hover:not(:disabled), +.cfg-danger-action:focus-visible:not(:disabled) { + color: var(--paper); + background: var(--ansi-red); + outline: 0; +} + +.cfg-danger-action:disabled { + cursor: not-allowed; + opacity: .5; +} + +/* ----------------------------------------------------------------- footer */ + +/* A settings dialog's footer. It carries the surface-wide verbs, which is why + * it is not a section: nothing above it is "adjacent" to it. */ +.cfg-footer { + display: flex; + flex: none; + flex-wrap: wrap; + align-items: center; + gap: 10px; +} + +@media (max-width: 620px) { + .cfg-danger { + align-items: flex-start; + flex-direction: column; + } + + .cfg-footer { + flex-direction: column; + align-items: stretch; + } + + .cfg-footer > * { + width: 100%; + } +} From 57237595cb63be791090c0594efaf3ae4069ac74 Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Sat, 29 Aug 2026 20:13:50 +0000 Subject: [PATCH 4/9] style(webapp): the workspace-details dialog stops resizing, and wears the system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anchor 1: the frame is `height`, not `max-height`. It is sized to the tallest tab — Settings, whose form, agent rules, facts and repositories run past the 760px ceiling the dialog already had — so Members and Credentials now leave space under their content instead of shrinking the dialog around it. The body is the only thing that scrolls. Anchor 4: `.workspace-details-list > div:first-child` loses its border-top. The fact lists are divider-free `.cfg-meta` now, and the only lines on the surface are the section boundaries the system draws. All three tabs, plus the agent-rules picker they share with the create dialog, move onto `cfg-`: section heads with an ink sentence-case title and a muted description, sentence-case micro-labels in place of the tracked-out NAME / DEFAULT MACHINE TYPE caps, one actions row per form, and the footer's Delete as `.cfg-danger-action`. Deleted with their last caller: the micro-caps `h2` rules, the `.workspace-details-list` list, `.workspace-details-note`, `.workspace-settings-form`, `.workspace-settings-toggle`, `.workspace-repos h2`, `.workspace-credential-add`, `.workspace-details-delete`, `.blueprint-agent-rules` and `.blueprint-agent-rules-note`. Co-Authored-By: Claude Fable 5 --- packages/webapp/src/AgentRulesPicker.tsx | 16 +- .../webapp/src/WorkspaceDetailsDialog.tsx | 108 +++++----- packages/webapp/src/WorkspaceSettingsTab.tsx | 190 ++++++++++-------- .../webapp/src/create-workspace-dialog.css | 13 +- packages/webapp/src/settings-surface.css | 6 + .../webapp/src/workspace-details-dialog.css | 72 +++---- 6 files changed, 209 insertions(+), 196 deletions(-) diff --git a/packages/webapp/src/AgentRulesPicker.tsx b/packages/webapp/src/AgentRulesPicker.tsx index e5670436..6031596e 100644 --- a/packages/webapp/src/AgentRulesPicker.tsx +++ b/packages/webapp/src/AgentRulesPicker.tsx @@ -119,16 +119,16 @@ export function AgentRulesPicker({ }; return ( -
-
-

Agent rules

-

+

+
+

Agent rules

+

The always-loaded instructions your agents read in this workspace. Leave it on the default unless your team needs its own.

-