Public: anyone can read this; the crew can change it.
/forges: an owner's forges side by side, with size, use against size, apps and last heartbeat #6579
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2933-forges-an-owner-s-forges-side-by-side-wi → main 4 commits fork
11 files
changed +307
−6
README.md +1 −1
| @@ -21,7 +21,7 @@ any URL — and it is the rest of this page. | ||
| 21 | 21 | A forge this one spawned (hosted) runs the apps it was given too: its `bakery` |
| 22 | 22 | answers at `bakery--<child>--<this forge's host>`, in this forge's lanes, on the |
| 23 | 23 | child's own data, and `/forges` says how many are running and why any is not. Each row also ends with the first step its person has not taken (`not claimed yet`, `no everyday account yet`, `no tell yet`, or `going: last tell <time>`; `has not reported yet` before the child's first beat), read from the child's heartbeat; the terminal's forges screen and the MCP `forges` tool (`nextStep`) say the same. |
| 24 | The list leads with the forges that need you, each badged | |
| 24 | `/forges` opens with the forges side by side (size, memory/CPU/disk against it or "shared", apps, last heartbeat; `docs/features/forges-side-by-side.md`). The list leads with the forges that need you, each badged | |
| 25 | 25 | with why (also in the terminal and the MCP `forges`): "Not reporting" (no word |
| 26 | 26 | in 24 hours), "App down", or "Unclaimed" three days after it was sent; the rest |
| 27 | 27 | follow by last activity. |
docs/features/forges-side-by-side.md added +7
| @@ -0,0 +1,7 @@ | ||
| 1 | # /forges side by side (#2933) | |
| 2 | ||
| 3 | `/forges` opens with a table of every forge this one sent: name, address, size, memory, CPU and disk in use against the size, included apps, and the last heartbeat received. A child in its own cell reads from the cell's watch (disk from a bounded walk of its data directory, taken in the background (the page reads the last figure; the first view says "measuring"), kept 30 s); a hosted child shares this forge's process and reads "shared"; a cell not running reads "not running". The same fields (`size`, `use`, `lastHeartbeatAt`) are in the `forges` MCP tool. | |
| 4 | ||
| 5 | Visible to this forge's admins; anyone else gets `not_found`. | |
| 6 | ||
| 7 | The table is for comparing; the list below it stays for what needs doing (claim state, a fresh claim link, spend, next step). The terminal's "Forges you sent" card ends each line with the same size, use and heartbeat words. A cell that is not running reads "not running" under each use column; its limits are in the Size column. The disk reading is cached for 30 s per forge and forgotten after five minutes unasked. |
src/apps/contain.ts +15
| @@ -723,6 +723,20 @@ export interface CellCounter { | ||
| 723 | 723 | readonly cpuUsec: number; |
| 724 | 724 | /** Resident memory of every process in the tree, bytes (`VmRSS`). */ |
| 725 | 725 | readonly memoryBytes: number; |
| 726 | /** Cores used over the watch's CPU window; null until the window has filled. */ | |
| 727 | readonly cores?: number | null; | |
| 728 | } | |
| 729 | ||
| 730 | /** Cores used since the oldest sample; null until the samples span half the CPU window. */ | |
| 731 | export function coresOf( | |
| 732 | samples: readonly { at: number; ticks: number }[], | |
| 733 | last: { ticks: number } | undefined, | |
| 734 | now: number, | |
| 735 | ): number | null { | |
| 736 | const first = samples[0]; | |
| 737 | if (first === undefined || last === undefined) return null; | |
| 738 | const span = (now - first.at) / 1000; | |
| 739 | return span >= WATCH.cpuWindowMs / 1000 / 2 ? Math.max(0, (last.ticks - first.ticks) / CLK_TCK / span) : null; | |
| 726 | 740 | } |
| 727 | 741 | |
| 728 | 742 | /** Every labelled cell the watch has read at least once. */ |
| @@ -735,6 +749,7 @@ export function cellCounters(): CellCounter[] { | ||
| 735 | 749 | label: e.label, |
| 736 | 750 | cpuUsec: (e.last.ticks * 1_000_000) / CLK_TCK, |
| 737 | 751 | memoryBytes: e.last.kib * 1024, |
| 752 | cores: coresOf(e.samples, e.last, Date.now()), | |
| 738 | 753 | }); |
| 739 | 754 | } |
| 740 | 755 | return out; |
src/spawn/clients.ts +12 −1
| @@ -10,13 +10,15 @@ | ||
| 10 | 10 | */ |
| 11 | 11 | |
| 12 | 12 | import type { App } from "../app.ts"; |
| 13 | import { listChildren, statusTokenOf, type Child } from "../actions/spawn.ts"; | |
| 13 | import { childDirOf, listChildren, statusTokenOf, type Child } from "../actions/spawn.ts"; | |
| 14 | 14 | import { timingSafeEqual } from "node:crypto"; |
| 15 | 15 | import { sha256 } from "../kernel/hash.ts"; |
| 16 | 16 | import type { ChildAppsSummary } from "./child-apps.ts"; |
| 17 | 17 | import { hostedChildApps } from "./host.ts"; |
| 18 | 18 | import { hasAdmin, spawnedOrg } from "./claim.ts"; |
| 19 | 19 | import { lastHeard, type Beat } from "./heartbeat.ts"; |
| 20 | import type { Size } from "./sizes.ts"; | |
| 21 | import { forgeUse, type ForgeUse } from "./usage.ts"; | |
| 20 | 22 | |
| 21 | 23 | /** What a spawned forge says about itself to anyone: nothing more. */ |
| 22 | 24 | export interface ForgeState { |
| @@ -43,6 +45,12 @@ export interface ClientRow { | ||
| 43 | 45 | readonly spendTodayUsd: number | null; |
| 44 | 46 | /** The child's forge cap in dollars; null when none or unsaid. */ |
| 45 | 47 | readonly ceilingUsd: number | null; |
| 48 | /** Its size (`spawn/sizes.ts`). */ | |
| 49 | readonly size: Size; | |
| 50 | /** What it uses against that size: its cell's reading, or "shared" when it is hosted here. */ | |
| 51 | readonly use: ForgeUse; | |
| 52 | /** When its last heartbeat reached this forge, ISO; null when none has. */ | |
| 53 | readonly lastHeartbeatAt: string | null; | |
| 46 | 54 | /** The first step its person has not taken, read from the child's heartbeat. */ |
| 47 | 55 | readonly nextStep: NextStep; |
| 48 | 56 | } |
| @@ -196,6 +204,9 @@ export async function clientRows(app: App): Promise<ClientRow[]> { | ||
| 196 | 204 | running: hostedChildApps(app, c.name) ?? null, |
| 197 | 205 | spendTodayUsd: beats[i]?.beat.spendTodayUsd ?? null, |
| 198 | 206 | ceilingUsd: beats[i]?.beat.ceilingUsd ?? null, |
| 207 | size: c.size, | |
| 208 | use: forgeUse(c, childDirOf(app, c.bodyId)), | |
| 209 | lastHeartbeatAt: beats[i] === undefined ? null : new Date(beats[i]!.receivedAt).toISOString(), | |
| 199 | 210 | nextStep: nextStepOf(states[i]?.claimed ?? null, beats[i]?.beat), |
| 200 | 211 | })), |
| 201 | 212 | now, |
src/spawn/usage.ts added +97
| @@ -0,0 +1,97 @@ | ||
| 1 | /** | |
| 2 | * What a child forge uses against its size (#2933). A child in a cell of | |
| 3 | * its own is read off the cell's watch (`apps/contain.ts`) and its data | |
| 4 | * directory; a hosted child shares this forge's process, so it has no | |
| 5 | * figures of its own and reads "shared". Plain data in, plain data out. | |
| 6 | */ | |
| 7 | ||
| 8 | import { diskBytes, cellCounters } from "../apps/contain.ts"; | |
| 9 | import type { Child } from "../actions/spawn.ts"; | |
| 10 | import { SIZES, type Size, type SizeLimits } from "./sizes.ts"; | |
| 11 | ||
| 12 | export type ForgeUse = | |
| 13 | | { readonly kind: "shared" } | |
| 14 | /** In a cell that is not running now (it starts on the next request). */ | |
| 15 | | { readonly kind: "idle"; readonly limits: SizeLimits } | |
| 16 | | { | |
| 17 | readonly kind: "cell"; | |
| 18 | readonly limits: SizeLimits; | |
| 19 | readonly memoryMiB: number; | |
| 20 | /** Cores used lately; null until the watch has enough to say. */ | |
| 21 | readonly cores: number | null; | |
| 22 | /** Null until the first disk reading lands (it is taken off the request) or when the directory is unknown. */ | |
| 23 | readonly diskMiB: number | null; | |
| 24 | }; | |
| 25 | ||
| 26 | /** The disk walk is bounded, but not free: a reading is kept this long, and refreshed off the request. */ | |
| 27 | const DISK_FRESH_MS = 30_000; | |
| 28 | const disks = new Map<string, { at: number; bytes: number }>(); | |
| 29 | const walking = new Set<string>(); | |
| 30 | ||
| 31 | /** | |
| 32 | * The last reading of a directory, or undefined before the first. A stale or | |
| 33 | * missing one is refreshed after the caller has answered (a timer, never in | |
| 34 | * the request), so a page reads memory and costs no walk. | |
| 35 | */ | |
| 36 | function diskOf(dir: string, now: number): number | undefined { | |
| 37 | // Forget readings nobody has asked for lately (a deleted forge's), so the map stays as big as the list. | |
| 38 | for (const [d, r] of disks) if (now - r.at > DISK_FRESH_MS * 10) disks.delete(d); | |
| 39 | const kept = disks.get(dir); | |
| 40 | if ((kept === undefined || now - kept.at >= DISK_FRESH_MS) && !walking.has(dir)) { | |
| 41 | walking.add(dir); | |
| 42 | setTimeout(() => { | |
| 43 | try { | |
| 44 | disks.set(dir, { at: Date.now(), bytes: diskBytes([dir]) }); | |
| 45 | } finally { | |
| 46 | walking.delete(dir); | |
| 47 | } | |
| 48 | }, 0).unref(); | |
| 49 | } | |
| 50 | return kept?.bytes; | |
| 51 | } | |
| 52 | ||
| 53 | /** One child's use; `dir` is where its data lives, `now` the clock. */ | |
| 54 | export function forgeUse(child: Child, dir?: string, now = Date.now()): ForgeUse { | |
| 55 | if (child.placement !== "cell") return { kind: "shared" }; | |
| 56 | const limits = SIZES[child.size]; | |
| 57 | const counter = cellCounters().find((c) => c.label === `forge:${child.name}`); | |
| 58 | if (counter === undefined) return { kind: "idle", limits }; | |
| 59 | const disk = dir === undefined ? undefined : diskOf(dir, now); | |
| 60 | return { | |
| 61 | kind: "cell", | |
| 62 | limits, | |
| 63 | memoryMiB: Math.round(counter.memoryBytes / 1024 / 1024), | |
| 64 | cores: counter.cores == null ? null : Math.round(counter.cores * 100) / 100, | |
| 65 | diskMiB: disk === undefined ? null : Math.round(disk / 1024 / 1024), | |
| 66 | }; | |
| 67 | } | |
| 68 | ||
| 69 | /** "512 MiB", "2 GiB": a size's figure the way a person says it. */ | |
| 70 | export function mibWords(mib: number): string { | |
| 71 | return mib >= 1024 ? `${Math.round((mib / 1024) * 10) / 10} GiB` : `${mib} MiB`; | |
| 72 | } | |
| 73 | ||
| 74 | /** The cell's reading as three short phrases (memory, CPU, disk); one "shared" for a hosted child. */ | |
| 75 | export function useWords(u: ForgeUse): { memory: string; cpu: string; disk: string } { | |
| 76 | if (u.kind === "shared") return { memory: "shared", cpu: "shared", disk: "shared" }; | |
| 77 | const cores = (n: number) => `${n} ${n === 1 ? "core" : "cores"}`; | |
| 78 | if (u.kind === "idle") | |
| 79 | return { memory: "not running", cpu: "not running", disk: "not running" }; | |
| 80 | return { | |
| 81 | memory: `${mibWords(u.memoryMiB)} of ${mibWords(u.limits.memoryMiB)}`, | |
| 82 | cpu: u.cores === null ? `of ${cores(u.limits.cpu)}` : `${u.cores} of ${cores(u.limits.cpu)}`, | |
| 83 | disk: u.diskMiB === null ? `measuring · ${mibWords(u.limits.diskMiB)}` : `${mibWords(u.diskMiB)} of ${mibWords(u.limits.diskMiB)}`, | |
| 84 | }; | |
| 85 | } | |
| 86 | ||
| 87 | /** A size with what it holds: "medium · 2 GiB, 2 cores, 10 GiB". */ | |
| 88 | export function sizeWords(size: Size): string { | |
| 89 | const l = SIZES[size]; | |
| 90 | return `${size} · ${mibWords(l.memoryMiB)}, ${l.cpu} ${l.cpu === 1 ? "core" : "cores"}, ${mibWords(l.diskMiB)}`; | |
| 91 | } | |
| 92 | ||
| 93 | /** One line for the terminal: size, then use ("shared", or memory, CPU and disk). */ | |
| 94 | export function useLine(size: Size, u: ForgeUse): string { | |
| 95 | const w = useWords(u); | |
| 96 | return u.kind === "shared" ? `${size} · shared` : `${sizeWords(size)} · memory ${w.memory} · cpu ${w.cpu} · disk ${w.disk}`; | |
| 97 | } |
src/tui/screens/forges.ts +3 −1
| @@ -7,6 +7,8 @@ | ||
| 7 | 7 | import type { Ctx, Screen } from "../app/app.ts"; |
| 8 | 8 | import { binding } from "../app/keys.ts"; |
| 9 | 9 | import { ATTENTION_WORDS, CLAIM_WORDS, claimState, runningWords, spendWords, type ClientRow } from "../../spawn/clients.ts"; |
| 10 | import { useLine } from "../../spawn/usage.ts"; | |
| 11 | import { timeAgo } from "../../web/ui.ts"; | |
| 10 | 12 | import { card } from "../ui/widgets.ts"; |
| 11 | 13 | import { drawFacts } from "./common.ts"; |
| 12 | 14 | |
| @@ -15,7 +17,7 @@ export function forgeLines(rows: readonly ClientRow[]): [string, string][] { | ||
| 15 | 17 | if (rows.length === 0) return [["", "You have not sent a forge yet"]]; |
| 16 | 18 | return rows.map((r) => [ |
| 17 | 19 | r.name, |
| 18 | [r.attention === null ? null : ATTENTION_WORDS[r.attention], r.for === null ? null : `for ${r.for}`, CLAIM_WORDS[claimState(r)], runningWords(r), r.nextStep.words, spendWords(r)?.words ?? null] | |
| 20 | [r.attention === null ? null : ATTENTION_WORDS[r.attention], r.for === null ? null : `for ${r.for}`, CLAIM_WORDS[claimState(r)], runningWords(r), r.nextStep.words, spendWords(r)?.words ?? null, useLine(r.size, r.use), r.lastHeartbeatAt === null ? "no heartbeat yet" : `heartbeat ${timeAgo(r.lastHeartbeatAt)}`] | |
| 19 | 21 | .filter((m): m is string => m !== null) |
| 20 | 22 | .join(" · "), |
| 21 | 23 | ]); |
src/web/pages/forges.ts +39
| @@ -16,6 +16,7 @@ import { reissueDoor } from "../../spawn/door.ts"; | ||
| 16 | 16 | import { readForm } from "../form.ts"; |
| 17 | 17 | import { badge, btn, card, empty, row, time } from "../ui.ts"; |
| 18 | 18 | import { noRoom } from "../../spawn/door.ts"; |
| 19 | import { sizeWords, useWords } from "../../spawn/usage.ts"; | |
| 19 | 20 | import { claimLinkBody } from "./new-forge.ts"; |
| 20 | 21 | |
| 21 | 22 | |
| @@ -69,6 +70,41 @@ function forgeRow(r: ClientRow): Html { | ||
| 69 | 70 | }); |
| 70 | 71 | } |
| 71 | 72 | |
| 73 | /** The forges side by side: one row each, the same columns, so none shifts. */ | |
| 74 | export function sideBySide(rows: readonly ClientRow[]): Html { | |
| 75 | return html`<div class="table-scroll" role="region" tabindex="0" aria-label="Forges side by side"> | |
| 76 | <table class="table" data-dense data-sticky-lead data-by="forges"> | |
| 77 | <thead> | |
| 78 | <tr> | |
| 79 | <th scope="col">Forge</th> | |
| 80 | <th scope="col">Address</th> | |
| 81 | <th scope="col">Size</th> | |
| 82 | <th scope="col">Memory</th> | |
| 83 | <th scope="col">CPU</th> | |
| 84 | <th scope="col">Disk</th> | |
| 85 | <th scope="col">Apps</th> | |
| 86 | <th scope="col">Last heartbeat</th> | |
| 87 | </tr> | |
| 88 | </thead> | |
| 89 | <tbody> | |
| 90 | ${rows.map((r) => { | |
| 91 | const use = useWords(r.use); | |
| 92 | return html`<tr data-compare="${r.name}" data-use="${r.use.kind}"> | |
| 93 | <th scope="row">${r.name}</th> | |
| 94 | <td><a href="${r.url}">${r.url.replace(/^https?:\/\//, "")}</a></td> | |
| 95 | <td>${sizeWords(r.size)}</td> | |
| 96 | <td>${use.memory}</td> | |
| 97 | <td>${use.cpu}</td> | |
| 98 | <td>${use.disk}</td> | |
| 99 | <td>${r.apps.length === 0 ? "none" : r.apps.join(", ")}</td> | |
| 100 | <td>${r.lastHeartbeatAt === null ? "none yet" : time(r.lastHeartbeatAt)}</td> | |
| 101 | </tr>`; | |
| 102 | })} | |
| 103 | </tbody> | |
| 104 | </table> | |
| 105 | </div>`; | |
| 106 | } | |
| 107 | ||
| 72 | 108 | // GET /forges — an admin's only. |
| 73 | 109 | export async function forgesPage(ctx: Ctx): Promise<Response> { |
| 74 | 110 | const user = ctx.auth.user; |
| @@ -97,6 +133,9 @@ export async function forgesPage(ctx: Ctx): Promise<Response> { | ||
| 97 | 133 | note: "A whole forge for someone: the apps you choose, a crew, and one link to send.", |
| 98 | 134 | action, |
| 99 | 135 | }) |
| 136 | : card(sideBySide(rows), { pad: false, id: "forges-compare" })} | |
| 137 | ${rows.length === 0 | |
| 138 | ? null | |
| 100 | 139 | : card( |
| 101 | 140 | html`<ul class="list setting-list"> |
| 102 | 141 | ${rows.map(forgeRow)} |
test/client-list.test.ts +13
| @@ -220,6 +220,19 @@ describe("the client list", () => { | ||
| 220 | 220 | expect(html).not.toContain(claimUrl.split("#")[1]!); |
| 221 | 221 | }); |
| 222 | 222 | |
| 223 | test("the admin's page carries the forges side by side: columns, shared use, no heartbeat yet", async () => { | |
| 224 | const res = await list(admin); | |
| 225 | expect(res.status).toBe(200); | |
| 226 | const html = (await res.text()).replace(/\s+/g, " "); | |
| 227 | for (const col of ["Forge", "Address", "Size", "Memory", "CPU", "Disk", "Apps", "Last heartbeat"]) | |
| 228 | expect(html).toContain(`<th scope="col">${col}</th>`); | |
| 229 | const maria = html.slice(html.indexOf('data-compare="maria"'), html.indexOf("</tr>", html.indexOf('data-compare="maria"'))); | |
| 230 | expect(maria).toContain('data-use="shared"'); | |
| 231 | expect(maria).toContain("<td>small · 512 MiB, 1 core, 2 GiB</td>"); | |
| 232 | expect(maria).toContain("<td>shared</td>"); | |
| 233 | expect(maria).toContain("<td>none yet</td>"); | |
| 234 | }); | |
| 235 | ||
| 223 | 236 | test("an unclaimed row offers a fresh link: it replaces the old one, shown once; only an admin may ask", async () => { |
| 224 | 237 | const html = (await (await list(admin)).text()).replace(/\s+/g, " "); |
| 225 | 238 | const maria = html.slice(html.indexOf('data-forge="maria"'), html.indexOf("</li>", html.indexOf('data-forge="maria"'))); |
test/forges-attention.test.ts +3
| @@ -20,6 +20,9 @@ const row = (name: string, o: Partial<ClientRow> = {}): Omit<ClientRow, "attenti | ||
| 20 | 20 | claimed: true, |
| 21 | 21 | lastActivityAt: ago(1), |
| 22 | 22 | running: null, |
| 23 | size: "small", | |
| 24 | use: { kind: "shared" }, | |
| 25 | lastHeartbeatAt: null, | |
| 23 | 26 | nextStep: { key: "unreported", words: "has not reported yet", lastTellAt: null }, |
| 24 | 27 | spendTodayUsd: null, |
| 25 | 28 | ceilingUsd: null, |
test/forges-side-by-side.test.ts added +94
| @@ -0,0 +1,94 @@ | ||
| 1 | /** | |
| 2 | * /forges side by side (#2933): size, use against size ("shared" for a | |
| 3 | * hosted child), apps and last heartbeat; an admin's only. | |
| 4 | */ | |
| 5 | ||
| 6 | import { describe, expect, test } from "bun:test"; | |
| 7 | import { forgeUse, mibWords, useWords } from "../src/spawn/usage.ts"; | |
| 8 | import { watchLabelled, cellCounters, coresOf, WATCH } from "../src/apps/contain.ts"; | |
| 9 | import type { Child } from "../src/actions/spawn.ts"; | |
| 10 | import { forgesPage, sideBySide } from "../src/web/pages/forges.ts"; | |
| 11 | import { createApp } from "../src/app.ts"; | |
| 12 | import { mkdtempSync } from "node:fs"; | |
| 13 | import { tmpdir } from "node:os"; | |
| 14 | import { join } from "node:path"; | |
| 15 | ||
| 16 | const child = (over: Partial<Child>): Child => ({ | |
| 17 | name: "serious", url: "https://serious.test", bodyId: "b", done: true, for: null, apps: [], | |
| 18 | createdAt: null, size: "medium", placement: "hosted", placementNote: null, ...over, | |
| 19 | }); | |
| 20 | ||
| 21 | describe("what a forge uses against its size", () => { | |
| 22 | test("a hosted child shares this forge's process", () => { | |
| 23 | const u = forgeUse(child({})); | |
| 24 | expect(u).toEqual({ kind: "shared" }); | |
| 25 | expect(useWords(u)).toEqual({ memory: "shared", cpu: "shared", disk: "shared" }); | |
| 26 | }); | |
| 27 | test("a cell that is not running says so, with its size's figures", () => { | |
| 28 | const u = forgeUse(child({ placement: "cell", name: "nobody-home" })); | |
| 29 | expect(u.kind).toBe("idle"); | |
| 30 | expect(useWords(u).memory).toBe("not running"); | |
| 31 | }); | |
| 32 | test("a running cell reads its memory off the watch", async () => { | |
| 33 | const stop = watchLabelled(process.pid, "forge:fun"); | |
| 34 | try { | |
| 35 | const wait = Date.now() + 3000; | |
| 36 | while (!cellCounters().some((c) => c.label === "forge:fun") && Date.now() < wait) await Bun.sleep(50); | |
| 37 | const dir = mkdtempSync(join(tmpdir(), "forge-sbs-d-")); | |
| 38 | const first = forgeUse(child({ placement: "cell", name: "fun", size: "small" }), dir); | |
| 39 | // The request never walks the disk: the first reading says so, a later one has the figure. | |
| 40 | expect(first.kind === "cell" && first.diskMiB).toBeNull(); | |
| 41 | await Bun.sleep(100); | |
| 42 | const u = forgeUse(child({ placement: "cell", name: "fun", size: "small" }), dir); | |
| 43 | expect(u.kind === "cell" && u.diskMiB).not.toBeNull(); | |
| 44 | expect(forgeUse(child({ placement: "cell", name: "fun" })).kind === "cell").toBe(true); | |
| 45 | expect(u.kind).toBe("cell"); | |
| 46 | if (u.kind === "cell") { | |
| 47 | expect(u.memoryMiB).toBeGreaterThan(0); | |
| 48 | expect(useWords(u).memory).toMatch(/ of 512 MiB$/); | |
| 49 | } | |
| 50 | } finally { | |
| 51 | stop(); | |
| 52 | } | |
| 53 | }); | |
| 54 | test("figures in words", () => { | |
| 55 | expect(mibWords(512)).toBe("512 MiB"); | |
| 56 | expect(mibWords(8192)).toBe("8 GiB"); | |
| 57 | }); | |
| 58 | }); | |
| 59 | ||
| 60 | describe("GET /forges", () => { | |
| 61 | test("anyone but an admin gets not_found", async () => { | |
| 62 | const app = createApp({ dataDir: mkdtempSync(join(tmpdir(), "forge-sbs-")), url: "https://x.test", port: 0 }); | |
| 63 | const request = new Request("https://x.test/forges"); | |
| 64 | const res = await forgesPage({ app, request, auth: { user: undefined }, params: {}, url: new URL(request.url) } as never); | |
| 65 | expect(res.status).toBe(404); | |
| 66 | }); | |
| 67 | }); | |
| 68 | ||
| 69 | describe("coresOf", () => { | |
| 70 | const samples = [{ at: 0, ticks: 0 }]; | |
| 71 | test("null before the samples span half the window, a figure after", () => { | |
| 72 | expect(coresOf(samples, { ticks: 500 }, WATCH.cpuWindowMs / 2 - 1)).toBeNull(); | |
| 73 | expect(coresOf([], { ticks: 500 }, 99_999)).toBeNull(); | |
| 74 | expect(coresOf(samples, undefined, 99_999)).toBeNull(); | |
| 75 | // 1500 ticks at 100/s over 30 s is half a core. | |
| 76 | expect(coresOf(samples, { ticks: 1500 }, 30_000)).toBeCloseTo(0.5, 5); | |
| 77 | }); | |
| 78 | }); | |
| 79 | ||
| 80 | describe("the table", () => { | |
| 81 | const base = { name: "fun", url: "https://fun.test", apps: [], lastHeartbeatAt: null } as never; | |
| 82 | test("a cell that is not running says so under each use column; one running reads X of Y", () => { | |
| 83 | const idle = String(sideBySide([{ ...(base as object), size: "small", use: { kind: "idle", limits: { cpu: 1, memoryMiB: 512, diskMiB: 2048 } } } as never])); | |
| 84 | expect(idle).toContain('data-use="idle"'); | |
| 85 | expect(idle.match(/<td>not running<\/td>/g)).toHaveLength(3); | |
| 86 | const live = String(sideBySide([{ ...(base as object), size: "small", lastHeartbeatAt: new Date().toISOString(), use: { kind: "cell", limits: { cpu: 1, memoryMiB: 512, diskMiB: 2048 }, memoryMiB: 310, cores: null, diskMiB: null } } as never])); | |
| 87 | expect(live).toContain("<td>310 MiB of 512 MiB</td>"); | |
| 88 | expect(live).toContain("<td>of 1 core</td>"); | |
| 89 | expect(live).toContain("<td>measuring · 2 GiB</td>"); | |
| 90 | expect(live).toContain("just now"); | |
| 91 | expect(live).toContain('role="region" tabindex="0" aria-label="Forges side by side"'); | |
| 92 | expect(live).toContain("data-sticky-lead"); | |
| 93 | }); | |
| 94 | }); |
test/tui-forges.test.ts +23 −3
| @@ -28,6 +28,9 @@ const row = (over: Partial<ClientRow>): ClientRow => ({ | ||
| 28 | 28 | attention: null, |
| 29 | 29 | spendTodayUsd: null, |
| 30 | 30 | ceilingUsd: null, |
| 31 | size: "small", | |
| 32 | use: { kind: "shared" }, | |
| 33 | lastHeartbeatAt: null, | |
| 31 | 34 | nextStep: { key: "unreported", words: "has not reported yet", lastTellAt: null }, |
| 32 | 35 | ...over, |
| 33 | 36 | }); |
| @@ -103,19 +106,36 @@ describe("forges you sent in the terminal", () => { | ||
| 103 | 106 | expect(forgeLines([row({})])[0]![1]).not.toContain("today"); |
| 104 | 107 | }); |
| 105 | 108 | |
| 109 | test("a row says its size and use: shared when hosted, X of Y in a cell", () => { | |
| 110 | const hosted = forgeLines([row({ size: "medium" })])[0]![1]; | |
| 111 | expect(hosted).toContain("medium · shared"); | |
| 112 | const cell = row({ | |
| 113 | size: "small", | |
| 114 | use: { kind: "cell", limits: { cpu: 1, memoryMiB: 512, diskMiB: 2048 }, memoryMiB: 310, cores: 0.4, diskMiB: 100 }, | |
| 115 | lastHeartbeatAt: new Date().toISOString(), | |
| 116 | }); | |
| 117 | const line = forgeLines([cell])[0]![1]; | |
| 118 | expect(line).toContain("memory 310 MiB of 512 MiB"); | |
| 119 | expect(line).toContain("cpu 0.4 of 1 core"); | |
| 120 | expect(line).toContain("disk 100 MiB of 2 GiB"); | |
| 121 | expect(line).toContain("heartbeat just now"); | |
| 122 | const idle = row({ size: "small", use: { kind: "idle", limits: { cpu: 1, memoryMiB: 512, diskMiB: 2048 } } }); | |
| 123 | expect(forgeLines([idle])[0]![1]).toContain("memory not running"); | |
| 124 | }); | |
| 125 | ||
| 106 | 126 | test("a row says who, the claim state and the apps note; never a link", () => { |
| 107 | 127 | const up = { total: 3, running: 3, notRunning: [] }; |
| 108 | 128 | const down = { total: 2, running: 1, notRunning: [{ slug: "notes", why: "build failed" }] }; |
| 109 | 129 | expect(forgeLines([row({ running: up })])).toEqual([ |
| 110 | ["maria", "for Maria's bakery · Not claimed yet · 3 apps running · has not reported yet"], | |
| 130 | ["maria", "for Maria's bakery · Not claimed yet · 3 apps running · has not reported yet · small · shared · no heartbeat yet"], | |
| 111 | 131 | ]); |
| 112 | 132 | expect(forgeLines([row({ claimed: true, running: down, for: null })])).toEqual([ |
| 113 | ["maria", "Claimed · 1 app running · notes: build failed · has not reported yet"], | |
| 133 | ["maria", "Claimed · 1 app running · notes: build failed · has not reported yet · small · shared · no heartbeat yet"], | |
| 114 | 134 | ]); |
| 115 | 135 | expect(forgeLines([row({ claimed: null })])[0]![1]).toContain("Unknown"); |
| 116 | 136 | expect(forgeLines([row({ made: false })])[0]![1]).toContain("Still being made"); |
| 117 | 137 | expect(forgeLines([row({ attention: "down", claimed: true, for: null })])).toEqual([ |
| 118 | ["maria", "App down · Claimed · has not reported yet"], | |
| 138 | ["maria", "App down · Claimed · has not reported yet · small · shared · no heartbeat yet"], | |
| 119 | 139 | ]); |
| 120 | 140 | expect(forgeLines([row({})])[0]![1]).not.toMatch(/Not reporting|App down|Unclaimed/); |
| 121 | 141 | expect(forgeLines([row({ nextStep: { key: "tell", words: "no tell yet", lastTellAt: null } })])[0]![1]).toContain("no tell yet"); |
new above
offline