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

The terminal's System screen draws the machine's CPU, memory and load over the last 6 hours as one sparkline each, as MCP `telemetry_history` reads them #7343

Merged opened this

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

Merged

scout/forge:scout/7321-the-terminal-s-system-screen-draws-the-m → main 5 commits fork 3 threads · 3 unresolved

  • Merged. scout/7321-the-terminal-s-system-screen-draws-the-m was merged into main as b777405.

Review

Sign in to review this change.
3 files changed +112 −10
docs/CREW.md +1 −1
@@ -340,7 +340,7 @@ Where the whole forge waits is the owner's, on `/boards` (the old `/flow` redire
340340 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.
341341
342342 `/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 the `/boards` flow panel, with the checks. Command lines are never kept — only `*.test.ts` arguments.
343 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.
343 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), below them three sparklines (CPU, memory, load) of the node over the last 6 hours, each with its latest value and peak, read through `historyOver` like the MCP `telemetry_history` tool; `h` cycles the window 1 h / 6 h / 24 h / 72 h, a window with nothing recorded says `no readings yet`, and anyone that tool refuses sees no line. `:` → Telemetry lists the agents and flips `telemetry:read` for the one you press Enter on.
344344 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.
345345
346346 **Raw files and caches.** `GET /:owner/:name/raw/:ref/*path` at a full 40-hex commit id is `immutable` for a year: `public, max-age=31536000, immutable` for a public repository, `private, max-age=31536000, immutable` for any other, so a shared cache never keeps private bytes. At a branch or tag it is `private, max-age=0` and revalidates. Every raw answer has the blob's oid as its `ETag`, and a matching `If-None-Match` gets a 304 with the same `Cache-Control`, `nosniff` and sandbox CSP. The HTML blob page is not cached this way (it carries the viewer and a nonce). A public repository that later turns private keeps its year-long `public` copies in shared caches already holding them. See docs/CACHING.md.
src/tui/screens/system.ts +56 −6
@@ -22,6 +22,8 @@ import type { SystemView } from "../../system/view.ts";
2222 import type { readHealth } from "../../actions/health.ts";
2323 import { rolloutLine, type ForgeHealth } from "../../kernel/guard.ts";
2424 import { canSeeMemory, memoryHours } from "../../boards/memory.ts";
25 import { telemetryReader } from "../../actions/telemetry.ts";
26 import { historyOver } from "../../system/telemetry.ts";
2527 import { binding } from "../app/keys.ts";
2628 import type { Session } from "../session.ts";
2729 import { setTelemetryGrant } from "../../actions/telemetry.ts";
@@ -103,6 +105,44 @@ export function systemLines(v: SystemView): string[] {
103105 ];
104106 }
105107
108 /** The windows `h` cycles, in hours: the MCP `telemetry_history` tool's `hours`. */
109 export const WINDOWS = [1, 6, 24, 72] as const;
110 /** Seven for the label, this, and " now … · peak …" must fit the dialog's 66 inner columns. */
111 const SPARK_COLUMNS = 20;
112
113 /** At most `n` readings, each the mean of its stretch, so a long window still fits the line. */
114 function shrink(xs: readonly number[], n: number): number[] {
115 if (xs.length <= n) return [...xs];
116 return Array.from({ length: n }, (_, i) => {
outdated

historyOver is called on every render. Compute on open and on h. Also make session required.

117 const part = xs.slice(Math.floor((i * xs.length) / n), Math.floor(((i + 1) * xs.length) / n));
118 return part.reduce((a, b) => a + b, 0) / part.length;
119 });
120 }
121
122 /**
123 * CPU, memory and load of the node over the last `hours`, one sparkline each (scaled to its own peak),
124 * with the latest value and the peak in words. Empty for anyone `telemetry_history` refuses;
125 * `no readings yet` when nothing is recorded.
126 */
127 export function historyLines(session: Session, hours: number): string[] {
128 if (!telemetryReader(session.app, session.user)) return [];
129 const head = `Last ${hours} h (h: 1 / 6 / 24 / 72)`;
130 const points = historyOver(session.app, hours).filter((p) => p.target === "node");
131 const series: [string, (p: (typeof points)[number]) => number | null, (n: number) => string][] = [
132 ["CPU", (p) => p.cpuCoresMax, (n) => `${n.toFixed(2)} cores`],
133 ["memory", (p) => p.memoryBytes, bytes],
134 ["load", (p) => p.load, (n) => n.toFixed(2)],
135 ];
136 const rows = series.map(([label, pick, say]) => {
137 const xs = points.map(pick).filter((x): x is number => x !== null);
138 if (xs.length === 0) return `${pad(label, 7)} no readings yet`;
139 const peak = Math.max(...xs);
140 const scaled = shrink(xs, SPARK_COLUMNS).map((x) => (peak > 0 ? (x / peak) * 100 : 0));
141 return `${pad(label, 7)} ${pad(spark(scaled), SPARK_COLUMNS)} now ${say(xs[xs.length - 1]!)} · peak ${say(peak)}`;
142 });
143 return [head, ...rows];
144 }
145
106146 /** Who may grant telemetry:read: the MCP tool's gate (`mayBoards`), a forge admin who is a person. */
107147 const mayGrant = (session: Session | undefined): boolean => session?.user?.isAdmin === true && session.user.kind !== "agent";
108148
@@ -116,10 +156,12 @@ export function grantLine(session: Session): string {
116156 const grantRefused = (error: "not_found" | "not_an_agent"): string =>
117157 error === "not_an_agent" ? "Only an agent account can be granted telemetry:read." : "no such account";
118158
119 export function systemScreen(ctx: Ctx, lines: readonly string[], session?: Session): Screen {
120 const offered = session !== undefined && mayGrant(session);
159 export function systemScreen(ctx: Ctx, lines: readonly string[], session: Session): Screen {
160 let span = 1; // index into WINDOWS; 1 is the default 6 h window
161 // read on open and when `h` moves the window, not on every paint
162 let history = historyLines(session, WINDOWS[span]!);
163 const offered = mayGrant(session);
121164 const askGrant = (): void => {
122 if (!session) return;
123165 if (!mayGrant(session)) return ctx.toast("telemetry:read is a forge admin's to grant", "danger");
124166 const agents = grantable(session.db);
125167 if (agents.length === 0) return ctx.toast("No agent accounts yet");
@@ -151,11 +193,16 @@ export function systemScreen(ctx: Ctx, lines: readonly string[], session?: Sessi
151193 };
152194 const screen: Screen = {
153195 title: "System",
154 keys: [...(offered ? [binding(["g"], "grant or take back telemetry")] : []), binding(["enter", "esc", "q"], "close")],
196 keys: [binding(["h"], "window"), ...(offered ? [binding(["g"], "grant or take back telemetry")] : []), binding(["enter", "esc", "q"], "close")],
155197 render(area, s) {
156198 const buttons = dialog(s, area, ctx.theme.c, {
157199 title: "System",
158 body: offered ? [...lines, "", grantLine(session), "g: grant or take back telemetry:read"] : lines,
200 body: [
201 ...lines,
202 "",
203 ...history,
204 ...(offered ? ["", grantLine(session), "g: grant or take back telemetry:read"] : []),
205 ],
159206 actions: [{ label: "Close" }],
160207 selected: 0,
161208 width: WIDTH,
@@ -163,7 +210,10 @@ export function systemScreen(ctx: Ctx, lines: readonly string[], session?: Sessi
163210 buttons.forEach((rect) => ctx.hit(rect, () => ctx.pop()));
164211 },
165212 key(e) {
166 if (e.key === "g" && session) askGrant();
213 if (e.key === "h") {
214 span = (span + 1) % WINDOWS.length;
215 history = historyLines(session, WINDOWS[span]!);
216 } else if (e.key === "g") askGrant();
167217 else if (e.key === "enter" || e.key === "esc" || e.key === "q") ctx.pop();
168218 return true;
169219 },
test/tui-system.test.ts +55 −3
@@ -15,7 +15,7 @@ import { createUser, type User } from "../src/core/users.ts";
1515 import { createForgeApp, forgeCommands, Session } from "../src/tui/forge.ts";
1616 import { setTelemetryGrant } from "../src/actions/telemetry.ts";
1717 import { hasGrant } from "../src/core/telemetry.ts";
18 import { forgeLines, memoryLines, memoryScreen, systemScreen } from "../src/tui/screens/system.ts";
18 import { forgeLines, historyLines, memoryLines, memoryScreen, systemScreen } from "../src/tui/screens/system.ts";
1919 import { headlessTerminal } from "../src/tui/term/terminal.ts";
2020 import { quietLogs } from "./helpers/globals.ts";
2121 import { settleApp } from "./helpers/tui.ts";
@@ -103,9 +103,61 @@ describe("tui system memory", () => {
103103 });
104104 });
105105
106 describe("tui system history", () => {
107 const put = (at: string, cpu: number, mem: number, load: number) =>
108 app.db
109 .query("INSERT INTO machine_sample (at, step, target, cpu_cores, cpu_cores_max, memory_bytes, load) VALUES (?1, 15, 'node', ?2, ?2, ?3, ?4)")
110 .run(at, cpu, mem, load);
111
112 test("no readings says so, never an empty chart", () => {
113 const lines = historyLines(new Session(app, identity(carol)), 6);
114 expect(lines.filter((l) => l.includes("no readings yet")).length).toBe(3);
115 });
116
117 test("recorded points draw three lines with the latest and the peak; h cycles the window", async () => {
118 const t = Date.now();
119 put(new Date(t - 120_000).toISOString(), 1, 2 * MiB, 0.5);
120 put(new Date(t - 60_000).toISOString(), 4, 8 * MiB, 2);
121 put(new Date(t - 30_000).toISOString(), 2, 4 * MiB, 1);
122 const lines = historyLines(new Session(app, identity(carol)), 6);
123 expect(lines.length).toBe(4);
124 expect(lines[1]).toContain("CPU");
125 expect(lines[1]).toContain("now 2.00 cores · peak 4.00 cores");
126 expect(lines[2]).toContain("now 4.0 MiB");
127 expect(lines[3]).toContain("peak 2.00");
128 expect(lines.join("\n")).not.toContain("no readings yet");
129
130 const terminal = headlessTerminal(120, 50);
131 const { app: tui, session } = createForgeApp(app, identity(carol), { terminal, exitHooks: false });
outdated

Assert 'peak' is visible on screen (the 120-column terminal hides the overflow), and press h through the full cycle.

132 tui.start();
133 tui.ctx.push(systemScreen(tui.ctx, ["CPU"], session));
134 await settleApp(tui);
135 const seen = () => tui.screen.toText();
136 // the dialog is 72 wide: every row's now and peak words must be on screen, not clipped
137 const peaks = () => seen().split("\n").filter((r) => r.includes("peak"));
138 expect(seen()).toContain("Last 6 h");
139 expect(peaks().length).toBe(3);
140 expect(seen()).toContain("peak 4.00 cores");
141 expect(seen()).toContain("peak 8.0 MiB");
142 for (const hrs of [24, 72, 1, 6]) {
143 tui.dispatch({ type: "key", key: "h", ctrl: false, alt: false, shift: false });
144 await settleApp(tui);
145 expect(seen()).toContain(`Last ${hrs} h`);
146 // the 72 h window reads 5-minute points, of which the test recorded none
147 expect(peaks().length).toBe(hrs === 72 ? 0 : 3);
148 if (hrs === 72) expect(seen()).toContain("no readings yet");
149 }
150 tui.stop?.();
151 });
152
153 test("anyone telemetry_history refuses sees no line", () => {
154 for (const who of [bob, undefined]) expect(historyLines(new Session(app, identity(who)), 6)).toEqual([]);
155 });
156 });
157
106158 describe("tui system telemetry grant", () => {
107159 const boot = (user: User) => {
108 const { app: tui, session } = createForgeApp(app, identity(user), { terminal: headlessTerminal(120, 40), exitHooks: false });
160 const { app: tui, session } = createForgeApp(app, identity(user), { terminal: headlessTerminal(120, 50), exitHooks: false });
109161 tui.start();
110162 tui.ctx.push(systemScreen(tui.ctx, ["CPU"], session));
111163 const press = async (...keys: string[]) => {
@@ -151,7 +203,7 @@ describe("tui system telemetry grant", () => {
151203 });
152204
153205 test("the System command passes the session, so the admin has the line and g", async () => {
154 const { app: tui, session } = createForgeApp(app, identity(carol), { terminal: headlessTerminal(120, 40), exitHooks: false });
206 const { app: tui, session } = createForgeApp(app, identity(carol), { terminal: headlessTerminal(120, 50), exitHooks: false });
155207 tui.start();
156208 const system = forgeCommands(session)(tui.ctx).find((c) => c.id === "system");
157209 system!.run();

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs