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

/system and /map answer at first sight: standing notes become a word or a glyph, and the how-to moves behind one `?` #11124

Merged opened this

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

Merged

mason/forge:mason/11111-system-and-map-answer-at-first-sight-sta → main 4 commits fork 4 threads · 4 unresolved

  • Merged. mason/11111-system-and-map-answer-at-first-sight-sta was merged into main as b5db1f2.

Review

Sign in to review this change.
6 files changed +146 −40
src/web/pages/map.ts +9 −10
@@ -52,7 +52,7 @@ import {
5252 import { liveRegionsJs } from "../client.ts";
5353 import { html, type Html } from "../html.ts";
5454 import { page } from "../layout.ts";
55 import { btn, empty, row } from "../ui.ts";
55 import { btn, disclosure, empty, row } from "../ui.ts";
5656 import {
5757 parkedTwice,
5858 struckOut,
@@ -1506,14 +1506,13 @@ export async function mapPage(ctx: Ctx): Promise<Response> {
15061506 const body = html`${repoHeader(ctx, repo, "map")}
15071507 <div class="page-head mt">
15081508 <h1>Map</h1>
1509 <p class="muted">
1510 What waits for what. The top edge is ready now; the chain in the accent
1511 is the least work that still has to happen in order.
1512 <a href="${repoBase(repo)}/space">The same work as a space to move through</a>.
1513 <a href="${repoBase(repo)}/milestones">How far each milestone is</a>.
1514 <a href="${repoBase(repo)}/next">Next up</a> is the one ranked list the crew builds from.
1515 </p>
1509 <span class="spacer"></span>
1510 <a href="${repoBase(repo)}/space">Space</a><a href="${repoBase(repo)}/milestones">Milestones</a><a href="${repoBase(repo)}/next">Next up</a>
outdated

Three links in a bare span.muted separated by whitespace: at 360px they run together with no spacing and tap targets are tiny. Use the named row/links component with real gaps.

15161511 </div>
1512 ${disclosure("? How to read this", html`<div class="prose break" id="map-help">

Principle 1: the third paragraph re-lists Space, Milestones and Next up, which the head already links. Remove it; the head links carry that.

1513 <p>What waits for what. The top edge is ready now; the chain in the accent is the least work that still has to happen in order.</p>
1514 <p>The first list is what nothing stands in front of; when it is empty, everything open waits for something. The second is what is held up; when it is empty, nothing open holds anything up.</p>
1515 </div>`, { id: "map-more" })}
15171516 <div
15181517 class="map-wrap"
15191518 id="map-live"
@@ -1542,7 +1541,7 @@ export async function mapPage(ctx: Ctx): Promise<Response> {
15421541 ? "holds nothing up"
15431542 : `finishing it frees ${r.holdsUp}`
15441543 }`,
1545 "Nothing is ready: everything open waits for something.",
1544 "Nothing ready.",
15461545 tellHref,
15471546 {
15481547 writer:
@@ -1564,7 +1563,7 @@ export async function mapPage(ctx: Ctx): Promise<Response> {
15641563 blocked,
15651564 (r) =>
15661565 `${wordFor(r.issue, readySet, marks)} · ${pillarOf(r)}${r.waitsFor > 0 ? `waits for ${r.waitsFor} · ` : ""}holds up ${r.holdsUp}`,
1567 "Nothing open holds anything up.",
1566 "Nothing blocked.",
15681567 tellHref,
15691568 )}
15701569 </aside>
src/web/pages/system.ts +29 −22
@@ -43,7 +43,7 @@ import { KEPT_PAGE_BYTES, keptPageStats } from "./kept_page.ts";
4343 import { COLUMN_BYTES, PARTS_BYTES, keptPartsStats, treeCacheStats } from "./tree_cache.ts";
4444 import { featureHealth, type FeatureHealth } from "../../system/feature_health.ts";
4545 import { egressReportedLine } from "../../system/egress_line.ts";
46 import { badge, chartBox, meter, pill, row, time } from "../ui.ts";
46 import { badge, chartBox, disclosure, meter, pill, row, time } from "../ui.ts";
4747 import { childDirOf, listChildren } from "../../actions/spawn.ts";
4848 import { childrenUse, realSelf, selfUse, shareOf, sizeWords, useWords, type ChildUseRow, type SelfUse } from "../../spawn/usage.ts";
4949
@@ -84,9 +84,13 @@ export function selfSection(self: SelfUse | null): Html {
8484 ${meterRow("cpu", use.cores, use.limits.cpu, use.cores === null ? `measuring · ${w.cpu}` : w.cpu)}
8585 ${meterRow("disk", use.diskMiB, use.limits.diskMiB, w.disk)}
8686 </ul>
87 <p class="small muted">Set by the forge that made this one. ${self.from === "process" ? "Memory and CPU count this process alone. " : ""}Disk is measured every 30 seconds.</p></section>`;
87 </section>`;
8888 }
8989
90 /** What this forge's own size section leaves to the page's `?`: who set the size and what is measured. */
91 export const selfHelp = (self: SelfUse | null): Html | null =>
92 self === null ? null : html`<p id="sys-self-help">Size is set by the forge that made this one. ${self.from === "process" ? "Memory and CPU count this process alone. " : ""}Disk is measured every 30 seconds.</p>`;
93
9094 /** What a child row says beside its meter: a cell's three figures, or why there are none. */
9195 const childSaid = (r: ChildUseRow): string => {
9296 if (r.use.kind === "shared") return "shared with this forge";
@@ -142,7 +146,7 @@ function memorySection(ctx: Ctx): Html {
142146 const series = (title: string, pick: (h: (typeof hours)[number]) => number) =>
143147 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>`;
144148 return html`<section class="settings-block" id="sys-memory"><h2>Forge memory</h2>${hours.length === 0
145 ? html`<p class="muted">No samples yet: the forge samples its memory every minute, so the first hour appears shortly.</p>`
149 ? html`<p class="muted">No samples yet.</p>`
146150 : html`<div class="plots">${series("RSS (MiB, hourly mean)", (h) => h.rssMean)}${series("JS heap (MiB, hourly mean)", (h) => h.heapMean)}</div>
147151 <div class="table-scroll"><table class="table" aria-label="Forge memory by hour">
148152 <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>
@@ -172,11 +176,10 @@ export const pageCaches = (): CacheRow[] => [
172176 /** `pageCaches`' rows as a table; the hit rate is hits over hits and misses since the process started. */
173177 export function cachesSection(rows: readonly CacheRow[]): Html {
174178 const rate = (r: CacheRow) => (r.hits + r.misses === 0 ? "—" : `${((100 * r.hits) / (r.hits + r.misses)).toFixed(1)}%`);
175 return html`<section class="settings-block"><h2>Code page caches</h2>
176 <p class="small muted">Counts since this process started.</p>
179 return html`<section class="settings-block"><h2>Code page caches, since start</h2>
177180 <div class="table-scroll"><table class="table" aria-label="Code page caches">
178 <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>
179 <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>
181 <thead><tr><th scope="col">Cache</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>
182 <tbody>${rows.map((r) => html`<tr data-cache="${r.name}" data-bytes="${String(r.bytes)}"><td>${r.name}</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>
outdated

Principle 8 / keyboard and touch: title is hover-only. The Holds column was real content and is now unreachable on a phone or with a keyboard. List what each cache holds inside the ?, or keep the column.

180183 </table></div></section>`;
181184 }
182185
@@ -199,8 +202,7 @@ export function featuresSection(rows: readonly FeatureHealth[]): Html {
199202
200203 /** The last hour's stalls by route, most milliseconds first; the rows are `stallHour`'s. */
201204 export function stallSection(rows: readonly RouteStall[], worst: Html = html``): Html {
202 return html`<section class="settings-block"><h2>Main-thread stalls, last hour</h2>
203 <p class="small muted">Past 200 ms, as of this page's load; <code>/metrics</code> has the series.</p>${
205 return html`<section class="settings-block"><h2>Main-thread stalls, last hour</h2>${
204206 rows.length === 0
205207 ? html`<p class="muted">No stalls in the last hour.</p>`
206208 : html`<div class="table-scroll"><table class="table" aria-label="Main-thread stalls by route, last hour">
@@ -263,7 +265,7 @@ export function readersSection(rows: readonly ReaderPace[]): Html {
263265 export function regions(v: SystemView): Record<string, string> {
264266 const dup: Html =
265267 v.duplicates.length === 0
266 ? html`<p id="duplicates" data-count="0" class="muted">No test file is running in more than one check.</p>`
268 ? html`<p id="duplicates" data-count="0" class="muted">✓ no repeated tests</p>`
267269 : html`<p id="duplicates" data-count="${String(v.duplicates.length)}">
268270 <strong>${String(v.duplicates.length)} test file${v.duplicates.length === 1 ? " is" : "s are"}
269271 running in more than one check at once:</strong>
@@ -323,12 +325,14 @@ export async function systemPage(ctx: Ctx): Promise<Response> {
323325 const [holder, pids] = timePhase(ctx.request, "caps", () => [memoryCapHolder(), processCapHolder()] as const);
324326 const caps: Html =
325327 holder.by === "kernel"
326 ? html`<p class="small" id="cell-caps" data-by="kernel">Each cell's memory is capped by the kernel (its own cgroup's <code>memory.max</code>): a cell over it is killed alone.</p>`
327 : html`<p class="small" id="cell-caps" data-by="watch">Each cell's memory is watched once a second and the cell stopped over its cap; the kernel does not cap it (${holder.reason}).</p>`;
328 ? html`<p id="cell-caps" data-by="kernel">Each cell's memory is capped by the kernel (its own cgroup's <code>memory.max</code>): a cell over it is killed alone.</p>`
329 : html`<p id="cell-caps" data-by="watch">Each cell's memory is watched once a second and the cell stopped over its cap; the kernel does not cap it (${holder.reason}).</p>`;
328330 const processCaps: Html =
329331 pids.by === "kernel"
330 ? html`<p class="small" id="cell-pids" data-by="kernel">Each cell's processes are capped by the kernel at ${PROCESSES_MAX} (its cgroup's <code>pids.max</code>; not a crew run's): a fork past it fails inside that cell.</p>`
331 : html`<p class="small" id="cell-pids" data-by="watch">Each cell's processes are counted once a second and the cell stopped past ${PROCESSES_MAX}; the kernel does not cap them (${pids.reason}).</p>`;
332 ? html`<p id="cell-pids" data-by="kernel">Each cell's processes are capped by the kernel at ${PROCESSES_MAX} (its cgroup's <code>pids.max</code>; not a crew run's): a fork past it fails inside that cell.</p>`
333 : html`<p id="cell-pids" data-by="watch">Each cell's processes are counted once a second and the cell stopped past ${PROCESSES_MAX}; the kernel does not cap them (${pids.reason}).</p>`;
334 // The two caps as a word each beside the machine's figures; the sentences are in the `?`.
335 const capWords: Html = html`<div class="row-meta" id="cap-words">${pill(`cell memory: ${holder.by === "kernel" ? "kernel-capped" : "watched"}`)} ${pill(`cell processes: ${pids.by === "kernel" ? "kernel-capped" : "watched"}`)}</div>`;
332336 const egressSaid = egressReportedLine(ctx.app.db);
333337 const egressLine: Html | null =
334338 egressSaid === null ? null : html`<p class="small" id="egress-reported">${egressSaid} <a href="/settings/capacity">Capacity</a></p>`;
@@ -343,22 +347,25 @@ export async function systemPage(ctx: Ctx): Promise<Response> {
343347 const rendering = performance.now();
344348 const r = regions(v);
345349 const at = (id: string): Html => raw(r[id]!);
350 const help = disclosure("? How to read this", html`<div class="prose break" id="sys-help">
351 <p>The machine now, updated in place about every 2 s over one event stream. Its history is on <a href="/boards">Boards</a> (the machine source).</p>
352 ${caps}${processCaps}${selfHelp(self)}
353 <p>Stalls are main-thread pauses past 200 ms, as of this page's load; <code>/metrics</code> has the series.</p>
354 <p>Cache counts are since this process started. What each cache holds:</p>
355 <ul>${pageCaches().map((c) => html`<li><strong>${c.name}</strong>: ${c.holds}</li>`)}</ul>
356 <p>The forge samples its memory every minute, so the first hour of its chart appears shortly after a start.</p>
357 </div>`, { id: "sys-more" });
346358 const body = html`<div class="page-head">
347359 <h1>System</h1>
348 <p class="muted">
349 The machine now, updated in place about every 2 s over one event
350 stream; <a href="/system?pause">pause</a>. Its history is on
351 <a href="/boards">Boards</a> (the machine source); the pipeline is on
352 <a href="/boards">Boards</a>.
353 </p>
360 <span class="spacer"></span><a href="/system?pause">pause</a>
354361 </div>
362 ${help}
355363 <div id="sys-dup">${at("sys-dup")}</div>
356364 <section class="settings-block"><h2>Machine</h2>
357365 <div id="summary" class="row-meta">${at("summary")}</div>
358366 <ul class="list" id="cores" aria-label="Per-core CPU, percent, with its last readings">${at("cores")}</ul>
359367 <ul class="list" id="mem">${at("mem")}</ul>
360 ${caps}
361 ${processCaps}
368 ${capWords}
362369 ${egressLine}
363370 <p class="small mono" id="temps">${at("temps")}</p></section>
364371 ${selfSection(self)}
test/first-sight-notes.test.ts added +99
@@ -0,0 +1,99 @@
1 /**
2 * /system and /map answer at first sight (#11111): the standing explanatory notes became a word or
3 * a glyph beside the figure they explain, or moved behind one `?` (a closed `<details>`), the cut
4 * /agents got in #11078. A note is any element, whatever its tag, that carries a note class token
5 * (muted, small, note, hint, why) and says more than four words; the count is of what the page
6 * renders outside its disclosures, and each page's ceiling is half of what main rendered.
7 */
8 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9 import { mkdtemp, rm } from "node:fs/promises";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { createApp, type App } from "../src/app.ts";
13 import { openIssue } from "../src/actions/issues.ts";
14 import { createRepo } from "../src/actions/repos.ts";
15 import { createToken } from "../src/core/tokens.ts";
16 import { createUser } from "../src/core/users.ts";
17 import { serve, type Servers } from "../src/http/server.ts";
18 import { quietLogs } from "./helpers/globals.ts";
19
20 quietLogs();
21
22 const NOTE = ["muted", "small", "note", "hint", "why"];
23
24 /** The note elements of a page outside its closed disclosures: any tag, a note class, over four words. */
25 function notesOf(page: string): string[] {
26 const main = page.slice(page.indexOf(`<div class="page-head`)).replace(/<details\b[\s\S]*?<\/details>/g, "");
27 const out: string[] = [];
28 for (const m of main.matchAll(/(?=<([a-z0-9]+)\b[^>]*\bclass="([^"]*)"[^>]*>([\s\S]*?)<\/\1>)/g)) {
29 const classes = m[2]!.split(/\s+/);
30 // Data is not a note: a monospace reading, or the label of the buttons it sits beside.
31 if (!classes.some((c) => NOTE.includes(c)) || classes.includes("mono") || m[3]!.includes("<button")) continue;
32 const words = m[3]!.replace(/<[^>]*>/g, " ").split(/\s+/).filter(Boolean);
33 if (words.length > 4) out.push(words.join(" "));
34 }
35 return out;
36 }
37
38 /** What main rendered for the same fixtures, counted before the cut. */
39 const MAIN_SYSTEM = 12;
40 const MAIN_MAP = 2;
41
42 /** Every explanation the cut moved is still on the page, closed behind one `?` each. */
43 const HELP = { "/system": "sys-more", "/alice/plan/map": "map-more" } as const;
44
45 let dir: string;
46 let app: App;
47 let servers: Servers;
48 let token: string;
49
50 beforeAll(async () => {
51 dir = await mkdtemp(join(tmpdir(), "forge-first-sight-"));
52 app = createApp({ dataDir: dir, port: 0 });
53 servers = await serve(app);
54 const u = await createUser(app.db, {
55 origin: "seed", trusted: true, username: "alice", email: "[email protected]",
56 password: "a-good-passphrase", adminIfFirst: true,
57 });
58 if (!u.ok) throw new Error(u.error);
59 token = createToken(app.db, u.value.id, "t", "admin").token;
60 const made = await createRepo(app, u.value, { name: "plan", visibility: "public" });
61 if (!made.ok) throw new Error(String(made.error));
62 if (!openIssue(app, u.value, made.value, { title: "Alpha work" }).ok) throw new Error("issue");
63 }, 30_000);
64 afterAll(async () => {
65 await servers.stop();
66 await rm(dir, { recursive: true, force: true });
67 });
68
69 const get = async (path: string): Promise<string> =>
70 (await fetch(servers.url + path, { headers: { authorization: `Bearer ${token}` } })).text();
71
72 describe("first sight", () => {
73 test("each page keeps its how-to behind one `?` disclosure, and the action links beside it", async () => {
74 for (const [path, id] of Object.entries(HELP)) {
75 const page = await get(path);
76 expect(page).toContain(`<details id="${id}" class="disclosure">`);
77 expect(page).toMatch(/<summary>\? How to read this<\/summary>/);
78 }
79 const system = await get("/system");
80 expect(system).toMatch(/id="sys-more"[\s\S]*id="cell-caps" data-by="(kernel|watch)"/);
81 // A control keeps its word beside it: pause is in the head, not behind the `?`.
82 expect(system.slice(system.indexOf('class="page-head"'), system.indexOf('id="sys-more"'))).toContain('href="/system?pause"');
83 expect(system).toMatch(/id="cap-words"[^>]*>\s*<span class="pill"[^>]*>cell memory: (kernel-capped|watched)/);
84 // What each cache holds is text inside the `?`, not a hover-only title.
85 expect(system.slice(system.indexOf('id="sys-more"'))).toMatch(/<li><strong>kept pages<\/strong>: a stranger&#39;s/);
86 expect(system).not.toContain('title="a stranger');
87 const map = await get("/alice/plan/map");
88 expect(map.slice(map.indexOf('id="map-more"'), map.indexOf('id="map-live"'))).not.toContain("/milestones");
89 expect(map.slice(map.indexOf('class="page-head'), map.indexOf('id="map-more"'))).toContain("/alice/plan/next");
90 });
91 test("/system renders at most half the notes main did", async () => {
92 const notes = notesOf(await get("/system"));
93 expect(notes.length).toBeLessThanOrEqual(Math.floor(MAIN_SYSTEM / 2));
outdated

Stray whitespace-only line; remove it. Also notesOf need not be exported.

94 });
95 test("/map renders at most half the notes main did", async () => {
96 const notes = notesOf(await get("/alice/plan/map"));
97 expect(notes.length).toBeLessThanOrEqual(Math.floor(MAIN_MAP / 2));
98 });
99 });
test/spawn-self-use.test.ts +5 −3
@@ -25,7 +25,7 @@ import {
2525 type SelfSource,
2626 } from "../src/spawn/usage.ts";
2727 import { childLines, selfLines } from "../src/tui/screens/system.ts";
28 import { childrenSection, selfSection } from "../src/web/pages/system.ts";
28 import { childrenSection, selfHelp, selfSection } from "../src/web/pages/system.ts";
2929 import { quietLogs } from "./helpers/globals.ts";
3030 import { settleApp } from "./helpers/tui.ts";
3131 import { createForgeApp, forgeCommands } from "../src/tui/forge.ts";
@@ -117,7 +117,9 @@ describe("selfUse", () => {
117117 expect(page).toContain("1.5 of 2 cores");
118118 expect(page).toContain("512 MiB of 10 GiB");
119119 expect(page).toContain("This forge's size: medium</h2>");
120 expect(page).toContain('<p class="small muted">Set by the forge that made this one. Disk is measured every 30 seconds.</p>');
120 // The note moved behind the page's `?` (#11111): the section is the figures alone.
121 expect(page).not.toContain("Disk is measured");
122 expect(selfHelp(second)!.text).toContain("Size is set by the forge that made this one. Disk is measured every 30 seconds.");
121123 expect(page).not.toContain("memory.current");
122124 });
123125
@@ -128,7 +130,7 @@ describe("selfUse", () => {
128130 // 2 s of CPU over 4 s = 0.5 cores.
129131 expect(s.use.cores).toBe(0.5);
130132 expect(selfLines(s)[1]).toBe(" memory 300 MiB of 2 GiB (this process alone)");
131 expect(selfSection(s).text).toContain("Memory and CPU count this process alone.");
133 expect(selfHelp(s)!.text).toContain("Memory and CPU count this process alone.");
132134 });
133135 });
134136
test/system-memory.test.ts +1 −2
@@ -122,7 +122,6 @@ describe("code page caches (#9514)", () => {
122122 expect(caps).toBe(128 * MiB);
123123 const html = String(cachesSection([{ name: "kept pages", holds: "<b>", capBytes: 64 * MiB, entries: 3, bytes: 2 * MiB, hits: 3, misses: 1 }]));
124124 expect(html).toContain('data-cache="kept pages"');
125 expect(html).toContain("&lt;b&gt;");
126125 expect(html).toContain("2.0 MiB");
127126 expect(html).toContain("64.0 MiB");
128127 expect(html).toContain("75.0%");
@@ -130,7 +129,7 @@ describe("code page caches (#9514)", () => {
130129
131130 test("the owner's /system shows them", async () => {
132131 const body = await (await get("alice")).text();
133 expect(body).toContain("<h2>Code page caches</h2>");
132 expect(body).toContain("<h2>Code page caches, since start</h2>");
134133 for (const name of ["kept pages", "encoded bodies", "page columns", "folders and refs"]) expect(body).toContain(`data-cache="${name}"`);
135134 });
136135 });
test/system-parts.test.ts +3 −3
@@ -54,7 +54,7 @@ test("one <h2> per part, each in a settings-block, and no card", async () => {
5454 "Processes",
5555 "Main-thread stalls, last hour",
5656 "Telemetry readers",
57 "Code page caches",
57 "Code page caches, since start",
5858 "Forge memory",
5959 ]);
6060 expect(main.match(/<section class="settings-block"/g)?.length).toBe(heads.length);
@@ -95,8 +95,8 @@ test("no stalls says so rather than drawing a header over nothing", () => {
9595 expect(stallSection([{ route: "/x", ms: 300, count: 1 }]).text).toContain("<table");
9696 });
9797
98 test("the caches section keeps one short sentence", () => {
99 expect(cachesSection([]).text.match(/<p /g)?.length).toBe(1);
98 test("the caches section keeps no note: the counts' span is in its heading, what each holds is in the `?`", () => {
99 expect(cachesSection([]).text).not.toContain("<p ");
100100 });
101101
102102 test("no memory samples yet says so", () => {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs