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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7321-the-terminal-s-system-screen-draws-the-m → main 5 commits fork 3 threads · 3 unresolved
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 | ||
| 340 | 340 | 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. |
| 341 | 341 | |
| 342 | 342 | `/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. | |
| 344 | 344 | 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. |
| 345 | 345 | |
| 346 | 346 | **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"; | ||
| 22 | 22 | import type { readHealth } from "../../actions/health.ts"; |
| 23 | 23 | import { rolloutLine, type ForgeHealth } from "../../kernel/guard.ts"; |
| 24 | 24 | import { canSeeMemory, memoryHours } from "../../boards/memory.ts"; |
| 25 | import { telemetryReader } from "../../actions/telemetry.ts"; | |
| 26 | import { historyOver } from "../../system/telemetry.ts"; | |
| 25 | 27 | import { binding } from "../app/keys.ts"; |
| 26 | 28 | import type { Session } from "../session.ts"; |
| 27 | 29 | import { setTelemetryGrant } from "../../actions/telemetry.ts"; |
| @@ -103,6 +105,44 @@ export function systemLines(v: SystemView): string[] { | ||
| 103 | 105 | ]; |
| 104 | 106 | } |
| 105 | 107 | |
| 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) => { | |
| 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 | ||
| 106 | 146 | /** Who may grant telemetry:read: the MCP tool's gate (`mayBoards`), a forge admin who is a person. */ |
| 107 | 147 | const mayGrant = (session: Session | undefined): boolean => session?.user?.isAdmin === true && session.user.kind !== "agent"; |
| 108 | 148 | |
| @@ -116,10 +156,12 @@ export function grantLine(session: Session): string { | ||
| 116 | 156 | const grantRefused = (error: "not_found" | "not_an_agent"): string => |
| 117 | 157 | error === "not_an_agent" ? "Only an agent account can be granted telemetry:read." : "no such account"; |
| 118 | 158 | |
| 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); | |
| 121 | 164 | const askGrant = (): void => { |
| 122 | if (!session) return; | |
| 123 | 165 | if (!mayGrant(session)) return ctx.toast("telemetry:read is a forge admin's to grant", "danger"); |
| 124 | 166 | const agents = grantable(session.db); |
| 125 | 167 | 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 | ||
| 151 | 193 | }; |
| 152 | 194 | const screen: Screen = { |
| 153 | 195 | 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")], | |
| 155 | 197 | render(area, s) { |
| 156 | 198 | const buttons = dialog(s, area, ctx.theme.c, { |
| 157 | 199 | 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 | ], | |
| 159 | 206 | actions: [{ label: "Close" }], |
| 160 | 207 | selected: 0, |
| 161 | 208 | width: WIDTH, |
| @@ -163,7 +210,10 @@ export function systemScreen(ctx: Ctx, lines: readonly string[], session?: Sessi | ||
| 163 | 210 | buttons.forEach((rect) => ctx.hit(rect, () => ctx.pop())); |
| 164 | 211 | }, |
| 165 | 212 | 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(); | |
| 167 | 217 | else if (e.key === "enter" || e.key === "esc" || e.key === "q") ctx.pop(); |
| 168 | 218 | return true; |
| 169 | 219 | }, |
test/tui-system.test.ts +55 −3
| @@ -15,7 +15,7 @@ import { createUser, type User } from "../src/core/users.ts"; | ||
| 15 | 15 | import { createForgeApp, forgeCommands, Session } from "../src/tui/forge.ts"; |
| 16 | 16 | import { setTelemetryGrant } from "../src/actions/telemetry.ts"; |
| 17 | 17 | 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"; | |
| 19 | 19 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; |
| 20 | 20 | import { quietLogs } from "./helpers/globals.ts"; |
| 21 | 21 | import { settleApp } from "./helpers/tui.ts"; |
| @@ -103,9 +103,61 @@ describe("tui system memory", () => { | ||
| 103 | 103 | }); |
| 104 | 104 | }); |
| 105 | 105 | |
| 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 | ||
| 106 | 158 | describe("tui system telemetry grant", () => { |
| 107 | 159 | 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 }); | |
| 109 | 161 | tui.start(); |
| 110 | 162 | tui.ctx.push(systemScreen(tui.ctx, ["CPU"], session)); |
| 111 | 163 | const press = async (...keys: string[]) => { |
| @@ -151,7 +203,7 @@ describe("tui system telemetry grant", () => { | ||
| 151 | 203 | }); |
| 152 | 204 | |
| 153 | 205 | 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 }); | |
| 155 | 207 | tui.start(); |
| 156 | 208 | const system = forgeCommands(session)(tui.ctx).find((c) => c.id === "system"); |
| 157 | 209 | system!.run(); |
new above
offline
historyOver is called on every render. Compute on open and on
h. Also makesessionrequired.