Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 7 additions & 1 deletion docs/CONSOLE.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,13 @@ Select a worker to open its page. It adds:
- **Load**: how busy the machine is right now: CPU busy share and cores,
memory available of total, swap used, memory pressure (`normal`, `warn` or
`critical`, left out where the system does not report it), and disk free of
total. A part the worker could not read says `Unavailable`. **Read** says how
total. CPU, memory and disk each have a bar under the figure showing how full
it is, for scanning at a glance: the memory bar takes the colour of its
pressure, and is itself neutral where no pressure is reported. The other two
stay neutral because the worker sends no CPU or disk threshold for the console
to judge them against — a `(limit)` beside a figure is the worker's own limit
on that figure, not a level to be judged against. A part the worker could not
read says `Unavailable`, and has no bar. **Read** says how
long ago the reading was taken, and it ticks up until the next one. A
worker that sent no load says "Not reported.", and a disconnected one "No
reading while disconnected."
Expand Down
21 changes: 20 additions & 1 deletion docs/internal/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,22 @@ scale; never a value between.
nothing is visible only in the picture. A chart can be focused, and the
arrow keys step through it. Its focus ring goes round the whole chart, for
keyboard focus only: a click on a chart draws none.
- **Load gauges.** On a worker's page, each of the Load panel's CPU, memory and
disk figures carries a bar in a line of its own under it: 8px high, `--field`
track, `max-width: 20rem`, square, and never the accent. Its fill takes a
status colour by class; its level arrives inline: `style-src 'self'` refuses a
style attribute in markup, and the level arrives anyway because React writes
styles through the CSSOM, where the policy does not reach. ADR 0011 allows the
`style` prop because it creates no `<style>` element — it says nothing about
the attribute. So a
gauge is drawn by the console's own code and never by server-rendered output. A gauge
is never the only place its number is, so it carries no accessible name and
takes no focus — the figure above it is the text. Colour is a second signal
here too: a gauge shades a level the console has a word for and nothing else,
so CPU and disk stay `--status-idle` until the worker sends a threshold they
can be judged against. A gauge belongs to this panel alone: the worker's card
prints the same load as one line of text, and charts are drawn by their own
rule above.

## Layout

Expand Down Expand Up @@ -208,7 +224,10 @@ scale; never a value between.
## Motion

Almost none. Colour changes on hover take 120ms (`--duration`). Nothing slides,
fades or bounces, charts included. With `prefers-reduced-motion: reduce`, there
fades or bounces, charts included. The one exception is a load gauge's fill: it
takes 120ms (`--duration`) to reach a new level rather than jumping, because a
new reading lands every 15 seconds and a jump reads as a glitch. Nothing else
moves, and nothing ever loops. With `prefers-reduced-motion: reduce`, there
are no transitions at all.

## Voice
Expand Down
259 changes: 258 additions & 1 deletion e2e/console/workers.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Locator, Page } from "@playwright/test";
import type { Locator, Page, Route } from "@playwright/test";

import { waitFor } from "../helpers/wait.js";
import {
Expand Down Expand Up @@ -31,6 +31,18 @@ interface WorkerView {
readonly android: { readonly running: number; readonly limit: number };
};
readonly leases: readonly { readonly deviceId: string; readonly grantedAt: number }[];
readonly load?: {
readonly readAt: number;
readonly cpu?: { readonly busyPercent: number; readonly cores: number };
readonly memory?: { readonly availableBytes: number; readonly totalBytes: number };
readonly disk?: { readonly freeBytes: number; readonly totalBytes: number };
};
}

// why: the test rewrites one field of this route's own answer, so it names the one path it touches
// rather than the whole view; everything else passes through as the daemon sent it.
interface WorkerPayload {
load?: { memory?: { pressure?: string } } | undefined;
}

