Public: anyone can read this; the crew can change it.

The /system page speaks the home page's language #9791

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/9786-the-system-page-speaks-the-home-page-s-l → main 3 commits fork

  • Merged. mason/9786-the-system-page-speaks-the-home-page-s-l was merged into main as 9ebd96b.

Review

Sign in to review this change.
5 files changed +157 −44
docs/design/areas/lists.md +6
@@ -46,3 +46,9 @@ frame, no tone and no icon before the heading. The state is a badge's word ("con
4646 their own headings, and the new-team form as a bare card: one background, no frame. The notice for an org with no
4747 owner keeps its tone. A person and an org share the template; team changes still go through `src/actions/orgs.ts`.
4848 No new class.
49
50 /system (`src/web/pages/system.ts`) adds no component class. As on the home page it is headings over rows: each part (Machine,
51 Disks and network, Processes, Main-thread stalls, Telemetry readers, Code page caches, Forge memory) is a
52 `<section class="settings-block">` with one `<h2>`; the lines are `row()`s in a `.list`, the wide figures stay tables. With
53 nothing to show a part says so in one plain sentence. The live regions (`summary`, `cores`, `mem`, `temps`, `sys-disks`,
54 `sys-procs`, …) keep their ids, so `system-live.ts` still patches them. `test/system-parts.test.ts` holds the shape.
src/web/pages/system.ts +31 −40
@@ -36,7 +36,7 @@ import { systemLiveJs } from "../system-live.ts";
3636 import { RECENT_BYTES, recentEncodings } from "../../http/compress.ts";
3737 import { KEPT_PAGE_BYTES, keptPageStats } from "./kept_page.ts";
3838 import { COLUMN_BYTES, PARTS_BYTES, keptPartsStats, treeCacheStats } from "./tree_cache.ts";
39 import { badge, card, chartBox, empty, meter, pill, row } from "../ui.ts";
39 import { badge, chartBox, meter, pill, row } from "../ui.ts";
4040
4141 /** A group's memory, and a cell's beside its cap and who holds the cap. */
4242 const memoryOf = (g: { rssBytes: number; capBytes: number | null; capBy: "kernel" | "watch" | null }): Html =>
@@ -68,6 +68,7 @@ const figureRow = (name: string, figure: string, data: Record<string, string> =
6868 const KIND_ORDER = ["forge", "crew", "check", "app", "host"] as const;
6969 function procTable(v: SystemView): Html {
7070 const groups = KIND_ORDER.flatMap((k) => v.groups.filter((g) => g.kind === k));
71 if (groups.length === 0) return html`<p class="muted">No processes were read.</p>`;
7172 return html`<div class="table-scroll"><table class="table" data-dense aria-label="Processes by what launched them">
7273 <thead><tr><th scope="col">pid</th><th scope="col">command</th><th scope="col" class="num">cpu</th><th scope="col" class="num">mem</th><th scope="col" class="num">age</th></tr></thead>
7374 ${groups.map(
@@ -91,17 +92,14 @@ function memorySection(ctx: Ctx): Html {
9192 if (!canSeeMemory(ctx.auth.user)) return html``;
9293 const hours = timePhase(ctx.request, "memory", () => memoryHours(ctx.app.db, Date.now()));
9394 const series = (title: string, pick: (h: (typeof hours)[number]) => number) =>
94 card(
95 chartBox("line", hours.map((h) => ({ label: hhmm(h.at).slice(11), value: pick(h) / 1_048_576 })), title),
96 { head: html`<h2>${title}</h2>` },
97 );
98 return hours.length === 0
99 ? empty({ icon: "activity", title: "No samples yet", note: "The forge samples its memory every minute; the first hour appears shortly." })
95 html`<div><h3 class="small">${title}</h3>${chartBox("line", hours.map((h) => ({ label: hhmm(h.at).slice(11), value: pick(h) / 1_048_576 })), title)}</div>`;
96 return html`<section class="settings-block" id="sys-memory"><h2>Forge memory</h2>${hours.length === 0
97 ? html`<p class="muted">No samples yet: the forge samples its memory every minute, so the first hour appears shortly.</p>`
10098 : html`<div class="plots">${series("RSS (MiB, hourly mean)", (h) => h.rssMean)}${series("JS heap (MiB, hourly mean)", (h) => h.heapMean)}</div>
10199 <div class="table-scroll"><table class="table" aria-label="Forge memory by hour">
102100 <thead><tr><th scope="col">Hour (UTC)</th><th scope="col" class="num">RSS mean</th><th scope="col" class="num">RSS max</th><th scope="col" class="num">Heap mean</th><th scope="col" class="num">Heap max</th></tr></thead>
103101 <tbody>${hours.map((h) => html`<tr><td class="mono">${hhmm(h.at)}</td><td class="num mono">${mib(h.rssMean)}</td><td class="num mono">${mib(h.rssMax)}</td><td class="num mono">${mib(h.heapMean)}</td><td class="num mono">${mib(h.heapMax)}</td></tr>`)}</tbody>
104 </table></div>`;
102 </table></div>`}</section>`;
105103 }
106104
107105 /** One in-process cache as it stands: what it is for, its byte cap, and `ViewCache.stats()`. */
@@ -126,40 +124,36 @@ export const pageCaches = (): CacheRow[] => [
126124 /** `pageCaches`' rows as a table; the hit rate is hits over hits and misses since the process started. */
127125 export function cachesSection(rows: readonly CacheRow[]): Html {
128126 const rate = (r: CacheRow) => (r.hits + r.misses === 0 ? "—" : `${((100 * r.hits) / (r.hits + r.misses)).toFixed(1)}%`);
129 return card(
130 html`<p class="small muted">In-process caches behind strangers' code pages, since this process started. Bytes are what each counts against its cap.</p>
127 return html`<section class="settings-block"><h2>Code page caches</h2>
128 <p class="small muted">Counts since this process started.</p>
131129 <div class="table-scroll"><table class="table" aria-label="Code page caches">
132130 <thead><tr><th scope="col">Cache</th><th scope="col">Holds</th><th scope="col" class="num">Entries</th><th scope="col" class="num">Bytes</th><th scope="col" class="num">Cap</th><th scope="col" class="num">Hits</th><th scope="col" class="num">Misses</th><th scope="col" class="num">Hit rate</th></tr></thead>
133131 <tbody>${rows.map((r) => html`<tr data-cache="${r.name}" data-bytes="${String(r.bytes)}"><td>${r.name}</td><td class="small muted">${r.holds}</td><td class="num mono">${String(r.entries)}</td><td class="num mono">${mib(r.bytes)}</td><td class="num mono">${mib(r.capBytes)}</td><td class="num mono">${String(r.hits)}</td><td class="num mono">${String(r.misses)}</td><td class="num mono">${rate(r)}</td></tr>`)}</tbody>
134 </table></div>`,
135 { head: html`<h2>Code page caches</h2>` },
136 );
132 </table></div></section>`;
137133 }
138134
139135 /** The last hour's stalls by route, most milliseconds first; the rows are `stallHour`'s. */
140136 export function stallSection(rows: readonly RouteStall[]): Html {
141 return card(
142 html`<p class="small muted">Milliseconds the main thread was held past 250 ms in the last hour, charged in full to every route running across the stall (<code>none</code>: no request was). As of this page's load; <code>/metrics</code> has the series.</p>
143 <div class="table-scroll"><table class="table" aria-label="Main-thread stalls by route, last hour">
137 return html`<section class="settings-block"><h2>Main-thread stalls, last hour</h2>
138 <p class="small muted">Past 250 ms, as of this page's load; <code>/metrics</code> has the series.</p>${
139 rows.length === 0
140 ? html`<p class="muted">No stalls in the last hour.</p>`
141 : html`<div class="table-scroll"><table class="table" aria-label="Main-thread stalls by route, last hour">
144142 <thead><tr><th scope="col">Route</th><th scope="col" class="num">Stall ms</th><th scope="col" class="num">Stalls</th></tr></thead>
145143 <tbody>${rows.map((r) => html`<tr data-route="${r.route}"><td class="mono">${r.route}</td><td class="num mono">${String(Math.round(r.ms))}</td><td class="num mono">${String(r.count)}</td></tr>`)}</tbody>
146 </table></div>`,
147 { head: html`<h2>Main-thread stalls, last hour</h2>` },
148 );
144 </table></div>`}</section>`;
149145 }
150146
151147 /** Who read the telemetry routes in the last 10 minutes and how fast (#9069); `readerPaces`' rows. */
152148 export function readersSection(rows: readonly ReaderPace[]): Html {
153149 const secs = (ms: number | null): string => (ms === null ? "—" : `${(ms / 1000).toFixed(1)} s`);
154 return card(
150 return html`<section class="settings-block"><h2>Telemetry readers</h2>${
155151 rows.length === 0
156152 ? html`<p class="muted">No telemetry reads in the last 10 minutes.</p>`
157153 : html`<div class="table-scroll"><table class="table" aria-label="Telemetry readers, last 10 minutes">
158154 <thead><tr><th scope="col">Route</th><th scope="col">Reader</th><th scope="col" class="num">Reads, 10 min</th><th scope="col" class="num">Mean interval</th><th scope="col"></th></tr></thead>
159155 <tbody>${rows.map((r) => html`<tr data-route="${r.route}" data-reader="${r.reader}"><td class="mono">${r.route}</td><td class="mono">${r.reader}</td><td class="num mono">${String(r.reads)}</td><td class="num mono">${secs(r.meanIntervalMs)}</td><td>${r.tooFast ? badge("faster than the flow copy changes", { tone: "danger" }) : null}</td></tr>`)}</tbody>
160 </table></div>`,
161 { head: html`<h2>Telemetry readers</h2>` },
162 );
156 </table></div>`}</section>`;
163157 }
164158
165159 /**
@@ -193,14 +187,12 @@ export function regions(v: SystemView): Record<string, string> {
193187 : null}
194188 ${v.load ? figureRow("load", v.load.map((l) => l.toFixed(2)).join(" ")) : null}`,
195189 temps: html`${v.temps.map((t) => `${t.chip} ${t.label} ${t.celsius.toFixed(0)}°C`).join(" · ")}`,
196 "sys-disks": card(
197 html`<ul class="list">
198 ${v.disks.map((d) => meterRow(d.mount, d.usedBytes, d.totalBytes, `${bytes(d.usedBytes)} / ${bytes(d.totalBytes)} ${d.fs}`))}
190 "sys-disks":
191 v.disks.length + v.io.length + v.net.length === 0
192 ? html`<li class="muted">No disks or network were read.</li>`
193 : html`${v.disks.map((d) => meterRow(d.mount, d.usedBytes, d.totalBytes, `${bytes(d.usedBytes)} / ${bytes(d.totalBytes)} ${d.fs}`))}
199194 ${v.io.map((d) => figureRow(d.name, `R ${rateOf(d.readBytesPerSecond)} W ${rateOf(d.writeBytesPerSecond)}`, { io: d.name }))}
200 ${v.net.map((n) => figureRow(n.name, `↓ ${rateOf(n.rxBytesPerSecond)} ↑ ${rateOf(n.txBytesPerSecond)}`, { net: n.name }))}
201 </ul>`,
202 { head: html`<h2>Disks and network</h2>`, pad: false },
203 ),
195 ${v.net.map((n) => figureRow(n.name, `↓ ${rateOf(n.rxBytesPerSecond)} ↑ ${rateOf(n.txBytesPerSecond)}`, { net: n.name }))}`,
204196 "sys-procs": procTable(v),
205197 };
206198 return Object.fromEntries(Object.entries(out).map(([k, h]) => [k, h.text]));
@@ -249,19 +241,18 @@ export function systemPage(ctx: Ctx): Response {
249241 </p>
250242 </div>
251243 <div id="sys-dup">${at("sys-dup")}</div>
252 ${card(
253 html`<div id="summary" class="row-meta">${at("summary")}</div>
244 <section class="settings-block"><h2>Machine</h2>
245 <div id="summary" class="row-meta">${at("summary")}</div>
254246 <ul class="list" id="cores" aria-label="Per-core CPU, percent, with its last readings">${at("cores")}</ul>
255247 <ul class="list" id="mem">${at("mem")}</ul>
256248 ${caps}
257 <p class="small mono" id="temps">${at("temps")}</p>`,
258 { head: html`<h2>Machine</h2>` },
259 )}
260 <div id="sys-disks">${at("sys-disks")}</div>
261 <div class="page-head"><h2>Processes</h2>
262 <p class="small muted"><span class="js-only">Sort by <button class="btn" data-size="sm" type="button" data-sort="cpu" aria-pressed="true"><kbd>c</kbd> CPU</button>
263 <button class="btn" data-size="sm" type="button" data-sort="mem" aria-pressed="false"><kbd>m</kbd> memory</button></span></p></div>
264 <div id="sys-procs">${at("sys-procs")}</div>
249 <p class="small mono" id="temps">${at("temps")}</p></section>
250 <section class="settings-block"><h2>Disks and network</h2>
251 <ul class="list" id="sys-disks">${at("sys-disks")}</ul></section>
252 <section class="settings-block"><h2>Processes</h2>
253 <div class="js-only small muted">Sort by <button class="btn" data-size="sm" type="button" data-sort="cpu" aria-pressed="true"><kbd>c</kbd> CPU</button>
254 <button class="btn" data-size="sm" type="button" data-sort="mem" aria-pressed="false"><kbd>m</kbd> memory</button></div>
255 <div id="sys-procs">${at("sys-procs")}</div></section>
265256 <div id="sys-stalls">${stallSection(stalls)}</div>
266257 <div id="sys-readers">${readersSection(readerPaces())}</div>
267258 <div id="sys-caches">${cachesSection(pageCaches())}</div>
test/system-btop.test.ts +3 −2
@@ -110,7 +110,8 @@ describe("/system as btop", () => {
110110 expect(r["cores"]).toContain('class="row-title"');
111111 expect(r["cores"]).toContain("<meter");
112112 expect(r["mem"]).toContain("load");
113 expect(r["sys-disks"]).toContain('class="card"');
113 expect(r["sys-disks"]).toContain('class="row"');
114 expect(r["sys-disks"]).not.toContain("card");
114115 expect(r["sys-disks"]).toContain('data-io="nvme0n1"');
115116 expect(r["sys-disks"]).toContain('data-net="eth0"');
116117 expect(r["sys-procs"]).toContain('class="table"');
@@ -132,7 +133,7 @@ describe("/system as btop", () => {
132133 expect(r["cores"]).toContain('aria-label="cpu0 ');
133134 expect(r["mem"]).toContain('aria-label="swap 512 MiB / 1.0 GiB"');
134135 const page = await Bun.file("src/web/pages/system.ts").text();
135 expect(page).toMatch(/class="js-only">Sort by[\s\S]*data-sort="mem"/);
136 expect(page).toMatch(/class="js-only[^"]*">Sort by[\s\S]*data-sort="mem"/);
136137 });
137138
138139 test("the sort puts kinds in order and the biggest first by CPU or memory, ties staying put", () => {
test/system-memory.test.ts +2 −2
@@ -93,8 +93,8 @@ describe("GET /system", () => {
9393 const body = await (await get("alice")).text();
9494 expect(body).not.toContain("sys-pair");
9595 expect(body).toContain('<div class="plots">');
96 expect(body).toContain("<h2>RSS (MiB, hourly mean)</h2>");
97 expect(body).toContain("<h2>JS heap (MiB, hourly mean)</h2>");
96 expect(body).toContain('<h3 class="small">RSS (MiB, hourly mean)</h3>');
97 expect(body).toContain('<h3 class="small">JS heap (MiB, hourly mean)</h3>');
9898 expect(body).toContain('aria-label="Forge memory by hour"');
9999 expect(body).toContain("Heap max");
100100 });
test/system-parts.test.ts added +115
@@ -0,0 +1,115 @@
1 /**
2 * /system is headings over rows, as the home page is: each part a
3 * `<section class="settings-block">` with one <h2>, no `.card`, and the live
4 * regions the stream patches keep their ids.
5 */
6 import { afterAll, beforeAll, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp } from "../src/app.ts";
11 import { recordMemory } from "../src/boards/memory.ts";
12 import { createToken } from "../src/core/tokens.ts";
13 import { createUser } from "../src/core/users.ts";
14 import { serve } from "../src/http/server.ts";
15 import type { SystemView } from "../src/system/view.ts";
16 import { cachesSection, regions, stallSection } from "../src/web/pages/system.ts";
17
18 let dir: string;
19 let app: ReturnType<typeof createApp>;
20 let servers: Awaited<ReturnType<typeof serve>>;
21 let token = "";
22
23 beforeAll(async () => {
24 dir = await mkdtemp(join(tmpdir(), "forge-system-parts-"));
25 app = createApp({ dataDir: dir, port: 0 });
26 servers = await serve(app);
27 const r = await createUser(app.db, {
28 origin: "seed",
29 trusted: true,
30 username: "alice",
31 email: "[email protected]",
32 password: "a-good-passphrase",
33 adminIfFirst: true,
34 });
35 if (!r.ok) throw new Error(r.error);
36 token = createToken(app.db, r.value.id, "t", "admin").token;
37 recordMemory(app.db, 120 * 1_048_576, 40 * 1_048_576, Date.now());
38 });
39 afterAll(async () => {
40 await servers.stop();
41 await rm(dir, { recursive: true, force: true });
42 });
43
44 test("one <h2> per part, each in a settings-block, and no card", async () => {
45 const res = await fetch(servers.url + "/system", { headers: { authorization: `Bearer ${token}` } });
46 expect(res.status).toBe(200);
47 const body = await res.text();
48 const main = body.slice(body.indexOf("<h1>System</h1>"));
49 expect(main).not.toMatch(/class="card[ "]/);
50 const heads = [...main.matchAll(/<h2>([^<]*)<\/h2>/g)].map((m) => m[1]);
51 expect(heads).toEqual([
52 "Machine",
53 "Disks and network",
54 "Processes",
55 "Main-thread stalls, last hour",
56 "Telemetry readers",
57 "Code page caches",
58 "Forge memory",
59 ]);
60 expect(main.match(/<section class="settings-block"/g)?.length).toBe(heads.length);
61 for (const id of ["sys-dup", "summary", "cores", "mem", "temps", "sys-disks", "sys-procs", "sys-stalls", "sys-readers", "sys-caches"]) {
62 expect(main).toContain(`id="${id}"`);
63 }
64 expect(main).toMatch(/<section class="settings-block"><h2>Disks and network<\/h2>\s*<ul class="list" id="sys-disks">/);
65 expect(main).not.toContain('<span class="js-only">');
66 });
67
68 const empty: SystemView = {
69 at: "",
70 seconds: 0,
71 cpu: { busyPct: null, cores: [], history: [] },
72 temps: [],
73 hottestC: null,
74 load: null,
75 memory: null,
76 disks: [],
77 net: [],
78 io: [],
79 processes: 0,
80 groups: [],
81 duplicates: [],
82 };
83
84 test("an empty view says so in a sentence, in the live regions too", () => {
85 const r = regions(empty);
86 expect(r["sys-procs"]).toContain("No processes were read.");
87 expect(r["sys-procs"]).not.toContain("<table");
88 expect(r["sys-disks"]).toContain("No disks or network were read.");
89 });
90
91 test("no stalls says so rather than drawing a header over nothing", () => {
92 const h = stallSection([]).text;
93 expect(h).toContain("No stalls in the last hour.");
94 expect(h).not.toContain("<table");
95 expect(stallSection([{ route: "/x", ms: 300, count: 1 }]).text).toContain("<table");
96 });
97
98 test("the caches section keeps one short sentence", () => {
99 expect(cachesSection([]).text.match(/<p /g)?.length).toBe(1);
100 });
101
102 test("no memory samples yet says so", () => {
103 const empty2 = mkdtemp(join(tmpdir(), "forge-system-mem-")).then(async (d) => {
104 const a = createApp({ dataDir: d, port: 0 });
105 const s = await serve(a);
106 const u = await createUser(a.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
107 if (!u.ok) throw new Error(u.error);
108 const t = createToken(a.db, u.value.id, "t", "admin").token;
109 const body = await (await fetch(s.url + "/system", { headers: { authorization: `Bearer ${t}` } })).text();
110 await s.stop();
111 await rm(d, { recursive: true, force: true });
112 return body;
113 });
114 return empty2.then((body) => expect(body).toContain("No samples yet"));
115 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs