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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2721-system-reads-like-btop-per-core-history → main 2 commits fork
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 | ||
| 974 | 974 | |
| 975 | 975 | 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. |
| 976 | 976 | |
| 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. | |
| 979 | 979 | 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. |
| 980 | 980 | |
| 981 | 981 | `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 filesrc/system/format.ts +5
| @@ -10,5 +10,10 @@ export const bytes = (n: number): string => { | ||
| 10 | 10 | return `${v >= 10 || i === 0 ? Math.round(v) : v.toFixed(1)} ${units[i]}`; |
| 11 | 11 | }; |
| 12 | 12 | |
| 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 | ||
| 13 | 18 | export const span = (s: number): string => |
| 14 | 19 | 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"; | ||
| 12 | 12 | |
| 13 | 13 | const FRESH_MS = 1_000; |
| 14 | 14 | |
| 15 | /** Readings of each core kept for the history on /system. */ | |
| 16 | export const HISTORY = 30; | |
| 17 | ||
| 15 | 18 | /** How often a watched reading is pushed: only while someone is subscribed. */ |
| 16 | 19 | export const PUSH_MS = 2_000; |
| 17 | 20 | |
| @@ -32,6 +35,7 @@ export function createLive(options: { paths?: Paths; roots?: () => Roots; self?: | ||
| 32 | 35 | let prev: Raw | undefined; |
| 33 | 36 | let last: Raw | undefined; |
| 34 | 37 | let view: SystemView | undefined; |
| 38 | let history: number[][] = []; | |
| 35 | 39 | const listeners = new Set<(view: SystemView) => void>(); |
| 36 | 40 | let timer: ReturnType<typeof setInterval> | undefined; |
| 37 | 41 | const self_: Live = { |
| @@ -65,7 +69,9 @@ export function createLive(options: { paths?: Paths; roots?: () => Roots; self?: | ||
| 65 | 69 | // A first reading has no rate: its rates are null until a second reading is taken. |
| 66 | 70 | prev = last; |
| 67 | 71 | 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 } }; | |
| 69 | 75 | return view; |
| 70 | 76 | }, |
| 71 | 77 | }; |
src/system/proc.ts +21 −1
| @@ -6,7 +6,7 @@ | ||
| 6 | 6 | * series empty, never a guess. Paths are a parameter so a test can hand it a |
| 7 | 7 | * directory it wrote. |
| 8 | 8 | */ |
| 9 | import { readdirSync, readFileSync, statfsSync } from "node:fs"; | |
| 9 | import { existsSync, readdirSync, readFileSync, statfsSync } from "node:fs"; | |
| 10 | 10 | import { join } from "node:path"; |
| 11 | 11 | |
| 12 | 12 | export type Paths = { proc: string; sys: string }; |
| @@ -22,6 +22,8 @@ export type Temp = { chip: string; label: string; celsius: number }; | ||
| 22 | 22 | export type Memory = { total: number; available: number; swapTotal: number; swapFree: number }; |
| 23 | 23 | export type Disk = { mount: string; fs: string; total: number; used: number }; |
| 24 | 24 | 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 }; | |
| 25 | 27 | export type Proc = { |
| 26 | 28 | pid: number; |
| 27 | 29 | ppid: number; |
| @@ -42,6 +44,7 @@ export type Raw = { | ||
| 42 | 44 | memory: Memory | null; |
| 43 | 45 | disks: Disk[]; |
| 44 | 46 | net: Nic[]; |
| 47 | io: Io[]; | |
| 45 | 48 | procs: Proc[]; |
| 46 | 49 | }; |
| 47 | 50 | |
| @@ -98,6 +101,22 @@ export function parseNet(text: string | null): Nic[] { | ||
| 98 | 101 | return out; |
| 99 | 102 | } |
| 100 | 103 | |
| 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 | ||
| 101 | 120 | /** The `stat` line of one process; null when it is not one. */ |
| 102 | 121 | export function parseProcStat(text: string): { comm: string; ppid: number; ticks: number; rss: number; start: number } | null { |
| 103 | 122 | const open = text.indexOf("("); |
| @@ -210,6 +229,7 @@ export function readRaw(paths: Paths, at: number): Raw { | ||
| 210 | 229 | memory: parseMemory(read(join(paths.proc, "meminfo"))), |
| 211 | 230 | disks: readDisks(paths.proc), |
| 212 | 231 | net: parseNet(read(join(paths.proc, "net/dev"))), |
| 232 | io: parseDiskstats(read(join(paths.proc, "diskstats")), (n) => existsSync(join(paths.sys, "block", n))), | |
| 213 | 233 | procs: readProcs(paths.proc), |
| 214 | 234 | }; |
| 215 | 235 | } |
src/system/view.ts +18 −3
| @@ -29,18 +29,27 @@ export type SystemView = { | ||
| 29 | 29 | at: string; |
| 30 | 30 | /** Seconds between the two readings the rates are over; 0 when there was only one. */ |
| 31 | 31 | 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 | }; | |
| 33 | 38 | temps: { chip: string; label: string; celsius: number }[]; |
| 34 | 39 | hottestC: number | null; |
| 35 | 40 | 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; | |
| 37 | 42 | disks: { mount: string; fs: string; totalBytes: number; usedBytes: number }[]; |
| 38 | 43 | net: { name: string; rxBytesPerSecond: number | null; txBytesPerSecond: number | null }[]; |
| 44 | io: { name: string; readBytesPerSecond: number | null; writeBytesPerSecond: number | null }[]; | |
| 39 | 45 | processes: number; |
| 40 | 46 | groups: Group[]; |
| 41 | 47 | duplicates: Duplicate[]; |
| 42 | 48 | }; |
| 43 | 49 | |
| 50 | /** Processes kept per group. */ | |
| 51 | const TOP = 8; | |
| 52 | ||
| 44 | 53 | export type Roots = readonly { label: string; root: number; cap?: { bytes: number; by: "kernel" | "watch" } }[]; |
| 45 | 54 | |
| 46 | 55 | const kindOf = (label: string): Group["kind"] => |
| @@ -125,7 +134,7 @@ export function buildSystem(prev: Raw | undefined, next: Raw, roots: Roots, self | ||
| 125 | 134 | for (const g of groups.values()) { |
| 126 | 135 | g.top = g.top |
| 127 | 136 | .sort((a, b) => (b.cpuCores ?? 0) - (a.cpuCores ?? 0) || b.rssBytes - a.rssBytes) |
| 128 | .slice(0, 5); | |
| 137 | .slice(0, TOP); | |
| 129 | 138 | if (g.cpuCores !== null) g.cpuCores = Math.round(g.cpuCores * 100) / 100; |
| 130 | 139 | } |
| 131 | 140 | const mem = next.memory; |
| @@ -135,6 +144,7 @@ export function buildSystem(prev: Raw | undefined, next: Raw, roots: Roots, self | ||
| 135 | 144 | cpu: { |
| 136 | 145 | busyPct: known.length === 0 ? null : Math.round((known.reduce((a, b) => a + b, 0) / known.length) * 10) / 10, |
| 137 | 146 | cores, |
| 147 | history: [], | |
| 138 | 148 | }, |
| 139 | 149 | temps: next.temps, |
| 140 | 150 | 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 | ||
| 143 | 153 | totalBytes: mem.total, |
| 144 | 154 | usedBytes: mem.total - mem.available, |
| 145 | 155 | swapUsedBytes: mem.swapTotal - mem.swapFree, |
| 156 | swapTotalBytes: mem.swapTotal, | |
| 146 | 157 | }, |
| 147 | 158 | disks: next.disks.map((d) => ({ mount: d.mount, fs: d.fs, totalBytes: d.total, usedBytes: d.used })), |
| 148 | 159 | net: next.net.map((n) => { |
| @@ -153,6 +164,10 @@ export function buildSystem(prev: Raw | undefined, next: Raw, roots: Roots, self | ||
| 153 | 164 | txBytesPerSecond: rate(o?.tx, n.tx, seconds), |
| 154 | 165 | }; |
| 155 | 166 | }), |
| 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 | }), | |
| 156 | 171 | processes: next.procs.length, |
| 157 | 172 | groups: [...groups.values()].sort((a, b) => (b.cpuCores ?? 0) - (a.cpuCores ?? 0) || b.rssBytes - a.rssBytes), |
| 158 | 173 | duplicates: [...files] |
src/tui/screens/system.ts +20 −4
| @@ -1,7 +1,8 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * 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. | |
| 5 | 6 | * It reads `live().read()`, the same view the page and the API serve. |
| 6 | 7 | * Read-only; Enter, Esc or q closes it. |
| 7 | 8 | * |
| @@ -16,7 +17,7 @@ import type { SystemView } from "../../system/view.ts"; | ||
| 16 | 17 | import { canSeeMemory, memoryHours } from "../../boards/memory.ts"; |
| 17 | 18 | import { binding } from "../app/keys.ts"; |
| 18 | 19 | import type { Session } from "../session.ts"; |
| 19 | import { bytes } from "../../system/format.ts"; | |
| 20 | import { bytes, spark } from "../../system/format.ts"; | |
| 20 | 21 | import { style } from "../term/style.ts"; |
| 21 | 22 | import { pad, wrap } from "../term/width.ts"; |
| 22 | 23 | import { dialog } from "../ui/widgets.ts"; |
| @@ -24,6 +25,10 @@ import { Scroll, drawLines, hitScroll } from "./common.ts"; | ||
| 24 | 25 | |
| 25 | 26 | const WIDTH = 72; |
| 26 | 27 | const GROUPS = 6; |
| 28 | const CORES = 8; | |
| 29 | const PROCS = 2; | |
| 30 | ||
| 31 | const rate = (n: number | null): string => (n === null ? "—" : `${bytes(n)}/s`); | |
| 27 | 32 | |
| 28 | 33 | const pct = (n: number | null): string => (n === null ? "—" : `${n.toFixed(1)}%`); |
| 29 | 34 | |
| @@ -46,12 +51,23 @@ const capOf = (g: SystemView["groups"][number]): string => | ||
| 46 | 51 | |
| 47 | 52 | export function systemLines(v: SystemView): string[] { |
| 48 | 53 | 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 | ); | |
| 49 | 57 | return [ |
| 50 | 58 | `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`] : []), | |
| 51 | 61 | `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)}`) : "—"}`, | |
| 52 | 63 | ...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)}`), | |
| 53 | 66 | "", |
| 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 | ]), | |
| 55 | 71 | "", |
| 56 | 72 | ...duplicateLines(v.duplicates, inner), |
| 57 | 73 | ]; |
src/web/pages/system.ts +47 −33
| @@ -15,7 +15,7 @@ import { canSeeMemory, memoryHours } from "../../boards/memory.ts"; | ||
| 15 | 15 | import { telemetryReader } from "../../actions/telemetry.ts"; |
| 16 | 16 | import type { Ctx } from "../../http/context.ts"; |
| 17 | 17 | import { memoryCapHolder } from "../../apps/contain.ts"; |
| 18 | import { bytes, span } from "../../system/format.ts"; | |
| 18 | import { bytes, spark, span } from "../../system/format.ts"; | |
| 19 | 19 | import { eventStream } from "../../http/sse.ts"; |
| 20 | 20 | import type { SystemView } from "../../system/view.ts"; |
| 21 | 21 | import { live } from "../../system/live.ts"; |
| @@ -23,7 +23,7 @@ import { notFoundPage, signInFirst } from "../chrome.ts"; | ||
| 23 | 23 | import { html, raw, type Html } from "../html.ts"; |
| 24 | 24 | import { page } from "../layout.ts"; |
| 25 | 25 | import { systemLiveJs } from "../system-live.ts"; |
| 26 | import { card, empty, listCard, pill, row } from "../ui.ts"; | |
| 26 | import { card, empty, pill } from "../ui.ts"; | |
| 27 | 27 | |
| 28 | 28 | /** A group's memory, and a cell's beside its cap and who holds the cap. */ |
| 29 | 29 | 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 | ||
| 34 | 34 | const pct = (n: number | null): string => (n === null ? "—" : `${n.toFixed(1)}%`); |
| 35 | 35 | const coresOf = (n: number | null): string => (n === null ? "—" : n.toFixed(2)); |
| 36 | 36 | |
| 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 | ||
| 37 | 64 | const mib = (n: number) => `${(n / 1_048_576).toFixed(1)} MiB`; |
| 38 | 65 | const hhmm = (at: number) => new Date(at).toISOString().slice(0, 16).replace("T", " "); |
| 39 | 66 | |
| @@ -71,9 +98,6 @@ export function regions(v: SystemView): Record<string, string> { | ||
| 71 | 98 | <ul class="list small mono"> |
| 72 | 99 | ${v.duplicates.map((d) => html`<li>${d.file} · ${String(d.checks.length)} checks: ${d.checks.join(", ")}</li>`)} |
| 73 | 100 | </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 | ); | |
| 77 | 101 | const out: Record<string, Html> = { |
| 78 | 102 | "sys-dup": dup, |
| 79 | 103 | 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> { | ||
| 82 | 106 | ${pill(`hottest ${v.hottestC === null ? "—" : `${v.hottestC.toFixed(0)}°C`}`)} |
| 83 | 107 | ${pill(`memory ${v.memory ? `${bytes(v.memory.usedBytes)} of ${bytes(v.memory.totalBytes)}` : "—"}`)} |
| 84 | 108 | ${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}`, | |
| 86 | 115 | 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>`, | |
| 103 | 122 | { head: html`<h2>Disks and network</h2>` }, |
| 104 | 123 | ), |
| 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), | |
| 115 | 125 | }; |
| 116 | 126 | return Object.fromEntries(Object.entries(out).map(([k, h]) => [k, h.text])); |
| 117 | 127 | } |
| @@ -157,12 +167,16 @@ export function systemPage(ctx: Ctx): Response { | ||
| 157 | 167 | <div id="sys-dup">${at("sys-dup")}</div> |
| 158 | 168 | ${card( |
| 159 | 169 | 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> | |
| 161 | 172 | ${caps} |
| 162 | 173 | <p class="small mono" id="temps">${at("temps")}</p>`, |
| 163 | 174 | { head: html`<h2>Machine</h2>` }, |
| 164 | 175 | )} |
| 165 | 176 | <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> | |
| 166 | 180 | <div id="sys-procs">${at("sys-procs")}</div> |
| 167 | 181 | ${memorySection(ctx)}`; |
| 168 | 182 | 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); } | ||
| 406 | 406 | .bform .qf > span { font-size: 12.5px; font-weight: 600; color: var(--fg); } |
| 407 | 407 | .bform .btn { flex: none; } |
| 408 | 408 | .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; } | |
| 409 | 426 | .sys-pair { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 16px; margin-bottom: 16px; } |
| 410 | 427 | .sys-pair .chart-box { max-width: none; } |
| 411 | 428 | .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 @@ | ||
| 1 | 1 | /** |
| 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 | |
| 3 | 3 | * element of the id it names. No reload, no timer, no polling; EventSource |
| 4 | 4 | * itself reconnects after a roll (the stream's `retry:` hint) and the first |
| 5 | 5 | * frame of the new stream is the latest sample. `?pause` opens nothing. |
| 6 | 6 | * The markup comes from our own server, escaped there, so it is set as HTML. |
| 7 | 7 | */ |
| 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 | ||
| 8 | 24 | export const systemLiveJs = /* js */ ` |
| 9 | 25 | (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 | }); | |
| 10 | 55 | if (typeof EventSource === 'undefined' || /[?&]pause\\b/.test(location.search)) return; |
| 11 | 56 | var es = new EventSource('/system/stream'); |
| 12 | 57 | es.addEventListener('sample', function (e) { |
| @@ -18,6 +63,7 @@ export const systemLiveJs = /* js */ ` | ||
| 18 | 63 | var el = document.getElementById(id); |
| 19 | 64 | if (el && el.innerHTML !== regions[id]) el.innerHTML = regions[id]; |
| 20 | 65 | } |
| 66 | sort(); | |
| 21 | 67 | }); |
| 22 | 68 | })(); |
| 23 | 69 | `; |
test/cgroup.test.ts +1
| @@ -162,6 +162,7 @@ describe("/system: a check cell's memory beside its cap", () => { | ||
| 162 | 162 | memory: null, |
| 163 | 163 | disks: [], |
| 164 | 164 | net: [], |
| 165 | io: [], | |
| 165 | 166 | procs: [proc(1, 0, 100), proc(200, 1, 1 << 20), proc(201, 200, 3 << 20)], |
| 166 | 167 | }; |
| 167 | 168 | 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", () => { | ||
| 100 | 100 | expect(v.cpu.cores.length).toBe(2); |
| 101 | 101 | expect(v.hottestC).toBe(76); |
| 102 | 102 | 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 }); | |
| 104 | 104 | expect(JSON.stringify(v)).not.toContain("hunter2"); |
| 105 | 105 | // A later reading gives rates over the two latest. |
| 106 | 106 | fakeHost(root, 300); |
| @@ -303,10 +303,10 @@ describe("telemetry:read: the owner grants it, an agent reads with it, nobody el | ||
| 303 | 303 | // Each check cell's memory beside its cap, and who holds the cap (#2410). |
| 304 | 304 | expect(html).toContain('data-cap="8589934592" data-cap-by="kernel">of 8.0 GiB (kernel cap)'); |
| 305 | 305 | 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). | |
| 307 | 307 | 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="/); | |
| 310 | 310 | const flowPage = await fetch(`${servers.url}/flow`, { headers: { cookie: await login("alice") } }); |
| 311 | 311 | expect(await flowPage.text()).toContain('id="duplicate-work" data-count="1"'); |
| 312 | 312 |
new above
offline