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
14 changes: 8 additions & 6 deletions docs/CONSOLE.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,12 +172,14 @@ Select a worker to open its page. It adds:
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. 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
it is, for scanning at a glance. A bar is neutral until the figure is
**85% full or more**, amber to 95%, red from there — the console's own rule,
read off the one figure it is drawn from and nothing else. Where memory
reports a pressure, that word wins instead: its bar is green, amber or red for
`normal`, `warn` or `critical` however full it is. Only the bar is coloured
this way: the figures themselves are printed as numbers, and `simlock status`
reports them without a verdict. 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
18 changes: 12 additions & 6 deletions docs/internal/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,12 +189,18 @@ scale; never a value between.
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.
takes no focus — the figure above it is the text. Where the worker sends a
memory pressure, that word colours its bar, so there colour is a second signal
for a fact the panel prints as a word. Every figure without a word to echo —
CPU and disk always, memory on a worker that reports no pressure — takes
`LOAD_THRESHOLDS` from `ui/src/load-thresholds.ts`: amber at 85% full, red at
95%, neutral below, the current reading only, nothing remembered between them.
That is the one place in the console where a colour says something no word
does, so it is kept where a gauge is — beside the figure it judges — and
nowhere else: not in a status, not in a table, not in the card's one line of
text. A worker sends no thresholds, so these are the console's opinion alone
and `simlock status` reports the same figures without one. A gauge belongs to
this panel alone.

## Layout

Expand Down
122 changes: 80 additions & 42 deletions e2e/console/workers.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,45 @@ interface WorkerView {
};
}

// 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;
load?:
| {
memory?: { availableBytes?: number; pressure?: string; totalBytes?: number } | undefined;
}
| undefined;
}

// why: the two fields a colour case has to state; everything else in the reading stays the
// daemon's.
interface MemoryClaim {
readonly pressure?: string;
readonly usedPercent: number;
}

/**
* why: only macOS reports a memory pressure, and how full a CI machine's memory happens to be is
* nobody's to predict — so the colour test states the memory reading it wants and this writes just
* those fields into the daemon's own answer, leaving every other field, its headers and its `Date`
* real. The route is asked again per request, so the reading stays as fresh as the daemon's own.
*/
async function answerMemory(page: Page, claim: () => MemoryClaim): Promise<void> {
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;
const total = memory?.totalBytes;
if (memory === undefined || typeof total !== "number" || total <= 0) continue;
const wanted = claim();
memory.availableBytes = Math.round((total * (100 - wanted.usedPercent)) / 100);
if (wanted.pressure === undefined) delete memory.pressure;
else memory.pressure = wanted.pressure;
}
await route.fulfill({ json: listed, response });
},
);
}

