Public: anyone can read this; the crew can change it.
/system shows each first-opened page's measured median beside its budget, read from PAGE_BUDGETS #11467
Check · Review · Land · Live — Its check is running.
torque/forge:torque/11457-system-shows-each-first-opened-page-s-me → main 8 commits fork 4 threads · 4 unresolved
docs/MCP.md +5
| @@ -314,6 +314,11 @@ the blocked host counted last, read by `violationsByDirective` in | ||
| 314 | 314 | `src/http/csp_report.ts` as the security page reads its week. A forge admin |
| 315 | 315 | only; anyone else is `forbidden`. |
| 316 | 316 | |
| 317 | `system` also answers `budgets` (#11457): one row per `PAGE_BUDGETS` page, | |
| 318 | `{ page, pattern, budgetMs, budgetStatements, medianMs, requests, over, says }`, | |
| 319 | `medianMs` null until 5 real requests; the rows /system and | |
| 320 | `GET /api/v1/telemetry/system` show (`src/perf/budget_read.ts`). | |
| 321 | ||
| 317 | 322 | `flow` with `{ q, action }` is the fleet's write door (#450): the plan |
| 318 | 323 | `POST /api/v1/fleet/ops` answers for a dry run, `{ dryRun: true, query, kind, |
| 319 | 324 | verb, value, reversible, rows, counts }`, with nothing changed and only a read |
docs/design/attention-budget.json +2 −2
| @@ -173,12 +173,12 @@ | ||
| 173 | 173 | "system": { |
| 174 | 174 | "390": { |
| 175 | 175 | "words": 212, |
| 176 | "blocks": 65, | |
| 176 | "blocks": 40, | |
| 177 | 177 | "colours": 0 |
| 178 | 178 | }, |
| 179 | 179 | "1280": { |
| 180 | 180 | "words": 212, |
| 181 | "blocks": 65, | |
| 181 | "blocks": 40, | |
| 182 | 182 | "colours": 0 |
| 183 | 183 | }, |
| 184 | 184 | "note": "Measured on the test forge's empty state with the machine reading replaced by an empty one and the two cell-cap sentences fixed (the live page's counts depend on the host's cores, disks, processes and cgroups); nothing seeded or waited for." |
docs/features/page-budget.md added +17
| @@ -0,0 +1,17 @@ | ||
| 1 | # Page budget on /system | |
| 2 | ||
| 3 | `PAGE_BUDGETS` (`src/perf/page_budget.ts`) names the pages people open first, | |
| 4 | each with the median it answers in and the SQL statements one request may run. | |
| 5 | `/system` shows one list row per page: the budget (ms, statements) and the median of | |
| 6 | the last 15 minutes of real requests from the rolling record | |
| 7 | (`src/perf/budget_read.ts`, `src/web/pages/system-budget.ts`). A page over its | |
| 8 | budget says its sentence (`says`) in its own row; the others show | |
| 9 | `50 ms, 46 SQL, median 90 ms`. Fewer than 5 requests is "not | |
| 10 | measured yet". Statements are budgeted but not recorded per request, so only | |
| 11 | `test/page-budget.test.ts` measures them. | |
| 12 | ||
| 13 | The same rows (`budgets`, with `says`) are in `GET /api/v1/telemetry/system` and | |
| 14 | the MCP `system` tool, and in the terminal's System screen (`budgetLines` in | |
| 15 | `src/tui/screens/system.ts`), shown to whoever `telemetryReader` admits and to | |
| 16 | no one else. The record is read at most once per 5 seconds. | |
| 17 | `test/system-budget.test.ts` fails if the page, the API, the MCP tool and the terminal stop saying the same string. |
scripts/unreached-allow.json +1
| @@ -7,6 +7,7 @@ | ||
| 7 | 7 | "src/flow/rebase_first.ts", |
| 8 | 8 | "src/flow/review_rounds.ts", |
| 9 | 9 | "src/flow/shared_red.ts", |
| 10 | "src/flow/stale_fold.ts", | |
| 10 | 11 | "src/perf/page_budget.ts", |
| 11 | 12 | "src/spawn/pod.ts", |
| 12 | 13 | "src/tui/ui/qr.ts", |
src/http/mcp/tools/system.ts +1 −1
| @@ -10,7 +10,7 @@ import { good, bad, type Tool } from "../shared.ts"; | ||
| 10 | 10 | |
| 11 | 11 | export const tool: Tool = { |
| 12 | 12 | name: "system", |
| 13 | doc: "the machine the forge runs on, now: CPU, load, memory, disk, network, processes, `stalls`: the last hour's main-thread stalls by route (`<route> (in flight)` when still running at the wake), or `job:<name>`, as {cause, ms, count}, `readings`: the last 20 minutes' worst loop lag as {at, ms, what} beside `barMs` (100) and `over`, and `readers`: who polls the telemetry and how fast; a forge admin, or an agent granted `telemetry:read`. `{ kind: \"csp\" }`: the last 24 h of Content-Security-Policy violations browsers reported, `{ directive, n, newest }` per directive (`newest` the blocked host last counted), most first, counted per UTC hour from `since`, and `scriptErrors` `{ page, where, message, n, last }`, as the security page reads them; a forge admin only", | |
| 13 | doc: "the machine the forge runs on, now: CPU, load, memory, disk, network, processes, `stalls`: the last hour's main-thread stalls by route (`<route> (in flight)` when still running at the wake), or `job:<name>`, as {cause, ms, count}, `readings`: the last 20 minutes' worst loop lag as {at, ms, what} beside `barMs` (100) and `over`, and `readers`: who polls the telemetry and how fast, `budgets`: each first-opened page's budget and measured median as {page, budgetMs, medianMs, over, says}; a forge admin, or an agent granted `telemetry:read`. `{ kind: \"csp\" }`: the last 24 h of Content-Security-Policy violations browsers reported, `{ directive, n, newest }` per directive (`newest` the blocked host last counted), most first, counted per UTC hour from `since`, and `scriptErrors` `{ page, where, message, n, last }`, as the security page reads them; a forge admin only", | |
| 14 | 14 | description: |
| 15 | 15 | "The forge machine now: CPU, memory, disk, network. Admin or telemetry:read; `kind: csp`: 24 h of CSP violations (admin).", |
| 16 | 16 | inputSchema: { type: "object", properties: { kind: { type: "string", enum: ["csp"] } } }, |
src/perf/budget_read.ts added +68
| @@ -0,0 +1,68 @@ | ||
| 1 | /** | |
| 2 | * The page budget beside what real requests measured (#11457): one row per | |
| 3 | * `PAGE_BUDGETS` entry, its budget and the median of the last 15 minutes from | |
| 4 | * the rolling record (`record.ts`, memory only). /system, the API and the MCP | |
| 5 | * `system` tool all say it with `budgetLine`. The record is read once per few | |
| 6 | * seconds, so a busy /system never sorts the samples again. SQL statements are | |
| 7 | * budgeted but not recorded per request, so only the walk in | |
| 8 | * `test/page-budget.test.ts` measures them. | |
| 9 | */ | |
| 10 | import { PAGE_BUDGETS } from "./page_budget.ts"; | |
| 11 | import { percentile, WINDOW_MS, type PerfRecord } from "./record.ts"; | |
| 12 | ||
| 13 | /** Fewer real requests than this and a median says nothing. */ | |
| 14 | export const MIN_MEASURED = 5; | |
| 15 | const KEEP_MS = 5000; | |
| 16 | ||
| 17 | export interface BudgetRow { | |
| 18 | readonly page: string; | |
| 19 | readonly pattern: string; | |
| 20 | readonly budgetMs: number; | |
| 21 | readonly budgetStatements: number; | |
| 22 | /** Median ms of the window's requests, null when fewer than `MIN_MEASURED`. */ | |
| 23 | readonly medianMs: number | null; | |
| 24 | readonly requests: number; | |
| 25 | readonly over: boolean; | |
| 26 | readonly says: string; | |
| 27 | } | |
| 28 | ||
| 29 | /** The one plain line for a row: over budget is a sentence, not an alarm. */ | |
| 30 | export function budgetLine(r: Pick<BudgetRow, "page" | "budgetMs" | "budgetStatements" | "medianMs" | "over">): string { | |
| 31 | const budget = `budget ${r.budgetMs} ms, ${r.budgetStatements} SQL statements`; | |
| 32 | if (r.medianMs === null) return `${r.page}: ${budget}; not measured yet.`; | |
| 33 | return r.over | |
| 34 | ? `${r.page}: median ${r.medianMs} ms is over its ${r.budgetMs} ms budget (${budget}).` | |
| 35 | : `${r.page}: median ${r.medianMs} ms, within its ${r.budgetMs} ms (${budget}).`; | |
| 36 | } | |
| 37 | ||
| 38 | /** Every budgeted page with its measured median, in table order. */ | |
| 39 | export function budgetRows(record: PerfRecord, now: number): BudgetRow[] { | |
| 40 | return PAGE_BUDGETS.map((b) => { | |
| 41 | const xs = record | |
| 42 | .traffic(b.pattern, now - WINDOW_MS) | |
| 43 | .filter((s) => s.signedIn === (b.signedOut !== true) && s.crawler !== true && s.internal !== true && s.refused !== true) | |
| 44 | .map((s) => s.ms); | |
| 45 | const med = xs.length < MIN_MEASURED ? null : Math.round(percentile(xs, 50)!); | |
| 46 | const row = { | |
| 47 | page: b.page, | |
| 48 | pattern: b.pattern, | |
| 49 | budgetMs: b.medianMs, | |
| 50 | budgetStatements: b.statements, | |
| 51 | medianMs: med, | |
| 52 | requests: xs.length, | |
| 53 | over: med !== null && med > b.medianMs, | |
| 54 | }; | |
| 55 | return { ...row, says: budgetLine(row) }; | |
| 56 | }); | |
| 57 | } | |
| 58 | ||
| 59 | let kept: { record: PerfRecord; at: number; rows: BudgetRow[] } | undefined; | |
| 60 | /** `budgetRows`, kept a few seconds; a change shows on reload. */ | |
| 61 | export function budgetRowsCached(record: PerfRecord, now = Date.now()): BudgetRow[] { | |
| 62 | if (kept === undefined || kept.record !== record || now - kept.at > KEEP_MS) kept = { record, at: now, rows: budgetRows(record, now) }; | |
| 63 | return kept.rows; | |
| 64 | } | |
| 65 | /** Drop the kept rows, so the next read measures again. */ | |
| 66 | export function forgetBudgetRows(): void { | |
| 67 | kept = undefined; | |
| 68 | } |
src/system/telemetry.ts +4 −2
| @@ -28,6 +28,8 @@ import type { Reader } from "../actions/telemetry.ts"; | ||
| 28 | 28 | import { agentView, live } from "./live.ts"; |
| 29 | 29 | import type { SystemView } from "./view.ts"; |
| 30 | 30 | import { readerPaces, type ReaderPace } from "./telemetry_reads.ts"; |
| 31 | import { budgetRowsCached, type BudgetRow } from "../perf/budget_read.ts"; | |
| 32 | import { perfRecord } from "../perf/live.ts"; | |
| 31 | 33 | import { featureHealth, type FeatureHealth } from "./feature_health.ts"; |
| 32 | 34 | |
| 33 | 35 | export function systemFor(app: App, reader: Reader): SystemView { |
| @@ -40,8 +42,8 @@ export function systemFor(app: App, reader: Reader): SystemView { | ||
| 40 | 42 | * rows /system draws (#9088). Names are accounts, so only a telemetry reader gets |
| 41 | 43 | * here. Call it before `countRead` so the read does not count into its own answer. |
| 42 | 44 | */ |
| 43 | export function systemWithReaders(app: App, reader: Reader): SystemView & { readers: ReaderPace[]; features: FeatureHealth[] } { | |
| 44 | return { ...systemFor(app, reader), readers: readerPaces(), features: featureHealth(app, reader) }; | |
| 45 | export function systemWithReaders(app: App, reader: Reader): SystemView & { readers: ReaderPace[]; features: FeatureHealth[]; budgets: BudgetRow[] } { | |
| 46 | return { ...systemFor(app, reader), readers: readerPaces(), features: featureHealth(app, reader), budgets: budgetRowsCached(perfRecord) }; | |
| 45 | 47 | } |
| 46 | 48 | |
| 47 | 49 | /** |
src/tui/screens/system.ts +14
| @@ -40,6 +40,8 @@ import type { PaletteEntry } from "./entries.ts"; | ||
| 40 | 40 | import { readHealth } from "../../actions/health.ts"; |
| 41 | 41 | import { egressReportedLine } from "../../system/egress_line.ts"; |
| 42 | 42 | import { featureHealth } from "../../system/feature_health.ts"; |
| 43 | import { budgetRowsCached } from "../../perf/budget_read.ts"; | |
| 44 | import { perfRecord } from "../../perf/live.ts"; | |
| 43 | 45 | import { readerPaces } from "../../system/telemetry_reads.ts"; |
| 44 | 46 | import { live as liveMachine } from "../../system/live.ts"; |
| 45 | 47 | import { childDirOf, listChildrenNow } from "../../actions/spawn.ts"; |
| @@ -197,6 +199,16 @@ export function featuresLines(session: Session): string[] { | ||
| 197 | 199 | ]; |
| 198 | 200 | } |
| 199 | 201 | |
| 202 | /** | |
| 203 | * Each first-opened page's budget beside its measured median, a line a page in | |
| 204 | * the row's own `says` words, as /system's "Page budgets" says it (#11457). | |
| 205 | * Empty for anyone /system refuses: the page's gate, `telemetryReader`. | |
| 206 | */ | |
| 207 | export function budgetLines(session: Session, now = Date.now()): string[] { | |
| 208 | if (!telemetryReader(session.app, session.user)) return []; | |
| 209 | return ["Page budgets, last 15 min", ...budgetRowsCached(perfRecord, now).flatMap((r) => wrap(r.says, WIDTH - 8).map((l, i) => (i === 0 ? "" : " ") + l))]; | |
| 210 | } | |
| 211 | ||
| 200 | 212 | /** The agents holding the grant, in one line. */ |
| 201 | 213 | export function grantLine(session: Session): string { |
| 202 | 214 | const held = grantable(session.db).filter((a) => a.granted).map((a) => a.username); |
| @@ -247,6 +259,7 @@ export function systemScreen(ctx: Ctx, lines: readonly string[], session: Sessio | ||
| 247 | 259 | keys: [binding(["h"], "window"), ...(offered ? [binding(["g"], "grant or take back telemetry")] : []), binding(["enter", "esc", "q"], "close")], |
| 248 | 260 | render(area, s) { |
| 249 | 261 | const features = featuresLines(session); |
| 262 | const budgets = budgetLines(session); | |
| 250 | 263 | const buttons = dialog(s, area, ctx.theme.c, { |
| 251 | 264 | title: "System", |
| 252 | 265 | body: [ |
| @@ -255,6 +268,7 @@ export function systemScreen(ctx: Ctx, lines: readonly string[], session: Sessio | ||
| 255 | 268 | ...history, |
| 256 | 269 | ...(offered ? ["", ...readerLines(session)] : []), |
| 257 | 270 | ...(features.length === 0 ? [] : ["", ...features]), |
| 271 | ...(budgets.length === 0 ? [] : ["", ...budgets]), | |
| 258 | 272 | ...(offered ? ["", grantLine(session), "g: grant or take back telemetry:read"] : []), |
| 259 | 273 | ], |
| 260 | 274 | actions: [{ label: "Close" }], |
src/web/pages/system-budget.ts added +21
| @@ -0,0 +1,21 @@ | ||
| 1 | /** | |
| 2 | * /system's page budget (#11457): each first-opened page's budget beside its | |
| 3 | * measured median, rows from `perf/budget_read.ts` (the same the API, the MCP | |
| 4 | * `system` tool and the terminal screen answer), one list row a page, each in | |
| 5 | * the row's own `says` words. | |
| 6 | */ | |
| 7 | import type { BudgetRow } from "../../perf/budget_read.ts"; | |
| 8 | import { html, type Html } from "../html.ts"; | |
| 9 | import { row } from "../ui.ts"; | |
| 10 | ||
| 11 | export function budgetSection(rows: readonly BudgetRow[]): Html { | |
outdated
Principle 8: 'No measured page is over its budget.' is fine, but the over-budget case joins every |
||
| 12 | return html`<section class="settings-block" id="sys-budget"><h2>Page budgets</h2> | |
outdated
Principles 2 and 7: the actual rows are hidden behind a closed <details> and drawn as a 5-column table (a hand-rolled table for facts). Draw one row per page ( |
||
| 13 | <ul class="list">${rows.map((r) => | |
| 14 | row({ title: r.over ? r.says : detail(r), data: { page: r.pattern, over: r.over ? "1" : "0" } }), | |
| 15 | )}</ul></section>`; | |
| 16 | } | |
| 17 | ||
| 18 | /** "home: 50 ms · 46 SQL · median 90 ms": a page within its budget, its figures on one line under the heading. */ | |
| 19 | function detail(r: BudgetRow): string { | |
| 20 | return `${r.page}: ${r.budgetMs} ms, ${r.budgetStatements} SQL, ${r.medianMs === null ? "not measured yet" : `median ${r.medianMs} ms`}`; | |
| 21 | } |
src/web/pages/system.ts +11 −5
| @@ -33,7 +33,7 @@ import { readerPaces, type ReaderPace } from "../../system/telemetry_reads.ts"; | ||
| 33 | 33 | import { slowPageLine, slowPages, type SlowPage } from "../../perf/watch.ts"; |
| 34 | 34 | import { stallHour } from "../../metrics/stalls.ts"; |
| 35 | 35 | import { HOUR_MS, type RouteStall } from "../../perf/loop.ts"; |
| 36 | import { lagMinutes, loopWatch, stallBlame } from "../../perf/live.ts"; | |
| 36 | import { lagMinutes, loopWatch, perfRecord, stallBlame } from "../../perf/live.ts"; | |
| 37 | 37 | import { LAG_BAR_MS, LAG_READINGS, overBar, type LagReading } from "../../perf/lag_minutes.ts"; |
| 38 | 38 | import { WORST_STALLS, type BlamedStall } from "../../perf/stall_blame.ts"; |
| 39 | 39 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; |
| @@ -46,6 +46,8 @@ import { KEPT_PAGE_BYTES, keptPageStats } from "./kept_page.ts"; | ||
| 46 | 46 | import { COLUMN_BYTES, PARTS_BYTES, keptPartsStats, treeCacheStats } from "./tree_cache.ts"; |
| 47 | 47 | import { featureHealth, type FeatureHealth } from "../../system/feature_health.ts"; |
| 48 | 48 | import { egressReportedLine } from "../../system/egress_line.ts"; |
| 49 | import { budgetSection } from "./system-budget.ts"; | |
| 50 | import { budgetRowsCached } from "../../perf/budget_read.ts"; | |
| 49 | 51 | import { badge, chartBox, disclosure, meter, pill, row, time } from "../ui.ts"; |
| 50 | 52 | import { childDirOf, listChildren } from "../../actions/spawn.ts"; |
| 51 | 53 | import { childrenUse, realSelf, selfUse, shareOf, sizeWords, useWords, type ChildUseRow, type SelfUse } from "../../spawn/usage.ts"; |
| @@ -180,10 +182,13 @@ export const pageCaches = (): CacheRow[] => [ | ||
| 180 | 182 | export function cachesSection(rows: readonly CacheRow[]): Html { |
| 181 | 183 | const rate = (r: CacheRow) => (r.hits + r.misses === 0 ? "—" : `${((100 * r.hits) / (r.hits + r.misses)).toFixed(1)}%`); |
| 182 | 184 | return html`<section class="settings-block"><h2>Code page caches, since start</h2> |
| 183 | <div class="table-scroll"><table class="table" aria-label="Code page caches"> | |
| 184 | <thead><tr><th scope="col">Cache</th><th scope="col" class="num">Entries</th><th scope="col" class="num">Bytes</th><th scope="col" class="num">Cap</th><th scope="col" class="num">Hits</th><th scope="col" class="num">Misses</th><th scope="col" class="num">Hit rate</th></tr></thead> | |
| 185 | <tbody>${rows.map((r) => html`<tr data-cache="${r.name}" data-bytes="${String(r.bytes)}"><td>${r.name}</td><td class="num mono">${String(r.entries)}</td><td class="num mono">${mib(r.bytes)}</td><td class="num mono">${mib(r.capBytes)}</td><td class="num mono">${String(r.hits)}</td><td class="num mono">${String(r.misses)}</td><td class="num mono">${rate(r)}</td></tr>`)}</tbody> | |
| 186 | </table></div></section>`; | |
| 185 | <ul class="list" aria-label="Code page caches">${rows.map((r) => | |
| 186 | row({ | |
| 187 | title: r.name, | |
| 188 | meta: html`<span class="mono small">${mib(r.bytes)}/${mib(r.capBytes)} MiB, ${String(r.entries)} entries, ${String(r.hits)} hits, ${String(r.misses)} misses${r.hits + r.misses === 0 ? "" : `, ${rate(r)}`}</span>`, | |
| 189 | data: { cache: r.name, bytes: String(r.bytes) }, | |
| 190 | }), | |
| 191 | )}</ul></section>`; | |
| 187 | 192 | } |
| 188 | 193 | |
| 189 | 194 | /** Absorbed apps and whether production is serving (#9887); `featureHealth`'s rows. Nothing absorbed, nothing drawn. */ |
| @@ -404,6 +409,7 @@ export async function systemPage(ctx: Ctx): Promise<Response> { | ||
| 404 | 409 | ${featuresSection(timePhase(ctx.request, "features", () => featureHealth(ctx.app, reader)))} |
| 405 | 410 | ${lagSection(lagMinutes.readings())} |
| 406 | 411 | <div id="sys-stalls">${stallSection(stalls, worstStallsTable(worst, nowMono, Date.now()))}</div> |
| 412 | ${budgetSection(timePhase(ctx.request, "budgets", () => budgetRowsCached(perfRecord)))} | |
| 407 | 413 | <div id="sys-slow-pages">${slowPagesSection(timePhase(ctx.request, "slowpages", () => slowPagesCached(ctx.app.db)))}</div> |
| 408 | 414 | <div id="sys-readers">${readersSection(readerPaces())}</div> |
| 409 | 415 | <div id="sys-caches">${cachesSection(pageCaches())}</div> |
test/system-budget.test.ts added +148
| @@ -0,0 +1,148 @@ | ||
| 1 | /** /system, the telemetry API and the MCP `system` tool read PAGE_BUDGETS beside the measured median (#11457). */ | |
The drift test would still pass if the page's words and the API's words diverged, and nothing covers budgetRowsCached or the terminal. See change request 2. |
||
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { createToken } from "../src/core/tokens.ts"; | |
| 8 | import { createUser, type User } from "../src/core/users.ts"; | |
| 9 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 10 | import { budgetLine, budgetRows, budgetRowsCached, forgetBudgetRows } from "../src/perf/budget_read.ts"; | |
| 11 | import { PAGE_BUDGETS } from "../src/perf/page_budget.ts"; | |
| 12 | import { perfRecord } from "../src/perf/live.ts"; | |
| 13 | import { createPerfRecord } from "../src/perf/record.ts"; | |
| 14 | import { createForgeApp, Session } from "../src/tui/forge.ts"; | |
| 15 | import { budgetLines, systemScreen } from "../src/tui/screens/system.ts"; | |
| 16 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 17 | import { settleApp } from "./helpers/tui.ts"; | |
| 18 | import { quietLogs } from "./helpers/globals.ts"; | |
| 19 | ||
| 20 | quietLogs(); | |
| 21 | ||
| 22 | /** Five signed-in home requests at 90 ms: over home's 50 ms budget, in the process-wide record the page reads. */ | |
| 23 | function seedOver(at = Date.now()): void { | |
| 24 | for (let i = 0; i < 5; i++) perfRecord.add("/", { at, ms: 90, signedIn: true, phases: [] }); | |
| 25 | forgetBudgetRows(); | |
| 26 | } | |
| 27 | ||
| 28 | let dataDir = ""; | |
| 29 | let app: App; | |
| 30 | let servers: Servers; | |
| 31 | let token = ""; | |
| 32 | let alice: User; | |
| 33 | ||
| 34 | beforeAll(async () => { | |
| 35 | dataDir = await mkdtemp(join(tmpdir(), "forge-sysbudget-")); | |
| 36 | app = createApp({ dataDir, port: 0 }); | |
| 37 | servers = await serve(app); | |
| 38 | const u = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "correct horse battery 9", isAdmin: true }); | |
| 39 | if (!u.ok) throw new Error("alice"); | |
| 40 | alice = u.value; | |
| 41 | token = createToken(app.db, u.value.id, "t", "admin").token; | |
| 42 | }, 30_000); | |
| 43 | afterAll(async () => { | |
| 44 | servers.stop(); | |
| 45 | app.close(); | |
| 46 | await rm(dataDir, { recursive: true, force: true }); | |
| 47 | }); | |
| 48 | ||
| 49 | test("the median is the middle of the window's real requests; over budget is one plain line", () => { | |
| 50 | const rec = createPerfRecord(); | |
| 51 | const now = Date.now(); | |
| 52 | // Home budget is 50 ms: five signed-in requests at 10, 20, 90, 100, 110 → median 90. | |
| 53 | for (const ms of [10, 20, 90, 100, 110]) rec.add("/", { at: now, ms, signedIn: true, phases: [] }); | |
| 54 | rec.add("/", { at: now, ms: 1, signedIn: false, phases: [] }); // a stranger's read is not the signed-in budget's | |
| 55 | rec.add("/explore", { at: now, ms: 5, signedIn: true, phases: [] }); // too few to judge | |
| 56 | const rows = budgetRows(rec, now); | |
| 57 | const home = rows.find((r) => r.pattern === "/")!; | |
| 58 | expect(home.medianMs).toBe(90); | |
| 59 | expect(home.requests).toBe(5); | |
| 60 | expect(home.over).toBe(true); | |
| 61 | expect(home.says).toBe("home: median 90 ms is over its 50 ms budget (budget 50 ms, 46 SQL statements)."); | |
| 62 | const explore = rows.find((r) => r.pattern === "/explore")!; | |
| 63 | expect(explore.medianMs).toBeNull(); | |
| 64 | expect(explore.over).toBe(false); | |
| 65 | expect(explore.says).toContain("not measured yet"); | |
| 66 | expect(budgetLine({ page: "x", budgetMs: 50, budgetStatements: 3, medianMs: 12, over: false })).toBe("x: median 12 ms, within its 50 ms (budget 50 ms, 3 SQL statements)."); | |
| 67 | }); | |
| 68 | ||
| 69 | test("one row per PAGE_BUDGETS entry, on /system, the API and the MCP tool, in the same words", async () => { | |
| 70 | const auth = { authorization: `Bearer ${token}` }; | |
| 71 | const patterns = PAGE_BUDGETS.map((b) => b.pattern); | |
| 72 | seedOver(); | |
| 73 | const page = await (await fetch(`${servers.url}/system`, { headers: auth })).text(); | |
| 74 | const section = page.slice(page.indexOf('id="sys-budget"')); | |
| 75 | expect([...section.matchAll(/data-page="([^"]*)"/g)].map((m) => m[1]!.replaceAll("/", "/").replaceAll("/", "/"))).toEqual(patterns); | |
| 76 | ||
| 77 | const api = (await (await fetch(`${servers.url}/api/v1/telemetry/system`, { headers: auth })).json()) as any; | |
| 78 | expect(api.budgets.map((r: any) => r.pattern)).toEqual(patterns); | |
| 79 | ||
| 80 | const res = await fetch(`${servers.url}/mcp`, { | |
| 81 | method: "POST", | |
| 82 | headers: { "content-type": "application/json", accept: "application/json, text/event-stream", ...auth }, | |
| 83 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "system", arguments: {} } }), | |
| 84 | }); | |
| 85 | const mcp = JSON.parse(((await res.json()) as any).result.content[0].text); | |
| 86 | expect(mcp.budgets).toEqual(api.budgets); | |
| 87 | for (const r of api.budgets) { | |
| 88 | const b = PAGE_BUDGETS.find((x) => x.pattern === r.pattern)!; | |
| 89 | expect(r.budgetMs).toBe(b.medianMs); | |
| 90 | expect(r.budgetStatements).toBe(b.statements); | |
| 91 | } | |
| 92 | }); | |
| 93 | ||
| 94 | const OVER = "home: median 90 ms is over its 50 ms budget (budget 50 ms, 46 SQL statements)."; | |
| 95 | ||
| 96 | test("an over-budget page says the same string on /system, the API, the MCP tool and the terminal", async () => { | |
| 97 | seedOver(); | |
| 98 | const auth = { authorization: `Bearer ${token}` }; | |
| 99 | const page = await (await fetch(`${servers.url}/system`, { headers: auth })).text(); | |
| 100 | const api = (await (await fetch(`${servers.url}/api/v1/telemetry/system`, { headers: auth })).json()) as any; | |
| 101 | const res = await fetch(`${servers.url}/mcp`, { | |
| 102 | method: "POST", | |
| 103 | headers: { "content-type": "application/json", accept: "application/json, text/event-stream", ...auth }, | |
| 104 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "system", arguments: {} } }), | |
| 105 | }); | |
| 106 | const mcp = JSON.parse(((await res.json()) as any).result.content[0].text); | |
| 107 | const says = (b: any[]) => b.find((r) => r.pattern === "/").says; | |
| 108 | expect(says(api.budgets)).toBe(OVER); | |
| 109 | expect(says(mcp.budgets)).toBe(OVER); | |
| 110 | expect(page.replaceAll("(", "(").replaceAll(")", ")")).toContain(OVER); | |
| 111 | // each over page is its own row, not one paragraph | |
| 112 | expect(page).not.toContain("<details id=\"sys-budget-rows\""); | |
| 113 | expect(page.match(/data-over="1"/g)?.length).toBe(1); | |
| 114 | }); | |
| 115 | ||
| 116 | test("budgetRowsCached keeps rows for 5 s, and a different record is read afresh", () => { | |
| 117 | const rec = createPerfRecord(); | |
| 118 | const t = 1_000_000_000_000; | |
| 119 | forgetBudgetRows(); | |
| 120 | const first = budgetRowsCached(rec, t); | |
| 121 | for (let i = 0; i < 5; i++) rec.add("/", { at: t, ms: 90, signedIn: true, phases: [] }); | |
| 122 | expect(budgetRowsCached(rec, t + 4000)).toBe(first); // kept | |
| 123 | expect(budgetRowsCached(rec, t + 4000).find((r) => r.pattern === "/")!.over).toBe(false); | |
| 124 | const later = budgetRowsCached(rec, t + 6000); // past the keep | |
| 125 | expect(later).not.toBe(first); | |
| 126 | expect(later.find((r) => r.pattern === "/")!.over).toBe(true); | |
| 127 | const other = createPerfRecord(); | |
| 128 | expect(budgetRowsCached(other, t + 6001).find((r) => r.pattern === "/")!.over).toBe(false); // a new record busts it | |
| 129 | }); | |
| 130 | ||
| 131 | test("the terminal's System screen shows the same rows to an admin and nothing to a guest", async () => { | |
| 132 | seedOver(); | |
| 133 | const identity = (user?: User) => ({ sshUsername: user?.username ?? "git", remoteAddress: "127.0.0.1", fingerprint: "SHA256:t", user }); | |
| 134 | const lines = budgetLines(new Session(app, identity(alice))); | |
| 135 | expect(lines[0]).toBe("Page budgets, last 15 min"); | |
| 136 | const flat = lines.join(" ").replace(/\s+/g, " "); | |
| 137 | for (const r of budgetRowsCached(perfRecord)) expect(flat).toContain(r.says); | |
| 138 | expect(flat).toContain(OVER); | |
| 139 | expect(budgetLines(new Session(app, identity()))).toEqual([]); | |
| 140 | ||
| 141 | const terminal = headlessTerminal(120, 60); | |
| 142 | const { app: tui, session } = createForgeApp(app, identity(alice), { terminal, exitHooks: false }); | |
| 143 | tui.start(); | |
| 144 | tui.ctx.push(systemScreen(tui.ctx, ["CPU"], session)); | |
| 145 | await settleApp(tui); | |
| 146 | expect(tui.screen.toText()).toContain("Page budgets"); | |
| 147 | tui.stop?.(); | |
| 148 | }); |
test/system-parts.test.ts +2 −1
| @@ -54,12 +54,13 @@ test("one <h2> per part, each in a settings-block, and no card", async () => { | ||
| 54 | 54 | "Processes", |
| 55 | 55 | "Loop lag, last 20 minutes", |
| 56 | 56 | "Main-thread stalls, last hour", |
| 57 | "Page budgets", | |
| 57 | 58 | "Telemetry readers", |
| 58 | 59 | "Code page caches, since start", |
| 59 | 60 | "Forge memory", |
| 60 | 61 | ]); |
| 61 | 62 | expect(main.match(/<section class="settings-block"/g)?.length).toBe(heads.length); |
| 62 | for (const id of ["sys-dup", "summary", "cores", "mem", "temps", "sys-disks", "sys-procs", "sys-lag", "sys-stalls", "sys-readers", "sys-caches"]) { | |
| 63 | for (const id of ["sys-dup", "summary", "cores", "mem", "temps", "sys-disks", "sys-procs", "sys-lag", "sys-stalls", "sys-budget", "sys-readers", "sys-caches"]) { | |
| 63 | 64 | expect(main).toContain(`id="${id}"`); |
| 64 | 65 | } |
| 65 | 66 | expect(main).toMatch(/<section class="settings-block"><h2>Disks and network<\/h2>\s*<ul class="list" id="sys-disks">/); |
test/tui-system.test.ts +1 −1
| @@ -211,7 +211,7 @@ describe("tui system telemetry grant", () => { | ||
| 211 | 211 | }); |
| 212 | 212 | |
| 213 | 213 | test("the System command passes the session, so the admin has the line and g", async () => { |
| 214 | const { app: tui, session } = createForgeApp(app, identity(carol), { terminal: headlessTerminal(120, 50), exitHooks: false }); | |
| 214 | const { app: tui, session } = createForgeApp(app, identity(carol), { terminal: headlessTerminal(120, 60), exitHooks: false }); | |
| 215 | 215 | tui.start(); |
| 216 | 216 | const system = forgeCommands(session)(tui.ctx).find((c) => c.id === "system"); |
| 217 | 217 | system!.run(); |
Both doors: the terminal has no screen for this. Add the rows to src/tui/screens/system.ts, using the same
budgetRowsCachedandsays.