Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
3f0478f
refactor(ui): the console is split into data, model, state, design-sy…
V3RON Oct 15, 2026
5659b0e
feat(ui): the console opens on an overview, inside a collapsible sidebar
V3RON Oct 15, 2026
51a91e9
docs(ui): the design guide describes the sidebar, list-and-inspector …
V3RON Oct 15, 2026
c8b9449
feat(ui): the waiting page is a list with an inspector
V3RON Oct 15, 2026
f3533ec
test(ui): the overview names each worker's health and says so when th…
V3RON Oct 15, 2026
8b52e7f
fix(ui): a list keeps the page its address asks for while it loads, a…
V3RON Oct 15, 2026
5b7e70f
merge: the waiting page is a list with an inspector
V3RON Oct 15, 2026
1f956fa
refactor(ui): the waiting page leaves an unknown length to the pager
V3RON Oct 15, 2026
c0e6354
feat(ui): the events page is a feed with an inspector
V3RON Oct 15, 2026
5ab8df9
fix(ui): a list keeps the page its address asks for while it loads, a…
V3RON Oct 15, 2026
1449887
test(ui): the events page is proved in the browser lane
V3RON Oct 15, 2026
6d4c76e
feat(ui): the leases page is a list with an inspector and the live sc…
V3RON Oct 15, 2026
5d30ae4
merge: the leases page is a list with an inspector and the live scree…
V3RON Oct 15, 2026
729dd2b
merge: the events page is a live list with an inspector
V3RON Oct 15, 2026
5e4f6d9
refactor(ui): a chart takes its height, and the events strip asks for…
V3RON Oct 15, 2026
5881374
feat(ui): the attention page is a list with an inspector
V3RON Oct 15, 2026
c1f4519
merge: the attention page is a list with an inspector
V3RON Oct 15, 2026
9f33adb
refactor(ui): attention reads the time of day from the events model
V3RON Oct 15, 2026
9ca15ca
feat(ui): the workers page is a list with an inspector
V3RON Oct 15, 2026
189f18c
merge: the workers page is a list with an inspector
V3RON Oct 15, 2026
bd9f8eb
test(ui): the shared console specs check the overview, the sidebar an…
V3RON Oct 15, 2026
a24ba7a
docs(ui): the console guide describes the overview, the sidebar and t…
V3RON Oct 15, 2026
c2fc275
fix(ui): a lease a gateway relayed names its agent, device rows stay …
V3RON Oct 15, 2026
9173362
fix(ui): a phone's top bar shows the full wordmark, and a device id u…
V3RON Oct 15, 2026
90067e1
test(ui): long lists page in the list pane
V3RON Oct 15, 2026
1d07628
fix(ui): the warn colour reads on a selected list row in the light theme
V3RON Oct 15, 2026
80db7fb
test(ui): the shared view specs wait for any heading and skip disable…
V3RON Oct 15, 2026
419adae
fix(ui): opening an item keeps its list on the page it was on, on eve…
V3RON Oct 15, 2026
6009edf
test(ui): a list row's link carries the list's page, and the opened i…
V3RON Oct 15, 2026
4e2078c
fix(ui): a worker's device links to its lease's page on a gateway, an…
V3RON Oct 15, 2026
dc086f0
test(ui): waiting for the console's motion to end counts an animation…
V3RON Oct 15, 2026
ddc0cb5
chore(ui): no export nothing imports
V3RON Oct 15, 2026
2b665e3
refactor(ui): split the functions over the complexity limits into nam…
V3RON Oct 16, 2026
ea15216
refactor(ui): the screen pane's view and the inspector's totals are p…
V3RON Oct 16, 2026
3d5a57c
test(console): the keyboard test names the host's worker it reads
V3RON Oct 16, 2026
aa51197
style(ui): format the foundation and table styles
V3RON Oct 16, 2026
d7271bd
test(console): an animation cut short counts as ended, and keyboard t…
V3RON Oct 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .dependency-cruiser-known-violations.json
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@
},
{
"type": "dependency",
"from": "ui/src/views/leases-model.ts",
"from": "ui/src/model/leases.ts",
"to": "src/contract/schemas.ts",
"unresolvedTo": "../../../src/contract/schemas",
"dependencyTypes": ["local", "type-only", "import"],
Expand All @@ -243,7 +243,7 @@
},
{
"type": "dependency",
"from": "ui/src/views/waiting-model.ts",
"from": "ui/src/model/waiting.ts",
"to": "src/contract/schemas.ts",
"unresolvedTo": "../../../src/contract/schemas",
"dependencyTypes": ["local", "type-only", "import"],
Expand All @@ -254,7 +254,7 @@
},
{
"type": "dependency",
"from": "ui/src/views/workers-model.ts",
"from": "ui/src/model/workers.ts",
"to": "src/contract/schemas.ts",
"unresolvedTo": "../../../src/contract/schemas",
"dependencyTypes": ["local", "type-only", "import"],
Expand Down
38 changes: 38 additions & 0 deletions .dependency-cruiser.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ function findComponents(dir) {
const escape = (path) => path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const testFile = "(\\.test\\.tsx?|/test-[^/]+\\.tsx?)$";
const linkTestFile = "(\\.test\\.tsx?|/test-[^/]+\\.tsx?|/testing\\.ts)$";
// why: the console layers below the screens are plain TypeScript, so a test drives them without a browser.
const uiLibrary = "(^|node_modules/)(react|react-dom|recharts|@tanstack/[^/]+)(/|$)";
const fsOrProcess = "^(node:)?(fs|fs/promises|child_process)$";

// why: e2e imports the compiled package from dist/, which mirrors src/, so each component's rule covers its compiled copy too.
Expand Down Expand Up @@ -195,6 +197,42 @@ export default {
"^src/(core|leasing|drivers|gateway|contract)/",
{ path: "^src/(modules|module-registry)/" },
),
direction(
"ui-ds-stands-alone",
"the console's design system imports nothing else of the console",
"^ui/src/ds/",
{ path: "^ui/src/(app|screens|state|data|model)/" },
),
direction(
"ui-model-is-pure",
"the console's business rules import no other layer and no UI library",
"^ui/src/model/",
{ path: `^ui/src/(app|screens|state|data|ds)/|${uiLibrary}` },
),
direction(
"ui-data-is-below-the-ui",
"the console's network and storage layer imports no state, screen, design system or UI library",
"^ui/src/data/",
{ path: `^ui/src/(app|screens|state|ds)/|${uiLibrary}` },
),
direction(
"ui-state-feeds-screens",
"the console's state layer imports no screen, and from the design system only the types it fills",
"^ui/src/state/",
{ path: "^ui/src/(app|screens)/" },
),
direction(
"ui-state-ds-types-only",
"the console's state layer imports no screen, and from the design system only the types it fills",
"^ui/src/state/",
{ path: "^ui/src/ds/", dependencyTypesNot: ["type-only"] },
),
direction(
"ui-screens-present-only",
"a console screen reaches data only through the state layer: no network, storage or business rule of its own",
"^ui/src/screens/",
{ path: "^ui/src/(app|data|model)/" },
),
direction(
"contract-imports-nothing",
"the contract imports nothing outside src/contract/",
Expand Down
334 changes: 180 additions & 154 deletions docs/CONSOLE.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion docs/internal/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ worker daemon ── uplink (WebSocket to /v1/uplink, worker dials) ──┘
| agent-identity | Default requester id for CLI and MCP | [agent-identity](../../src/agent-identity/AGENTS.md) |
| daemon-protocol | Socket framing and the protocol version | [daemon-protocol](../../src/daemon-protocol/AGENTS.md) |
| test-support | Shared test helpers | [test-support](../../src/test-support/AGENTS.md) |
| `ui/` | Web console, served by the daemon | [CONSOLE.md](../CONSOLE.md) |
| `ui/` | Web console, served by the daemon: data, model, state, design system and screens in layers | [ui](../../ui/AGENTS.md) · [CONSOLE.md](../CONSOLE.md) |

## Dependencies

Expand Down
197 changes: 109 additions & 88 deletions docs/internal/DESIGN.md

Large diffs are not rendered by default.

116 changes: 92 additions & 24 deletions e2e/console/attention.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,18 +131,26 @@ async function strandProvisioning(
async function openAttention(page: Page, daemon: RunningDaemon): Promise<void> {
await page.goto(daemon.url);
await signIn(page, daemon.tokens.operator);
await page.getByRole("link", { name: "Attention" }).click();
// why: a phone folds the navigation into a menu, so the page is opened by its address.
await expect(page.getByRole("heading", { level: 1, name: "Overview" })).toBeVisible();
await page.goto(new URL("/attention", daemon.url).href);
await expect(page.getByRole("heading", { level: 1, name: "Attention" })).toBeVisible();
}

/** The item for `condition` about the worker or device named `subject`. */
function rows(page: Page): Locator {
return page.getByRole("list", { name: "Needs attention" }).getByRole("link");
}

function item(page: Page, condition: string, subject: string): Locator {
return page
.locator(".attention-table tbody tr")
.filter({ has: page.locator(".status", { hasText: new RegExp(`^${condition}$`) }) })
return rows(page)
.filter({ has: page.getByRole("img", { exact: true, name: condition }) })
.filter({ hasText: subject });
}

function facts(page: Page): Locator {
return page.getByRole("group", { name: "Facts" });
}

/** Whether the page itself scrolls sideways. */
async function hasHorizontalScroll(page: Page): Promise<boolean> {
return page.evaluate(
Expand All @@ -169,11 +177,23 @@ test.describe("the attention view", () => {

const drained = item(page, "drained", "worker-b");
await expect(drained).toBeVisible({ timeout: 1_000 });
await expect(drained.getByRole("link", { name: "worker-b" })).toHaveAttribute(
await expect(drained).toHaveAttribute(
"href",
`/attention/${encodeURIComponent(`${b?.id}/drained`)}`,
);
await expect(rows(page)).toHaveCount(1);
await expect(page).toHaveURL(/\/attention$/);
await expect(page.getByRole("heading", { level: 2, name: "worker-b" })).toBeVisible();
await expect(page.getByRole("link", { name: "Open worker" })).toHaveAttribute(
"href",
`/workers/${encodeURIComponent(b?.id ?? "")}`,
);
await expect(page.locator(".attention-table tbody tr")).toHaveCount(1);
await expect(facts(page)).toContainText("Leases still held");
const recent = page.getByRole("region", { name: "Recent events" });
await expect(recent.getByRole("link", { name: /worker\.drain-started/ })).toHaveAttribute(
"href",
/^\/events\/.+/,
);
} finally {
await disposeFleet(fleet);
}
Expand All @@ -198,6 +218,30 @@ test.describe("the attention view", () => {
}
});

test("an item named in the URL that stops needing attention says Resolved. with a link back", async ({
page,
}) => {
const fleet = await startFleet([{ driverScript: { ios: IOS }, label: "worker-a" }]);
try {
const [a] = await workers(fleet.gateway);
expect((await fleet.gateway.cli(["worker", "drain", a?.id ?? ""])).code).toBe(0);
await openAttention(page, fleet.gateway);
await item(page, "drained", "worker-a").click();
await expect(page).toHaveURL(/\/attention\/.+/);
await expect(page.getByRole("heading", { level: 2, name: "worker-a" })).toBeVisible();

expect((await fleet.gateway.cli(["worker", "undrain", a?.id ?? ""])).code).toBe(0);
await untilTheDaemonSays(fleet.gateway, (views) => views[0]?.drained === false);

await expect(page.getByText("Resolved.")).toBeVisible({ timeout: 1_000 });
await page.getByRole("link", { name: "Back to Attention" }).click();
await expect(page).toHaveURL(/\/attention$/);
await expect(page.getByText("Nothing needs attention.")).toBeVisible();
} finally {
await disposeFleet(fleet);
}
});

test("stopping a worker adds it as disconnected within a second", async ({ page }) => {
const fleet = await startFleet([
{ driverScript: { ios: IOS }, label: "worker-a" },
Expand All @@ -215,6 +259,8 @@ test.describe("the attention view", () => {

await expect(item(page, "disconnected", "worker-b")).toBeVisible({ timeout: 1_000 });
await expect(item(page, "disconnected", "worker-a")).toHaveCount(0);
await item(page, "disconnected", "worker-b").click();
await expect(facts(page)).toContainText(/Last seen\s*\d+ s ago/);
} finally {
await disposeFleet(fleet);
}
Expand Down Expand Up @@ -266,12 +312,16 @@ test.describe("the attention view", () => {
await openAttention(page, fleet.gateway);

await expect(item(page, "incompatible", "old-worker")).toBeVisible();
await expect(
item(page, "quarantined", `Device ${quarantined?.id} on worker-a`),
).toBeVisible();
await expect(item(page, "stalled", `Device ${stalled?.id} on worker-a`)).toContainText(
/stuck provisioning for \d+ (s|min)/,
);
await item(page, "incompatible", "old-worker").click();
await expect(facts(page)).toContainText("Gateway protocol");
await expect(facts(page)).toContainText("Worker protocol");

await item(page, "quarantined", "iPhone 16 on worker-a").click();
await expect(facts(page)).toContainText(quarantined?.id ?? "");

await item(page, "stalled", "iPhone 16 on worker-a").click();
await expect(facts(page)).toContainText(stalled?.id ?? "");
await expect(facts(page)).toContainText(/In this state for\s*\d+ (s|min)/);
} finally {
hangUp();
await disposeFleet(fleet);
Expand Down Expand Up @@ -317,10 +367,16 @@ test.describe("the attention view", () => {
await openAttention(page, host);

const name = view?.id ?? "";
await expect(item(page, "quarantined", `Device ${quarantined?.id} on ${name}`)).toBeVisible();
await expect(item(page, "stalled", `Device ${stalled?.id} on ${name}`)).toBeVisible();
await expect(item(page, "quarantined", `iPhone 16 on ${name}`)).toBeVisible();
await expect(item(page, "stalled", `iPhone 16 on ${name}`)).toBeVisible();
await expect(item(page, "over RAM budget", name)).toBeVisible();
await expect(page.locator(".attention-table tbody tr")).toHaveCount(3);
await expect(rows(page)).toHaveCount(3);
expect(quarantined?.id).toBeTruthy();
expect(stalled?.id).toBeTruthy();

await item(page, "over RAM budget", name).click();
await expect(facts(page)).toContainText(/Used\s*\d+\.\d\d GiB/);
await expect(facts(page)).toContainText(/Limit\s*\d+\.\d\d GiB/);
} finally {
await host.dispose();
}
Expand All @@ -339,11 +395,15 @@ test.describe("the attention view", () => {
await page.goto(gateway.url);
await signIn(page, gateway.tokens.operator);

const attention = page.getByRole("navigation").getByRole("link", { name: /^Attention/ });
const attention = page
.getByRole("navigation", { name: "Console" })
.getByRole("link", { name: /^Attention/ });
for (const label of NAV_LABELS) {
await page.getByRole("navigation").getByRole("link", { name: label }).click();
await page
.getByRole("navigation", { name: "Console" })
.getByRole("link", { name: label })
.click();
await expect(page.getByRole("heading", { level: 1, name: label })).toBeVisible();
await expect(attention.locator(".nav-count")).toHaveText(/^\D*2\D*$/);
await expect(attention).toHaveAccessibleName("Attention 2 items");
}

Expand Down Expand Up @@ -375,7 +435,10 @@ test.describe("the attention view", () => {

await openAttention(page, gateway);

await expect(page.locator(".attention-table tbody tr")).toHaveCount(1);
await expect(rows(page)).toHaveCount(1);
expect(await hasHorizontalScroll(page)).toBe(false);
await rows(page).click();
await expect(page.getByRole("heading", { level: 2, name: /^aworkerwith/ })).toBeVisible();
expect(await hasHorizontalScroll(page)).toBe(false);
} finally {
hangUp();
Expand Down Expand Up @@ -404,13 +467,18 @@ test.describe("the attention view", () => {
await page.keyboard.press(tab);
await page.keyboard.type(gateway.tokens.operator);
await page.keyboard.press("Enter");
await expect(page.getByRole("heading", { level: 1, name: "Workers" })).toBeVisible();
await expect(page.getByRole("heading", { level: 1, name: "Overview" })).toBeVisible();

await follow(page.getByRole("navigation").getByRole("link", { name: /^Attention/ }));
await follow(
page.getByRole("navigation", { name: "Console" }).getByRole("link", { name: /^Attention/ }),
);
await expect(page.getByRole("heading", { level: 1, name: "Attention" })).toBeVisible();

await follow(item(page, "incompatible", "old-worker").getByRole("link"));
await expect(page.getByRole("heading", { level: 1, name: "old-worker" })).toBeVisible();
await follow(item(page, "incompatible", "old-worker"));
await expect(page.getByRole("heading", { level: 2, name: "old-worker" })).toBeVisible();

await follow(page.getByRole("link", { name: "Open worker" }));
await expect(page).toHaveURL(/\/workers\/wrk_old$/);
} finally {
hangUp();
await gateway.dispose();
Expand Down
Loading
Loading