interface Status {
Expand Down Expand Up @@ -176,6 +211,24 @@ function fillsMatch(painted: readonly PaintedGauge[], percents: readonly number[
);
}

interface PaintedTone {
readonly colour: string;
readonly tone: string;
}

// why: what the memory poll cannot see, because it watches one gauge: that every other gauge on
// the panel carries a tone class at all, and wears none of them in the accent. Which token a tone
// class resolves to is the poll's claim, proven for all four in both themes.
function gaugeToneFaults(painted: readonly PaintedTone[], accent: string): string[] {
const faults: string[] = [];
for (const fill of painted) {
if (fill.tone === "") faults.push("a gauge carries no tone class");
else if (fill.colour === accent)
faults.push(`the ${fill.tone} gauge is painted with the accent`);
}
return faults;
}

/** 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 @@ -578,26 +631,13 @@ test.describe("the workers views", () => {
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 });
},
);
// why: only macOS reports a memory pressure, and how full a CI machine's memory happens to be
// is nobody's to predict. This answers the page's own read of the worker list with the memory
// reading's own fields set to the level the case asks for and every other field left as the
// daemon sent it, so what is painted is the real component's doing and the same colour is
// expected on any machine.
let claim: MemoryClaim = { usedPercent: 50 };
await answerMemory(page, () => claim);
await shown.getByRole("link", { name: host?.id ?? "" }).click();

const panel = page.getByRole("region", { name: "Load", exact: true });
Expand All @@ -621,40 +661,38 @@ test.describe("the workers views", () => {
};
});

// 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.
// why: these six are the whole colour rule for a memory gauge — the worker's three words, that
// the word outranks the console's threshold, and the threshold on its own at either end. Each is
// seen in each theme by the real component, so a tone 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"],
for (const [level, usedPercent, tone] of [
["normal", 50, "ok"],
["warn", 50, "warn"],
["critical", 50, "error"],
["normal", 97, "ok"],
[undefined, 97, "error"],
[undefined, 50, "idle"],
] as const) {
pressure = level;
claim = { usedPercent, ...(level === undefined ? {} : { pressure: level }) };
const want = tones[TONES.indexOf(tone)];
const at = `${usedPercent}%${level === undefined ? " with no word" : ` and ${level}`}`;
await expect
.poll(() => memoryFill(), {
timeout: 5_000,
message: `the ${level ?? "absent"} pressure to paint the ${tone} gauge in ${colorScheme}`,
message: `${at} 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.
// why: the poll above watches the memory gauge; this watches the rest of the panel, for the
// one thing a poll of a single gauge cannot see.
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);
}
expect(gaugeToneFaults(fills, accent)).toEqual([]);
}
}
});
Expand Down
34 changes: 34 additions & 0 deletions ui/src/load-thresholds.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";

import { LOAD_THRESHOLDS, levelOf } from "./load-thresholds";

describe("the console's own load thresholds", () => {
it("colours a figure at or over each threshold, and nothing below it", () => {
expect(levelOf(LOAD_THRESHOLDS.errorPercent)).toBe("error");
// why: one step under each edge, or a threshold moved down by one still colours the whole
// range this file names and stays green.
expect(levelOf(LOAD_THRESHOLDS.errorPercent - 1)).toBe("warn");
expect(levelOf(LOAD_THRESHOLDS.warnPercent)).toBe("warn");
expect(levelOf(LOAD_THRESHOLDS.warnPercent - 1)).toBe("idle");
});

it("reads each figure the same way round, however full", () => {
expect(levelOf(0)).toBe("idle");
expect(levelOf(84.9)).toBe("idle");
expect(levelOf(90)).toBe("warn");
expect(levelOf(100)).toBe("error");
});

it("stays neutral for a figure the reading does not carry", () => {
expect(levelOf(undefined)).toBe("idle");
});

it("keeps the numbers the console's docs promise, apart and in order", () => {
// why: these two are printed to a person in the console's own manual, so a change here is a
// change to what the docs say; the literals hold the pair to that promise.
expect(LOAD_THRESHOLDS.warnPercent).toBe(85);
expect(LOAD_THRESHOLDS.errorPercent).toBe(95);
expect(LOAD_THRESHOLDS.warnPercent).toBeLessThan(LOAD_THRESHOLDS.errorPercent);
expect(LOAD_THRESHOLDS.errorPercent).toBeLessThanOrEqual(100);
});
});
17 changes: 17 additions & 0 deletions ui/src/load-thresholds.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { Tone } from "./status";

// why: the worker sends no thresholds, so the CPU and disk gauges would have nothing to be
// coloured by. These are the console's own opinion, read off the one figure each gauge is drawn
// from; nothing here is remembered between readings, so a bar always shows the current reading's
// verdict. `simlock status` reports the same figures without one.
export const LOAD_THRESHOLDS = { errorPercent: 95, warnPercent: 85 } as const;

// why: below `warnPercent` a figure stays in the neutral tone, so a colour always says a threshold
// was crossed rather than "healthy, as far as we guessed". A figure the reading does not carry has
// nothing to judge.
export function levelOf(percent: number | undefined): Tone {
if (percent === undefined) return "idle";
if (percent >= LOAD_THRESHOLDS.errorPercent) return "error";
if (percent >= LOAD_THRESHOLDS.warnPercent) return "warn";
return "idle";
}
57 changes: 43 additions & 14 deletions ui/src/views/worker-detail.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -607,30 +607,59 @@ describe("the Load gauges", () => {
expect(row(panel, "Memory")).not.toContain("load-bar");
});

it("a worker's detail colours the memory gauge with its pressure, and idle where the view has none", () => {
it("a worker's detail colours the memory gauge with its pressure word", () => {
type Memory = NonNullable<NonNullable<WorkerView["load"]>["memory"]>;
const { pressure: _pressure, ...noPressure } = LOAD.memory;
const memoryBar = (memory: Memory) =>
row(panelOf({ ...RUNNING, load: { ...LOAD, memory } }), "Memory");

expect(memoryBar(LOAD.memory)).toContain(bar("ok", 69));
expect(memoryBar({ ...LOAD.memory, pressure: "warn" })).toContain(bar("warn", 69));
expect(memoryBar({ ...LOAD.memory, pressure: "critical" })).toContain(bar("error", 69));
expect(memoryBar(noPressure)).toContain(bar("idle", 69));
});

it("a worker's detail keeps the CPU and disk gauges idle at any level, a full disk included", () => {
const panel = panelOf({
...RUNNING,
load: {
...LOAD,
cpu: { busyPercent: 99, cores: 10, limited: false },
disk: { freeBytes: 0, totalBytes: 494.4 * GIB },
},
});
it("a worker's detail falls back to the console's threshold for memory the worker gives no word for", () => {
const { pressure: _pressure, ...noPressure } = LOAD.memory;
// why: 97% of a 32 GiB worker is the figure the spec names, over the edge; 1.5 GiB available is
// 95% used, the error threshold itself. The one step under it is the CPU and disk test's job.
const almostGone = { ...noPressure, availableBytes: 0.96 * GIB };
const atTheEdge = { ...noPressure, availableBytes: 1.5 * GIB };
const plenty = { ...noPressure, availableBytes: 30 * GIB };

expect(row(panelOf({ ...RUNNING, load: { ...LOAD, memory: almostGone } }), "Memory")).toContain(
bar("error", 97),
);
expect(row(panelOf({ ...RUNNING, load: { ...LOAD, memory: atTheEdge } }), "Memory")).toContain(
bar("error", 95),
);
expect(row(panelOf({ ...RUNNING, load: { ...LOAD, memory: plenty } }), "Memory")).toContain(
bar("idle", 6),
);
});

it("a worker's detail colours the CPU and disk gauges by the console's own thresholds", () => {
type Cpu = NonNullable<NonNullable<WorkerView["load"]>["cpu"]>;
type Disk = NonNullable<NonNullable<WorkerView["load"]>["disk"]>;
const rowFor = (label: string, cpu: Partial<Cpu>, disk: Partial<Disk>) =>
row(
panelOf({
...RUNNING,
load: { ...LOAD, cpu: { ...LOAD.cpu, ...cpu }, disk: { ...LOAD.disk, ...disk } },
}),
label,
);

expect(row(panel, "CPU")).toContain(bar("idle", 99));
expect(row(panel, "Disk")).toContain(bar("idle", 100));
// why: the worker sends no threshold for either figure, so the console's own decides, one step
// under each edge included. The disk is 500 GiB so each case lands on a whole percent: 84, 85,
// 94 and 95% used.
const disk = (free: number) => ({ freeBytes: free * GIB, totalBytes: 500 * GIB });
expect(rowFor("CPU", { busyPercent: 84 }, disk(500))).toContain(bar("idle", 84));
expect(rowFor("CPU", { busyPercent: 85 }, disk(500))).toContain(bar("warn", 85));
expect(rowFor("CPU", { busyPercent: 94 }, disk(500))).toContain(bar("warn", 94));
expect(rowFor("CPU", { busyPercent: 95 }, disk(500))).toContain(bar("error", 95));
expect(rowFor("Disk", { busyPercent: 0 }, disk(80))).toContain(bar("idle", 84));
expect(rowFor("Disk", { busyPercent: 0 }, disk(75))).toContain(bar("warn", 85));
expect(rowFor("Disk", { busyPercent: 0 }, disk(30))).toContain(bar("warn", 94));
expect(rowFor("Disk", { busyPercent: 0 }, disk(25))).toContain(bar("error", 95));
});

it("a worker's detail says each figure's number once, the gauge carrying none of its own", () => {
Expand Down
16 changes: 12 additions & 4 deletions ui/src/views/worker-detail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Link } from "../router";
import { deviceStateStatus, installStatus, pressureStatus, Status, type Tone } from "../status";
import { type Column, DataTable } from "../table";
import { WorkerLeases } from "./leases";
import { levelOf } from "../load-thresholds";
import { formatBusyPercent, formatBytes, formatCores, loadGauges } from "./load-model";
import { WorkerFacts } from "./worker-facts";
import {
Expand Down Expand Up @@ -332,16 +333,22 @@ function LoadFacts({ now, worker }: { readonly worker: WorkerView; readonly now:
const { cpu, disk, memory } = load;
const age = formatDuration(Math.ceil((now - load.readAt) / 1000) * 1000);
const gauges = loadGauges(load);
// why: a gauge only shades the figure beside it. The console takes thresholds from the daemon
// and invents none, so CPU and disk have no level to be wrong at and stay in the neutral tone;
// memory has one the worker sends — its pressure — and takes that tone.
const memoryTone = memory?.pressure === undefined ? "idle" : pressureStatus(memory.pressure).tone;
// why: a gauge only shades the figure beside it. Memory has a level the worker sends — its
// pressure — and takes that tone; where the worker sends none, which is every Linux worker, it
// falls back to the console's own threshold like the other two. Those two have nothing to ask the
// worker for, so `load-thresholds.ts` decides, and a figure stays in the neutral tone until one
// is crossed.
const memoryTone =
memory?.pressure === undefined ? levelOf(gauges.memory) : pressureStatus(memory.pressure).tone;
const cpuTone = levelOf(gauges.cpu);
const diskTone = levelOf(gauges.disk);
return (
<dl className="facts">
<FigureRow
label="CPU"
part={cpu}
gauge={gauges.cpu}
tone={cpuTone}
figure={(part) => (
<>
{formatBusyPercent(part.busyPercent)} busy, {formatCores(part.cores)}
Expand Down Expand Up @@ -379,6 +386,7 @@ function LoadFacts({ now, worker }: { readonly worker: WorkerView; readonly now:
label="Disk"
part={disk}
gauge={gauges.disk}
tone={diskTone}
figure={(part) => `${formatBytes(part.freeBytes)} of ${formatBytes(part.totalBytes)} free`}
/>
<div>
Expand Down
Loading