interface Status {
Expand Down Expand Up @@ -120,6 +132,50 @@ function fact(scope: Locator, label: string): Locator {
return scope.locator("dt", { hasText: label }).locator("xpath=following-sibling::dd[1]");
}

interface PaintedGauge {
readonly track: number;
readonly fill: number;
readonly sliding: boolean;
}

// why: taken from the worker's own reading rather than from the page, so the console cannot agree
// with itself; there is no expected level until the reading holds a figure for each of the three.
function gaugePercents(load: NonNullable<WorkerView["load"]>): readonly number[] | undefined {
const { cpu, disk, memory } = load;
if (cpu === undefined || disk === undefined || memory === undefined) return undefined;
const whole = (percent: number) => Math.round(Math.min(100, Math.max(0, percent)));
return [
whole(cpu.busyPercent),
whole((1 - memory.availableBytes / memory.totalBytes) * 100),
whole((1 - disk.freeBytes / disk.totalBytes) * 100),
];
}

function paintedGauges(panel: Locator): Promise<readonly PaintedGauge[]> {
return panel.locator(".load-bar").evaluateAll((bars) =>
bars.map((bar) => {
const fill = bar.firstElementChild as HTMLElement;
// why: the fill's own 120 ms ease means a width read mid-slide sits between two levels, so
// this asks the browser whether a transition is still running and reports it with the width.
return {
track: bar.getBoundingClientRect().width,
fill: fill.getBoundingClientRect().width,
sliding: fill.getAnimations().some((animation) => animation.playState === "running"),
};
}),
);
}

function fillsMatch(painted: readonly PaintedGauge[], percents: readonly number[]): boolean {
return (
painted.length === percents.length &&
painted.every((bar, index) => {
const percent = percents[index] ?? -1;
return Math.abs(bar.fill - (bar.track * percent) / 100) <= 1;
})
);
}

/** A device's row in a worker's device table. */
function deviceRow(page: Page, id: string): Locator {
return page.getByRole("row").filter({ has: page.getByRole("cell", { name: id, exact: true }) });
Expand Down Expand Up @@ -444,6 +500,207 @@ test.describe("the workers views", () => {
await expect(fact(panel, "Memory")).toContainText("available");
});

test("a worker's detail draws a bar under each figure, filled to that figure's own percent", async ({
daemon,
page,
}) => {
const [host] = await workerList(daemon);
await openConsole(page, daemon);
const shown = card(page, host?.id ?? "");
await expect(fact(shown, "Load")).toHaveText(/^CPU .*memory .*disk /);
await shown.getByRole("link", { name: host?.id ?? "" }).click();

const panel = page.getByRole("region", { name: "Load", exact: true });
// why: both the worker's reading (every 15 s) and the fill's own 120 ms slide can move between
// two measurements, so this measures around both and retries until the fill has settled at the
// level the reading implies — otherwise the bar and the figure compared are not the same pair.
await waitFor(
async () => {
const before = (await workerList(daemon))[0]?.load;
const wanted = before === undefined ? undefined : gaugePercents(before);
if (wanted === undefined) return false;
const painted = await paintedGauges(panel);
const now = (await workerList(daemon))[0]?.load;
// why: what has to hold still is the level, not the reading — a fresh sample that rounds to
// the same percents is the same pair of figures and bars. The reading's own stamp moves
// between two reads even when nothing in it does, so comparing it here only ever costs
// retries.
const since = now === undefined ? undefined : gaugePercents(now);
if (since === undefined || JSON.stringify(wanted) !== JSON.stringify(since)) return false;
if (painted.some((bar) => bar.sliding)) return false;
return fillsMatch(painted, wanted);
},
{ interval: 500, timeout: 40_000, label: () => "the fill to match the figure" },
);
});

test("a load gauge sits in a line of its own under its figure, capped rather than full width", async ({
daemon,
page,
}) => {
const [host] = await workerList(daemon);
await openConsole(page, daemon);
const shown = card(page, host?.id ?? "");
await expect(fact(shown, "Load")).toHaveText(/^CPU .*memory .*disk /);
await shown.getByRole("link", { name: host?.id ?? "" }).click();

const panel = page.getByRole("region", { name: "Load", exact: true });
const row = fact(panel, "Memory");
await expect(row.locator(".load-bar")).toBeVisible();
// why: the viewport is 1280 wide, so the value column is far wider than the cap; a bar that
// filled its column instead of capping, or sat beside the text instead of under it, fails here.
const geometry = await row.evaluate((cell) => {
const figure = cell.querySelector(".load-figure > span") as HTMLElement;
const bar = cell.querySelector(".load-bar") as HTMLElement;
const text = figure.getBoundingClientRect();
const track = bar.getBoundingClientRect();
return {
column: cell.getBoundingClientRect().width,
left: track.left - text.left,
root: Number.parseFloat(getComputedStyle(document.documentElement).fontSize),
top: track.top,
track: track.width,
textBottom: text.bottom,
};
});

expect(geometry.track).toBeCloseTo(20 * geometry.root, 0);
expect(geometry.track).toBeLessThan(geometry.column - 100);
expect(geometry.top).toBeGreaterThanOrEqual(geometry.textBottom);
expect(geometry.left).toBeCloseTo(0, 0);
});

test("each memory pressure paints its gauge in that pressure's colour, and never the accent, in both themes", async ({
daemon,
page,
}) => {
const [host] = await workerList(daemon);
await openConsole(page, daemon);
const shown = card(page, host?.id ?? "");
await expect(fact(shown, "Load")).toHaveText(/^CPU .*memory .*disk /);
// why: only macOS reports a memory pressure, so a Linux lane can never see a warn or critical
// gauge from a live reading. This answers the page's own read of the worker list with one field
// of the reading changed and every other field left as the daemon sent it, so what is painted
// is the real component's doing. The list is read again per request, so the reading stays as
// fresh as the daemon's own.
let pressure: string | undefined;
await page.route(
(url) => url.pathname === "/v1/workers",
async (route: Route) => {
const response = await route.fetch();
const listed = (await response.json()) as { workers: WorkerPayload[] };
for (const worker of listed.workers) {
const memory = worker.load?.memory;
if (memory === undefined) continue;
if (pressure === undefined) delete memory.pressure;
else memory.pressure = pressure;
}
await route.fulfill({ json: listed, response });
},
);
await shown.getByRole("link", { name: host?.id ?? "" }).click();

const panel = page.getByRole("region", { name: "Load", exact: true });
await expect(panel.locator(".load-bar").first()).toBeVisible();
const memory = fact(panel, "Memory");
const paintedFills = () =>
panel.locator(".load-bar").evaluateAll((bars) =>
bars.map((bar) => ({
tone: /load-bar-(\w+)/.exec(bar.className)?.[1] ?? "",
colour: getComputedStyle(bar.firstElementChild as HTMLElement).backgroundColor,
})),
);
// why: the page polls every second, so each level shows within about that. The memory gauge is
// identified by its own row, so a tone read here cannot be a leftover of the last level.
const memoryFill = () =>
memory.evaluate((cell) => {
const bar = cell.querySelector(".load-bar") as HTMLElement;
return {
tone: /load-bar-(\w+)/.exec(bar.className)?.[1] ?? "",
colour: getComputedStyle(bar.firstElementChild as HTMLElement).backgroundColor,
};
});

// why: the four levels are the four tone rules, so each one is seen in each theme by the real
// component; a rule deleted, swapped or given the accent fails a named assertion here.
for (const colorScheme of ["light", "dark"] as const) {
await page.emulateMedia({ colorScheme });
const { accent, tones } = await readStatuses(page);

for (const [level, tone] of [
["normal", "ok"],
["warn", "warn"],
["critical", "error"],
[undefined, "idle"],
] as const) {
pressure = level;
const want = tones[TONES.indexOf(tone)];
await expect
.poll(() => memoryFill(), {
timeout: 5_000,
message: `the ${level ?? "absent"} pressure to paint the ${tone} gauge in ${colorScheme}`,
})
.toEqual({ tone, colour: want });
expect(want, `the ${tone} gauge in ${colorScheme}`).not.toBe(accent);

// why: every gauge on the panel, not only the one driven above: each must show the colour of
// the tone class it carries, so none is painted by a stray inline colour or by the accent.
const fills = await paintedFills();
expect(fills.length).toBeGreaterThan(1);
for (const fill of fills) {
const context = `${fill.tone} gauge in ${colorScheme}`;
expect(fill.tone, context).not.toBe("");
expect(fill.colour, context).toBe(
tones[TONES.indexOf(fill.tone as (typeof TONES)[number])],
);
expect(fill.colour, context).not.toBe(accent);
}
}
}
});

test("a load gauge's fill transitions its width over the document's duration token, and over none under reduced motion", async ({
browserName,
daemon,
page,
}) => {
const [host] = await workerList(daemon);
await openConsole(page, daemon);
const shown = card(page, host?.id ?? "");
await expect(fact(shown, "Load")).toHaveText(/^CPU .*memory .*disk /);
await shown.getByRole("link", { name: host?.id ?? "" }).click();

const panel = page.getByRole("region", { name: "Load", exact: true });
const fill = panel.locator(".load-bar-fill").first();
await expect(fill).toBeVisible();
// why: the stylesheet moves the fill's width over the document's own duration token, which is
// what `prefers-reduced-motion: reduce` already sets to zero. Both sides are read back as
// computed values and compared as milliseconds: a browser writes the token's `120ms` as `.12s`.
const asMs = (value: string) =>
value.trim().endsWith("ms") ? Number.parseFloat(value) : Number.parseFloat(value) * 1_000;
await expect
.poll(async () => {
const style = await fill.evaluate((element) => {
const computed = getComputedStyle(element);
return {
duration: computed.transitionDuration,
property: computed.transitionProperty,
token: getComputedStyle(document.documentElement).getPropertyValue("--duration").trim(),
};
});
return style.property === "width" && asMs(style.duration) === asMs(style.token);
})
.toBe(true);

if (browserName === "chromium") {
// why: emulated in Chromium alone — it is the one engine whose reduced-motion emulation was
// verified on the machine that wrote this test, and a guard must not hide a real failure in
// the other two.
await page.emulateMedia({ reducedMotion: "reduce" });
await expect(fill).toHaveCSS("transition-duration", "0s");
}
});

test("a disconnected worker's Load row says No reading while disconnected within a second", async ({
page,
}) => {
Expand Down
46 changes: 46 additions & 0 deletions ui/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -986,6 +986,52 @@ input[aria-invalid="true"] {
margin-bottom: var(--space-3);
}

/* Load gauges: how full one machine figure is, as a bar in a line of its own under the figure
* it belongs to. Every colour comes from here by class, so both themes apply (ADR 0011 §4); only
* the level arrives inline, which the Content-Security-Policy allows (style-src governs style
* elements, not style attributes). A gauge only shades what its figure already says, so it is
* square, quiet, and never the accent. */

.load-figure {
display: grid;
gap: var(--space-1);
}

/* why: the bar stops where a reading is readable rather than filling the panel — a full-width
* band would outweigh the one line of text it shades. */
.load-bar {
display: block;
height: 8px;
max-width: 20rem;
background: var(--field);
}

/* why: a new reading lands every 15 seconds, so the fill slides to its level over --duration
* rather than jumping; --duration is already zero under `prefers-reduced-motion: reduce`. Moving
* the width costs 0.13ms of layout per change, measured, and no long task either way. */
.load-bar-fill {
display: block;
height: 100%;
width: var(--load-bar, 0%);
transition: width var(--duration);
}

.load-bar-ok .load-bar-fill {
background: var(--status-ok);
}

.load-bar-warn .load-bar-fill {
background: var(--status-warn);
}

.load-bar-error .load-bar-fill {
background: var(--status-error);
}

.load-bar-idle .load-bar-fill {
background: var(--status-idle);
}

/* Tables: a row per item, in a panel. Headers are labels, rows are roomy, numbers sit right.
* Under 768px each row stacks into labelled lines, so the page never scrolls sideways. */

Expand Down
Loading
Loading