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

/system reads like btop: per-core history, memory, disk, network and a process table grouped by forge, check, crew and app #2786

Merged opened this

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

Merged

scout/forge:scout/2721-system-reads-like-btop-per-core-history → main 2 commits fork

  • Merged. scout/2721-system-reads-like-btop-per-core-history was merged into main as ec36e28.

Review

Sign in to review this change.
13 files changed +328 −50
README.md +2 −2
@@ -974,8 +974,8 @@ Where the whole forge waits is a forge admin's: `/flow` shows each stage from re
974974
975975 The terminal also has an Access command (`:` → Access), a site admin's: it reads the same last-day non-browser requests as Settings → Access and the `access` MCP tool, newest first, and filters them with `s` (since), `u` (user agent) and `p` (path), `c` to clear.
976976
977 `/system` is the machine, for the owner (#1789): per-core CPU, hwmon temperatures, load, memory, disk per mount, network, and every process grouped by what the forge launched it for (`check:<run>`, `app:<slug>/<env>`, the forge, the host) with CPU, memory and age. It reads `/proc` and `/sys` and updates in place over one event stream, `/system/stream` (`?pause` holds it still; no reload, no polling); the 2 s push timer runs only while someone is subscribed, so it costs nothing unwatched. Its history is the `machine` board source. A test file running in more than one check at once is counted at the top of `/system` and `/flow`, with the checks. Command lines are never kept — only `*.test.ts` arguments.
978 In the terminal, `:` → System shows the same view (the owner's only), and `:` → Telemetry lists the agents and flips `telemetry:read` for the one you press Enter on.
977 `/system` is the machine, for the owner (#1789), laid out like btop (#2721): a meter and a 30-reading block-character history per core, memory and swap meters, load, hwmon temperatures, disk use per mount, disk and network rates (`/proc/diskstats`, `/proc/net/dev`), and a table of every process grouped forge / check (`check:<run>`) / app (`app:<slug>/<env>`) / host with CPU, memory and age, sortable by CPU (`c`) or memory (`m`) or the buttons (shown only with a script; without one the order is by CPU). It reads `/proc` and `/sys` and updates in place over one event stream, `/system/stream` (`?pause` holds it still; no reload, no polling); the 2 s push timer runs only while someone is subscribed, so it costs nothing unwatched. Its history is the `machine` board source. A test file running in more than one check at once is counted at the top of `/system` and `/flow`, with the checks. Command lines are never kept — only `*.test.ts` arguments.
978 In the terminal, `:` → System shows the same view (the owner's only): per-core CPU with a block-character history, memory, swap, disk and network rates, and each group's top two processes (no sort; the dialog is read-only), and `:` → Telemetry lists the agents and flips `telemetry:read` for the one you press Enter on.
979979 An agent the owner grants `telemetry:read` (Settings → Telemetry, one switch per agent, revoked the same way) reads the same through `GET /api/v1/telemetry/{system,flow,sources,history}` and the MCP `flow` and `system` tools; private repositories' checks and apps are renamed `check:private-N` and lose their processes and test files, and `flow` (like `system`, an admin agent account needs the grant too: only a person who is an admin reads without it) names only public repositories' conflicted pulls, every read is listed on that page, and an agent without the grant is refused. `/contribute`, `GET /api/v1/contribute` and the MCP `contribute` tool are open to anyone: what the forge is, the bottleneck now, open issues labelled “help wanted” in public repositories, and how to propose (fork, pull, the same gate). The grant is a table, not a token scope, so no token has to be re-issued.
980980
981981 `docs/PLANNING.md` is how issues depend on issues: the edge, the map, and
docs/DESIGN.md +1 −1

Diff not shown — a line 2,427 characters wide (limit 2,000). +1 −1

View file
src/system/format.ts +5
@@ -10,5 +10,10 @@ export const bytes = (n: number): string => {
1010 return `${v >= 10 || i === 0 ? Math.round(v) : v.toFixed(1)} ${units[i]}`;
1111 };
1212
13 const BLOCKS = "▁▂▃▄▅▆▇█";
14 /** A history as block characters, one per reading, each 0–100. */
15 export const spark = (history: readonly number[]): string =>
16 history.map((n) => BLOCKS[Math.min(7, Math.max(0, Math.floor((n / 100) * 8)))]).join("");
17
1318 export const span = (s: number): string =>
1419 s < 90 ? `${s}s` : s < 5400 ? `${Math.round(s / 60)}m` : s < 172_800 ? `${Math.round(s / 3600)}h` : `${Math.round(s / 86_400)}d`;
src/system/live.ts +7 −1
@@ -12,6 +12,9 @@ import { buildSystem, forAgent, type Roots, type SystemView } from "./view.ts";
1212
1313 const FRESH_MS = 1_000;
1414
15 /** Readings of each core kept for the history on /system. */
16 export const HISTORY = 30;
17
1518 /** How often a watched reading is pushed: only while someone is subscribed. */
1619 export const PUSH_MS = 2_000;
1720
@@ -32,6 +35,7 @@ export function createLive(options: { paths?: Paths; roots?: () => Roots; self?:
3235 let prev: Raw | undefined;
3336 let last: Raw | undefined;
3437 let view: SystemView | undefined;
38 let history: number[][] = [];
3539 const listeners = new Set<(view: SystemView) => void>();
3640 let timer: ReturnType<typeof setInterval> | undefined;
3741 const self_: Live = {
@@ -65,7 +69,9 @@ export function createLive(options: { paths?: Paths; roots?: () => Roots; self?:
6569 // A first reading has no rate: its rates are null until a second reading is taken.
6670 prev = last;
6771 last = raw;
68 view = buildSystem(prev, last, roots(), self);
72 const built = buildSystem(prev, last, roots(), self);
73 history = built.cpu.cores.map((c, i) => [...(history[i] ?? []), c ?? 0].slice(-HISTORY));
74 view = { ...built, cpu: { ...built.cpu, history } };
6975 return view;
7076 },
7177 };
src/system/proc.ts +21 −1
@@ -6,7 +6,7 @@
66 * series empty, never a guess. Paths are a parameter so a test can hand it a
77 * directory it wrote.
88 */
9 import { readdirSync, readFileSync, statfsSync } from "node:fs";
9 import { existsSync, readdirSync, readFileSync, statfsSync } from "node:fs";
1010 import { join } from "node:path";
1111
1212 export type Paths = { proc: string; sys: string };
@@ -22,6 +22,8 @@ export type Temp = { chip: string; label: string; celsius: number };
2222 export type Memory = { total: number; available: number; swapTotal: number; swapFree: number };
2323 export type Disk = { mount: string; fs: string; total: number; used: number };
2424 export type Nic = { name: string; rx: number; tx: number };
25 /** Bytes read and written since boot by one whole block device. */
26 export type Io = { name: string; read: number; write: number };
2527 export type Proc = {
2628 pid: number;
2729 ppid: number;
@@ -42,6 +44,7 @@ export type Raw = {
4244 memory: Memory | null;
4345 disks: Disk[];
4446 net: Nic[];
47 io: Io[];
4548 procs: Proc[];
4649 };
4750
@@ -98,6 +101,22 @@ export function parseNet(text: string | null): Nic[] {
98101 return out;
99102 }
100103
104 const SECTOR = 512;
105 const DEVICES_MAX = 16;
106
107 /** The whole devices of /proc/diskstats (`whole` says which names are not partitions or loop/ram devices). */
108 export function parseDiskstats(text: string | null, whole: (name: string) => boolean): Io[] {
109 const out: Io[] = [];
110 for (const line of (text ?? "").split("\n")) {
111 const f = line.trim().split(/\s+/);
112 const name = f[2];
113 if (!name || !/^[\w.-]{1,32}$/.test(name) || /^(loop|ram|zram|dm-)/.test(name) || !whole(name)) continue;
114 out.push({ name, read: num(f[5]) * SECTOR, write: num(f[9]) * SECTOR });
115 if (out.length >= DEVICES_MAX) break;
116 }
117 return out;
118 }
119
101120 /** The `stat` line of one process; null when it is not one. */
102121 export function parseProcStat(text: string): { comm: string; ppid: number; ticks: number; rss: number; start: number } | null {
103122 const open = text.indexOf("(");
@@ -210,6 +229,7 @@ export function readRaw(paths: Paths, at: number): Raw {
210229 memory: parseMemory(read(join(paths.proc, "meminfo"))),
211230 disks: readDisks(paths.proc),
212231 net: parseNet(read(join(paths.proc, "net/dev"))),
232 io: parseDiskstats(read(join(paths.proc, "diskstats")), (n) => existsSync(join(paths.sys, "block", n))),
213233 procs: readProcs(paths.proc),
214234 };
215235 }
src/system/view.ts +18 −3
@@ -29,18 +29,27 @@ export type SystemView = {
2929 at: string;
3030 /** Seconds between the two readings the rates are over; 0 when there was only one. */
3131 seconds: number;
32 cpu: { busyPct: number | null; cores: (number | null)[] };
32 cpu: {
33 busyPct: number | null;
34 cores: (number | null)[];
35 /** Each core's last readings, percent, oldest first (kept by `live.ts`; empty from `buildSystem`). */
36 history: number[][];
37 };
3338 temps: { chip: string; label: string; celsius: number }[];
3439 hottestC: number | null;
3540 load: [number, number, number] | null;
36 memory: { totalBytes: number; usedBytes: number; swapUsedBytes: number } | null;
41 memory: { totalBytes: number; usedBytes: number; swapUsedBytes: number; swapTotalBytes: number } | null;
3742 disks: { mount: string; fs: string; totalBytes: number; usedBytes: number }[];
3843 net: { name: string; rxBytesPerSecond: number | null; txBytesPerSecond: number | null }[];
44 io: { name: string; readBytesPerSecond: number | null; writeBytesPerSecond: number | null }[];
3945 processes: number;
4046 groups: Group[];
4147 duplicates: Duplicate[];
4248 };
4349
50 /** Processes kept per group. */
51 const TOP = 8;
52
4453 export type Roots = readonly { label: string; root: number; cap?: { bytes: number; by: "kernel" | "watch" } }[];
4554
4655 const kindOf = (label: string): Group["kind"] =>
@@ -125,7 +134,7 @@ export function buildSystem(prev: Raw | undefined, next: Raw, roots: Roots, self
125134 for (const g of groups.values()) {
126135 g.top = g.top
127136 .sort((a, b) => (b.cpuCores ?? 0) - (a.cpuCores ?? 0) || b.rssBytes - a.rssBytes)
128 .slice(0, 5);
137 .slice(0, TOP);
129138 if (g.cpuCores !== null) g.cpuCores = Math.round(g.cpuCores * 100) / 100;
130139 }
131140 const mem = next.memory;
@@ -135,6 +144,7 @@ export function buildSystem(prev: Raw | undefined, next: Raw, roots: Roots, self
135144 cpu: {
136145 busyPct: known.length === 0 ? null : Math.round((known.reduce((a, b) => a + b, 0) / known.length) * 10) / 10,
137146 cores,
147 history: [],
138148 },
139149 temps: next.temps,
140150 hottestC: next.temps.length === 0 ? null : Math.max(...next.temps.map((t) => t.celsius)),
@@ -143,6 +153,7 @@ export function buildSystem(prev: Raw | undefined, next: Raw, roots: Roots, self
143153 totalBytes: mem.total,
144154 usedBytes: mem.total - mem.available,
145155 swapUsedBytes: mem.swapTotal - mem.swapFree,
156 swapTotalBytes: mem.swapTotal,
146157 },
147158 disks: next.disks.map((d) => ({ mount: d.mount, fs: d.fs, totalBytes: d.total, usedBytes: d.used })),
148159 net: next.net.map((n) => {
@@ -153,6 +164,10 @@ export function buildSystem(prev: Raw | undefined, next: Raw, roots: Roots, self
153164 txBytesPerSecond: rate(o?.tx, n.tx, seconds),
154165 };
155166 }),
167 io: next.io.map((d) => {
168 const o = prev?.io.find((x) => x.name === d.name);
169 return { name: d.name, readBytesPerSecond: rate(o?.read, d.read, seconds), writeBytesPerSecond: rate(o?.write, d.write, seconds) };
170 }),
156171 processes: next.procs.length,
157172 groups: [...groups.values()].sort((a, b) => (b.cpuCores ?? 0) - (a.cpuCores ?? 0) || b.rssBytes - a.rssBytes),
158173 duplicates: [...files]
src/tui/screens/system.ts +20 −4
@@ -1,7 +1,8 @@
11 /**
22 * The System command's dialog, the owner's (#1789): the machine now, in the
3 * lines of `/system` — CPU, load, heat, memory, disks, and the heaviest
4 * groups of processes with the test files running in more than one check.
3 * lines of `/system` — per-core CPU with its history, load, heat,
4 * memory, swap, disks, disk and network rates, and the heaviest groups of
5 * processes with their top two with the test files running in more than one check.
56 * It reads `live().read()`, the same view the page and the API serve.
67 * Read-only; Enter, Esc or q closes it.
78 *
@@ -16,7 +17,7 @@ import type { SystemView } from "../../system/view.ts";
1617 import { canSeeMemory, memoryHours } from "../../boards/memory.ts";
1718 import { binding } from "../app/keys.ts";
1819 import type { Session } from "../session.ts";
19 import { bytes } from "../../system/format.ts";
20 import { bytes, spark } from "../../system/format.ts";
2021 import { style } from "../term/style.ts";
2122 import { pad, wrap } from "../term/width.ts";
2223 import { dialog } from "../ui/widgets.ts";
@@ -24,6 +25,10 @@ import { Scroll, drawLines, hitScroll } from "./common.ts";
2425
2526 const WIDTH = 72;
2627 const GROUPS = 6;
28 const CORES = 8;
29 const PROCS = 2;
30
31 const rate = (n: number | null): string => (n === null ? "—" : `${bytes(n)}/s`);
2732
2833 const pct = (n: number | null): string => (n === null ? "—" : `${n.toFixed(1)}%`);
2934
@@ -46,12 +51,23 @@ const capOf = (g: SystemView["groups"][number]): string =>
4651
4752 export function systemLines(v: SystemView): string[] {
4853 const inner = WIDTH - 6;
54 const cores = v.cpu.cores.slice(0, CORES).map(
55 (c, i) => `${pad(`cpu${i}`, 6)} ${pad(c === null ? "—" : `${Math.round(c)}%`, 4, "right")} ${spark(v.cpu.history[i] ?? [])}`,
56 );
4957 return [
5058 `CPU ${pct(v.cpu.busyPct)} over ${v.cpu.cores.length} cores · load ${v.load ? v.load.map((l) => l.toFixed(2)).join(" ") : "—"}`,
59 ...cores,
60 ...(v.cpu.cores.length > CORES ? [`… and ${v.cpu.cores.length - CORES} more cores`] : []),
5161 `hottest ${v.hottestC === null ? "—" : `${v.hottestC.toFixed(0)}°C`} · memory ${v.memory ? `${bytes(v.memory.usedBytes)} of ${bytes(v.memory.totalBytes)}` : "—"} · ${v.processes} processes`,
62 `swap ${v.memory ? (v.memory.swapTotalBytes === 0 ? "none" : `${bytes(v.memory.swapUsedBytes)} of ${bytes(v.memory.swapTotalBytes)}`) : "—"}`,
5263 ...v.disks.map((d) => `${pad(d.mount, 20)} ${Math.round((d.usedBytes / Math.max(1, d.totalBytes)) * 100)}% of ${bytes(d.totalBytes)}`),
64 ...v.io.map((d) => `${pad(d.name, 20)} R ${rate(d.readBytesPerSecond)} W ${rate(d.writeBytesPerSecond)}`),
65 ...v.net.map((n) => `${pad(n.name, 20)} ↓ ${rate(n.rxBytesPerSecond)} ↑ ${rate(n.txBytesPerSecond)}`),
5366 "",
54 ...v.groups.slice(0, GROUPS).map((g) => `${pad(g.label, 24)} ${pad(String(g.procs), 4, "right")} procs ${capOf(g)}`.trimEnd()),
67 ...v.groups.slice(0, GROUPS).flatMap((g) => [
68 `${pad(g.label, 24)} ${pad(String(g.procs), 4, "right")} procs ${capOf(g)}`.trimEnd(),
69 ...g.top.slice(0, PROCS).map((p) => ` ${pad(p.comm, 16)} ${pad(p.cpuCores === null ? "—" : p.cpuCores.toFixed(2), 5, "right")} cores ${bytes(p.rssBytes)}`),
70 ]),
5571 "",
5672 ...duplicateLines(v.duplicates, inner),
5773 ];
src/web/pages/system.ts +47 −33
@@ -15,7 +15,7 @@ import { canSeeMemory, memoryHours } from "../../boards/memory.ts";
1515 import { telemetryReader } from "../../actions/telemetry.ts";
1616 import type { Ctx } from "../../http/context.ts";
1717 import { memoryCapHolder } from "../../apps/contain.ts";
18 import { bytes, span } from "../../system/format.ts";
18 import { bytes, spark, span } from "../../system/format.ts";
1919 import { eventStream } from "../../http/sse.ts";
2020 import type { SystemView } from "../../system/view.ts";
2121 import { live } from "../../system/live.ts";
@@ -23,7 +23,7 @@ import { notFoundPage, signInFirst } from "../chrome.ts";
2323 import { html, raw, type Html } from "../html.ts";
2424 import { page } from "../layout.ts";
2525 import { systemLiveJs } from "../system-live.ts";
26 import { card, empty, listCard, pill, row } from "../ui.ts";
26 import { card, empty, pill } from "../ui.ts";
2727
2828 /** A group's memory, and a cell's beside its cap and who holds the cap. */
2929 const memoryOf = (g: { rssBytes: number; capBytes: number | null; capBy: "kernel" | "watch" | null }): Html =>
@@ -34,6 +34,33 @@ const memoryOf = (g: { rssBytes: number; capBytes: number | null; capBy: "kernel
3434 const pct = (n: number | null): string => (n === null ? "—" : `${n.toFixed(1)}%`);
3535 const coresOf = (n: number | null): string => (n === null ? "—" : n.toFixed(2));
3636
37 const rateOf = (n: number | null): string => (n === null ? "—" : `${bytes(n)}/s`);
38
39 /** One line of the machine card: a name, a meter, its figure and (for a core) its history. */
40 const meterRow = (name: string, value: number | null, max: number, figure: string, history = ""): Html =>
41 html`<div class="bt-row"><span class="bt-name" title="${name}">${name}</span>
42 <meter class="bt-bar" aria-label="${name} ${figure}" min="0" max="${String(max)}" high="${String(max * 0.85)}" value="${String(value ?? 0)}">${figure}</meter>
43 <span class="bt-val">${figure}</span>${history ? html`<span class="bt-hist" aria-hidden="true">${history}</span>` : null}</div>`;
44
45 /** Processes grouped forge, check, app, host; the client re-sorts them by CPU or memory (`system-live.ts`). */
46 const KIND_ORDER = ["forge", "check", "app", "host"] as const;
47 function procTable(v: SystemView): Html {
48 const groups = KIND_ORDER.flatMap((k) => v.groups.filter((g) => g.kind === k));
49 return html`<div class="table-wrap"><table class="table bt-procs" aria-label="Processes by what launched them">
50 <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>
51 ${groups.map(
52 (g) => html`<tbody data-kind="${g.kind}" data-cpu="${String(g.cpuCores ?? 0)}" data-mem="${String(g.rssBytes)}">
53 <tr class="bt-group"><th scope="colgroup" colspan="2">${g.label} <span class="muted">${String(g.procs)} procs</span></th>
54 <td class="num">${coresOf(g.cpuCores)}</td><td class="num">${memoryOf(g)}</td><td class="num">${span(g.oldestSeconds)}</td></tr>
55 ${g.top.map(
56 (p) => html`<tr data-cpu="${String(p.cpuCores ?? 0)}" data-mem="${String(p.rssBytes)}"><td class="mono">${String(p.pid)}</td><td class="mono">${p.comm}</td>
57 <td class="num mono">${coresOf(p.cpuCores)}</td><td class="num mono">${bytes(p.rssBytes)}</td><td class="num mono">${span(p.ageSeconds)}</td></tr>`,
58 )}
59 </tbody>`,
60 )}
61 </table></div>`;
62 }
63
3764 const mib = (n: number) => `${(n / 1_048_576).toFixed(1)} MiB`;
3865 const hhmm = (at: number) => new Date(at).toISOString().slice(0, 16).replace("T", " ");
3966
@@ -71,9 +98,6 @@ export function regions(v: SystemView): Record<string, string> {
7198 <ul class="list small mono">
7299 ${v.duplicates.map((d) => html`<li>${d.file} · ${String(d.checks.length)} checks: ${d.checks.join(", ")}</li>`)}
73100 </ul>`;
74 const cores = v.cpu.cores.map(
75 (c, i) => html`<span class="mono small" title="cpu${String(i)}">${c === null ? "—" : String(Math.round(c))}</span> `,
76 );
77101 const out: Record<string, Html> = {
78102 "sys-dup": dup,
79103 summary: html`${pill(`CPU ${pct(v.cpu.busyPct)}`, v.cpu.busyPct !== null && v.cpu.busyPct >= 90 ? "danger" : undefined)}
@@ -82,36 +106,22 @@ export function regions(v: SystemView): Record<string, string> {
82106 ${pill(`hottest ${v.hottestC === null ? "—" : `${v.hottestC.toFixed(0)}°C`}`)}
83107 ${pill(`memory ${v.memory ? `${bytes(v.memory.usedBytes)} of ${bytes(v.memory.totalBytes)}` : "—"}`)}
84108 ${pill(`${String(v.processes)} processes`)}`,
85 cores: html`${cores}`,
109 cores: html`${v.cpu.cores.map((c, i) => meterRow(`cpu${String(i)}`, c, 100, c === null ? "—" : `${c.toFixed(0)}%`, spark(v.cpu.history[i] ?? [])))}`,
110 mem: html`${v.memory
111 ? html`${meterRow("mem", v.memory.usedBytes, v.memory.totalBytes, `${bytes(v.memory.usedBytes)} / ${bytes(v.memory.totalBytes)}`)}
112 ${meterRow("swap", v.memory.swapUsedBytes, v.memory.swapTotalBytes, v.memory.swapTotalBytes === 0 ? "none" : `${bytes(v.memory.swapUsedBytes)} / ${bytes(v.memory.swapTotalBytes)}`)}`
113 : null}
114 ${v.load ? html`<div class="bt-row"><span class="bt-name">load</span><span class="bt-val">${v.load.map((l) => l.toFixed(2)).join(" ")}</span></div>` : null}`,
86115 temps: html`${v.temps.map((t) => `${t.chip} ${t.label} ${t.celsius.toFixed(0)}°C`).join(" · ")}`,
87 "sys-disks": listCard(
88 [
89 ...v.disks.map((d) =>
90 row({
91 title: html`<span class="mono">${d.mount}</span>`,
92 meta: html`${d.fs}`,
93 trail: html`${pill(`${String(Math.round((d.usedBytes / d.totalBytes) * 100))}%`)} ${bytes(d.totalBytes)}`,
94 }),
95 ),
96 ...v.net.map((n) =>
97 row({
98 title: html`<span class="mono">${n.name}</span>`,
99 trail: html`↓ ${n.rxBytesPerSecond === null ? "—" : `${bytes(n.rxBytesPerSecond)}/s`} ↑ ${n.txBytesPerSecond === null ? "—" : `${bytes(n.txBytesPerSecond)}/s`}`,
100 }),
101 ),
102 ],
116 "sys-disks": card(
117 html`<div class="bt-grid">
118 ${v.disks.map((d) => meterRow(d.mount, d.usedBytes, d.totalBytes, `${bytes(d.usedBytes)} / ${bytes(d.totalBytes)} ${d.fs}`))}
119 ${v.io.map((d) => html`<div class="bt-row" data-io="${d.name}"><span class="bt-name">${d.name}</span><span class="bt-val">R ${rateOf(d.readBytesPerSecond)} W ${rateOf(d.writeBytesPerSecond)}</span></div>`)}
120 ${v.net.map((n) => html`<div class="bt-row" data-net="${n.name}"><span class="bt-name">${n.name}</span><span class="bt-val">↓ ${rateOf(n.rxBytesPerSecond)} ↑ ${rateOf(n.txBytesPerSecond)}</span></div>`)}
121 </div>`,
103122 { head: html`<h2>Disks and network</h2>` },
104123 ),
105 "sys-procs": listCard(
106 v.groups.map(
107 (g) => html`<li data-kind="${g.kind}"><div class="row">
108 <span class="row-main"><span class="row-title mono">${g.label}</span>
109 <span class="row-meta">${String(g.procs)} processes · oldest ${span(g.oldestSeconds)}${g.top.length ? html` · ${g.top.slice(0, 3).map((p) => `${p.comm} ${coresOf(p.cpuCores)}`).join(" · ")}` : null}</span></span>
110 <span class="row-trail">${pill(`${coresOf(g.cpuCores)} cores`)} ${memoryOf(g)}</span>
111 </div></li>`,
112 ),
113 { head: html`<h2>Processes</h2>` },
114 ),
124 "sys-procs": procTable(v),
115125 };
116126 return Object.fromEntries(Object.entries(out).map(([k, h]) => [k, h.text]));
117127 }
@@ -157,12 +167,16 @@ export function systemPage(ctx: Ctx): Response {
157167 <div id="sys-dup">${at("sys-dup")}</div>
158168 ${card(
159169 html`<div id="summary" class="row-meta">${at("summary")}</div>
160 <p class="small" id="cores" aria-label="Per-core CPU, percent">${at("cores")}</p>
170 <div class="bt-grid" id="cores" aria-label="Per-core CPU, percent, with its last readings">${at("cores")}</div>
171 <div class="bt-grid" id="mem">${at("mem")}</div>
161172 ${caps}
162173 <p class="small mono" id="temps">${at("temps")}</p>`,
163174 { head: html`<h2>Machine</h2>` },
164175 )}
165176 <div id="sys-disks">${at("sys-disks")}</div>
177 <div class="page-head"><h2>Processes</h2>
178 <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>
179 <button class="btn" data-size="sm" type="button" data-sort="mem" aria-pressed="false"><kbd>m</kbd> memory</button></span></p></div>
166180 <div id="sys-procs">${at("sys-procs")}</div>
167181 ${memorySection(ctx)}`;
168182 return page({ ctx, title: "System", body, wide: true, scriptJs: systemLiveJs });
src/web/style/content.ts +17
@@ -406,6 +406,23 @@ a.origin-chip:hover { border-color: var(--border-strong); color: var(--fg); }
406406 .bform .qf > span { font-size: 12.5px; font-weight: 600; color: var(--fg); }
407407 .bform .btn { flex: none; }
408408 .boards-result .table { margin-top: 12px; }
409 .bt-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 300px), 1fr)); gap: 2px 16px; font-family: var(--mono); font-size: 12.5px; margin-top: 8px; }
410 .bt-row { display: grid; grid-template-columns: 10ch minmax(40px, 1fr) auto auto; align-items: center; gap: 8px; min-width: 0; }
411 .bt-row > .bt-val:last-child { grid-column: span 2; }
412 .bt-name { color: var(--muted-fg); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
413 .bt-val { text-align: right; white-space: nowrap; }
414 .bt-hist { color: var(--accent); letter-spacing: -1px; overflow: hidden; max-width: 15ch; }
415 .bt-bar { width: 100%; height: 8px; appearance: none; background: var(--muted); border: 0; border-radius: var(--radius-sm); overflow: hidden; }
416 .bt-bar::-webkit-meter-bar { background: var(--muted); border: 0; height: 8px; }
417 .bt-bar::-webkit-meter-optimum-value, .bt-bar::-webkit-meter-suboptimum-value { background: var(--accent); }
418 .bt-bar::-webkit-meter-even-less-good-value { background: var(--danger); }
419 .bt-bar::-moz-meter-bar { background: var(--accent); }
420 .bt-bar:-moz-meter-sub-optimum::-moz-meter-bar { background: var(--accent); }
421 .bt-bar:-moz-meter-sub-sub-optimum::-moz-meter-bar { background: var(--danger); }
422 .table-wrap { overflow-x: auto; }
423 .bt-procs { font-size: 12.5px; }
424 .bt-procs th, .bt-procs td { padding: 2px 8px; white-space: nowrap; }
425 .bt-group th, .bt-group td { background: var(--muted); font-weight: 600; }
409426 .sys-pair { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 16px; margin-bottom: 16px; }
410427 .sys-pair .chart-box { max-width: none; }
411428 .bgrid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-bottom: 16px; }
src/web/system-live.ts +47 −1
@@ -1,12 +1,57 @@
11 /**
2 * /system's client: one EventSource, and each pushed sample patched into the
2 * /system's client: sorting the process table by CPU or memory (`c`, `m`, or the buttons) and one EventSource, each pushed sample patched into the
33 * element of the id it names. No reload, no timer, no polling; EventSource
44 * itself reconnects after a roll (the stream's `retry:` hint) and the first
55 * frame of the new stream is the latest sample. `?pause` opens nothing.
66 * The markup comes from our own server, escaped there, so it is set as HTML.
77 */
8 /**
9 * The ordering of the process table, apart from the page so a test can run it:
10 * `rank(items, key, kinds)` is a new array in kind order (as `kinds` lists
11 * them), the biggest `key` first within a kind; ties keep their place.
12 */
13 export const procSortJs = /* js */ `
14 function rank(items, key, kinds) {
15 return items
16 .map(function (item, i) { return { item: item, i: i }; })
17 .sort(function (a, b) {
18 return (kinds.indexOf(a.item.kind) - kinds.indexOf(b.item.kind)) || (b.item[key] - a.item[key]) || (a.i - b.i);
19 })
20 .map(function (x) { return x.item; });
21 }
22 `;
23
824 export const systemLiveJs = /* js */ `
925 (function () {
26 ${procSortJs}
27 var KINDS = ['forge', 'check', 'app', 'host'], key = 'cpu';
28 function read(el) {
29 return { el: el, kind: el.getAttribute('data-kind'), cpu: Number(el.getAttribute('data-cpu')) || 0, mem: Number(el.getAttribute('data-mem')) || 0 };
30 }
31 function sort() {
32 var table = document.querySelector('#sys-procs table');
33 if (!table) return;
34 var bodies = Array.prototype.slice.call(table.tBodies);
35 bodies.forEach(function (tb) {
36 var rows = Array.prototype.slice.call(tb.rows, 1).map(read);
37 rank(rows, key, []).forEach(function (r) { tb.appendChild(r.el); });
38 });
39 rank(bodies.map(read), key, KINDS).forEach(function (b) { table.appendChild(b.el); });
40 Array.prototype.forEach.call(document.querySelectorAll('[data-sort]'), function (b) {
41 b.setAttribute('aria-pressed', String(b.getAttribute('data-sort') === key));
42 });
43 }
44 function pick(k) { key = k; sort(); }
45 document.addEventListener('click', function (e) {
46 var b = e.target && e.target.closest && e.target.closest('[data-sort]');
47 if (b) pick(b.getAttribute('data-sort'));
48 });
49 document.addEventListener('keydown', function (e) {
50 var t = e.target, tag = t && t.tagName;
51 if (e.ctrlKey || e.metaKey || e.altKey || tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || (t && t.isContentEditable)) return;
52 if ((tag === 'BUTTON' || tag === 'A') && !(t.hasAttribute && t.hasAttribute('data-sort'))) return;
53 if (e.key === 'c') pick('cpu'); else if (e.key === 'm') pick('mem');
54 });
1055 if (typeof EventSource === 'undefined' || /[?&]pause\\b/.test(location.search)) return;
1156 var es = new EventSource('/system/stream');
1257 es.addEventListener('sample', function (e) {
@@ -18,6 +63,7 @@ export const systemLiveJs = /* js */ `
1863 var el = document.getElementById(id);
1964 if (el && el.innerHTML !== regions[id]) el.innerHTML = regions[id];
2065 }
66 sort();
2167 });
2268 })();
2369 `;
test/cgroup.test.ts +1
@@ -162,6 +162,7 @@ describe("/system: a check cell's memory beside its cap", () => {
162162 memory: null,
163163 disks: [],
164164 net: [],
165 io: [],
165166 procs: [proc(1, 0, 100), proc(200, 1, 1 << 20), proc(201, 200, 3 << 20)],
166167 };
167168 const v = buildSystem(undefined, raw, [
test/system-btop.test.ts added +138
@@ -0,0 +1,138 @@
1 /**
2 * /system reads like btop (#2721): per-core bars with a short history, memory,
3 * swap, disk and network rates, and a process table grouped forge / check /
4 * crew / app, all from one seeded sample.
5 */
6 import { describe, expect, test } from "bun:test";
7 import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createLive } from "../src/system/live.ts";
11 import { parseDiskstats, type Proc, type Raw } from "../src/system/proc.ts";
12 import { buildSystem } from "../src/system/view.ts";
13 import { regions } from "../src/web/pages/system.ts";
14 import { systemLines } from "../src/tui/screens/system.ts";
15 import { procSortJs, systemLiveJs } from "../src/web/system-live.ts";
16
17 const proc = (pid: number, ppid: number, comm: string, ticks: number, rss: number): Proc => ({ pid, ppid, comm, ticks, rss, age: 60, tests: [] });
18
19 const raw = (at: number, ticks: number): Raw => ({
20 at,
21 cores: [{ busy: ticks, total: ticks * 2 }, { busy: 0, total: ticks * 2 }],
22 temps: [{ chip: "coretemp", label: "Core 0", celsius: 61 }],
23 load: [1, 2, 3],
24 memory: { total: 8 * 2 ** 30, available: 2 * 2 ** 30, swapTotal: 2 ** 30, swapFree: 2 ** 29 },
25 disks: [{ mount: "/data", fs: "ext4", total: 100 * 2 ** 30, used: 40 * 2 ** 30 }],
26 net: [{ name: "eth0", rx: ticks * 1000, tx: ticks * 500 }],
27 io: [{ name: "nvme0n1", read: ticks * 4096, write: ticks * 8192 }],
28 procs: [
29 proc(1, 0, "forge", ticks, 50 << 20),
30 proc(200, 1, "bwrap", 0, 1 << 20),
31 proc(201, 200, "bun", ticks * 2, 300 << 20),
32 proc(300, 1, "bwrap", 0, 1 << 20),
33 proc(301, 300, "claude", ticks * 3, 200 << 20),
34 proc(400, 1, "bwrap", 0, 1 << 20),
35 proc(401, 400, "node", ticks, 100 << 20),
36 ],
37 });
38
39 const roots = [
40 { label: "check:RUN1", root: 200 },
41 { label: "app:other/production", root: 300 },
42 { label: "app:notes/production", root: 400 },
43 ];
44
45 describe("/system as btop", () => {
46 const view = buildSystem(raw(0, 100), raw(2000, 300), roots, 1);
47
48 test("each group — forge, check, app — appears, in that order, with its processes", () => {
49 const html = regions(view)["sys-procs"]!;
50 const kinds = [...html.matchAll(/<tbody data-kind="(\w+)"/g)].map((m) => m[1]);
51 expect(kinds).toEqual(["forge", "check", "app", "app"]);
52 for (const label of ["forge", "check:RUN1", "app:other/production", "app:notes/production"]) expect(html).toContain(label);
53 for (const comm of ["bun", "claude", "node"]) expect(html).toContain(`<td class="mono">${comm}</td>`);
54 expect(html).toMatch(/data-cpu="[\d.]+" data-mem="\d+"/);
55 });
56
57 test("cores, memory, swap, load, disk and network rates are drawn from the sample", () => {
58 const r = regions(view);
59 expect(r["cores"]).toContain("cpu0");
60 expect(r["cores"]).toContain("cpu1");
61 expect(r["cores"]).toContain("<meter");
62 expect(r["mem"]).toContain("6.0 GiB / 8.0 GiB");
63 expect(r["mem"]).toContain("512 MiB / 1.0 GiB");
64 expect(r["mem"]).toContain("1.00 2.00 3.00");
65 expect(r["temps"]).toContain("coretemp Core 0 61°C");
66 expect(r["sys-disks"]).toContain("/data");
67 expect(r["sys-disks"]).toContain("↓ 98 KiB/s ↑ 49 KiB/s");
68 expect(r["sys-disks"]).toContain("R 400 KiB/s W 800 KiB/s");
69 });
70
71 test("a core's history grows by one reading per sample and is capped", () => {
72 const dir = mkdtempSync(join(tmpdir(), "forge-btop-"));
73 try {
74 mkdirSync(join(dir, "proc"), { recursive: true });
75 const l = createLive({ paths: { proc: join(dir, "proc"), sys: join(dir, "sys") }, roots: () => [], self: 1 });
76 let last = l.read(1_000_000);
77 for (let i = 1; i <= 40; i++) {
78 writeFileSync(join(dir, "proc/stat"), `cpu0 ${i * 50} 0 0 ${i * 50} 0 0 0 0\n`);
79 last = l.read(1_000_000 + i * 2000);
80 }
81 expect(last.cpu.history[0]!.length).toBe(30);
82 expect(last.cpu.history[0]!.at(-1)).toBe(50);
83 expect(regions(last)["cores"]).toContain("▅".repeat(5));
84 } finally {
85 rmSync(dir, { recursive: true, force: true });
86 }
87 });
88
89 test("diskstats keep whole devices only", () => {
90 const text = " 259 0 nvme0n1 10 0 8 0 20 0 16 0\n 259 1 nvme0n1p1 1 0 2 0 1 0 2 0\n 7 0 loop0 1 0 2 0 0 0 0 0\n";
91 expect(parseDiskstats(text, (n) => n === "nvme0n1")).toEqual([{ name: "nvme0n1", read: 8 * 512, write: 16 * 512 }]);
92 });
93
94 test("a meter has an accessible name, and the sort buttons need a script", async () => {
95 const r = regions(view);
96 expect(r["cores"]).toContain('aria-label="cpu0 ');
97 expect(r["mem"]).toContain('aria-label="swap 512 MiB / 1.0 GiB"');
98 const page = await Bun.file("src/web/pages/system.ts").text();
99 expect(page).toMatch(/class="js-only">Sort by[\s\S]*data-sort="mem"/);
100 });
101
102 test("the sort puts kinds in order and the biggest first by CPU or memory, ties staying put", () => {
103 const rank = new Function(`${procSortJs}; return rank;`)() as (
104 items: { kind?: string; cpu: number; mem: number; id: string }[],
105 key: "cpu" | "mem",
106 kinds: string[],
107 ) => { id: string }[];
108 const items = [
109 { id: "app-small", kind: "app", cpu: 1, mem: 9 },
110 { id: "check-a", kind: "check", cpu: 2, mem: 1 },
111 { id: "forge", kind: "forge", cpu: 0, mem: 5 },
112 { id: "check-b", kind: "check", cpu: 5, mem: 3 },
113 { id: "check-c", kind: "check", cpu: 5, mem: 2 },
114 ];
115 const kinds = ["forge", "check", "app", "host"];
116 expect(rank(items, "cpu", kinds).map((i) => i.id)).toEqual(["forge", "check-b", "check-c", "check-a", "app-small"]);
117 expect(rank(items, "mem", kinds).map((i) => i.id)).toEqual(["forge", "check-b", "check-c", "check-a", "app-small"]);
118 expect(rank(items.filter((i) => i.kind === "check"), "mem", []).map((i) => i.id)).toEqual(["check-b", "check-c", "check-a"]);
119 expect(rank(items, "cpu", kinds).length).toBe(5);
120 expect(items[0]!.id).toBe("app-small");
121 });
122
123 test("hotkeys are ignored on a focused button or link, except the sort buttons", () => {
124 expect(systemLiveJs).toContain("(tag === 'BUTTON' || tag === 'A') && !(t.hasAttribute && t.hasAttribute('data-sort'))");
125 });
126
127 test("the terminal's System view has the cores with history, swap, rates and the top processes", () => {
128 const lines = systemLines(JSON.parse(JSON.stringify({ ...view, cpu: { ...view.cpu, history: [[0, 50, 100], [10]] } })));
129 const text = lines.join("\n");
130 expect(text).toContain("cpu0");
131 expect(text).toContain("▁▅█");
132 expect(text).toContain("swap 512 MiB of 1.0 GiB");
133 expect(text).toContain("R 400 KiB/s W 800 KiB/s");
134 expect(text).toContain("↓ 98 KiB/s ↑ 49 KiB/s");
135 expect(text).toMatch(/bun\s+\d+\.\d+ cores/);
136 expect(text).toContain("check:RUN1");
137 });
138 });
test/system-telemetry.test.ts +4 −4
@@ -100,7 +100,7 @@ describe("the machine, read from /proc and /sys", () => {
100100 expect(v.cpu.cores.length).toBe(2);
101101 expect(v.hottestC).toBe(76);
102102 expect(v.load?.[0]).toBe(64);
103 expect(v.memory).toEqual({ totalBytes: 1024_000, usedBytes: 614_400, swapUsedBytes: 0 });
103 expect(v.memory).toEqual({ totalBytes: 1024_000, usedBytes: 614_400, swapUsedBytes: 0, swapTotalBytes: 0 });
104104 expect(JSON.stringify(v)).not.toContain("hunter2");
105105 // A later reading gives rates over the two latest.
106106 fakeHost(root, 300);
@@ -303,10 +303,10 @@ describe("telemetry:read: the owner grants it, an agent reads with it, nobody el
303303 // Each check cell's memory beside its cap, and who holds the cap (#2410).
304304 expect(html).toContain('data-cap="8589934592" data-cap-by="kernel">of 8.0 GiB (kernel cap)');
305305 expect(html).toMatch(/id="cell-caps" data-by="(kernel|watch)"/);
306 // Built from the system's own parts: pills and rows, no table, no style attribute (#2691).
306 // Built from the system's own parts: pills, meters and the process table grouped by kind (#2721), no style attribute (#2691).
307307 expect(html).toMatch(/id="summary"[^>]*>\s*<span class="pill"/);
308 expect(html).toContain('<li data-kind="check"><div class="row">');
309 expect(html).not.toContain("<table");
308 expect(html).toContain('<tbody data-kind="check"');
309 expect(html).not.toMatch(/<[a-z]+ [^>]*\bstyle="/);
310310 const flowPage = await fetch(`${servers.url}/flow`, { headers: { cookie: await login("alice") } });
311311 expect(await flowPage.text()).toContain('id="duplicate-work" data-count="1"');
312312

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs