Public: anyone can read this; the crew can change it.
The terminal's palette offers "Forges you sent" to an admin: each forge with its claim state and the same apps-running note as /forges #2214
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2211-the-terminal-s-palette-offers-forges-you → main 3 commits fork 2 threads · 2 unresolved
6 files
changed +212
−20
README.md +2
| @@ -21,6 +21,8 @@ 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. |
| 24 | In the terminal an admin's palette offers "Forges you sent": each forge with its | |
| 25 | claim state and the same apps-running note, never a claim link. | |
| 24 | 26 | |
| 25 | 27 | Git hosting, code browsing, and a JSON API in one fast Bun process, with |
| 26 | 28 | **zero runtime dependencies** — the `package.json` dependency list is empty and |
src/spawn/clients.ts +22
| @@ -171,3 +171,25 @@ export async function clientRows(app: App): Promise<ClientRow[]> { | ||
| 171 | 171 | a.name.localeCompare(b.name), |
| 172 | 172 | ); |
| 173 | 173 | } |
| 174 | ||
| 175 | /** A forge's claim state in words, as /forges and the terminal say it. */ | |
| 176 | export const CLAIM_WORDS: Record<"yes" | "no" | "unknown" | "making", string> = { | |
| 177 | yes: "Claimed", | |
| 178 | no: "Not claimed yet", | |
| 179 | unknown: "Unknown", | |
| 180 | making: "Still being made", | |
| 181 | }; | |
| 182 | ||
| 183 | export function claimState(r: ClientRow): keyof typeof CLAIM_WORDS { | |
| 184 | if (!r.made) return "making"; | |
| 185 | return r.claimed === null ? "unknown" : r.claimed ? "yes" : "no"; | |
| 186 | } | |
| 187 | ||
| 188 | /** "3 apps running", or which one is not and why; null when none are run here. */ | |
| 189 | export function runningWords(r: ClientRow): string | null { | |
| 190 | const a = r.running; | |
| 191 | if (a === null || a.total === 0) return null; | |
| 192 | const word = `${a.running} ${a.running === 1 ? "app" : "apps"} running`; | |
| 193 | if (a.notRunning.length === 0) return word; | |
| 194 | return `${word} · ${a.notRunning.map((n) => `${n.slug}: ${n.why}`).join("; ")}`; | |
| 195 | } |
src/tui/forge.ts +13
| @@ -33,6 +33,8 @@ import { canSeeMemory } from "../boards/memory.ts"; | ||
| 33 | 33 | import { boardsScreen } from "./screens/boards.ts"; |
| 34 | 34 | import { askedScreen } from "./screens/asked.ts"; |
| 35 | 35 | import { deciderScreen } from "./screens/decider.ts"; |
| 36 | import { forgesScreen } from "./screens/forges.ts"; | |
| 37 | import { clientRows } from "../spawn/clients.ts"; | |
| 36 | 38 | import { familyScreen } from "./screens/family.ts"; |
| 37 | 39 | import { familyLines, familyView } from "../spawn/family.ts"; |
| 38 | 40 | import { issueScreen } from "./screens/issue.ts"; |
| @@ -388,6 +390,17 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 388 | 390 | hint: "what needs the owner, snapshots, failure domains", |
| 389 | 391 | run: () => ctx.push(ownerScreen(session, ctx)), |
| 390 | 392 | }); |
| 393 | // The forges this one sent, as /forges lists them: an admin's only. | |
| 394 | if (session.user?.isAdmin === true) | |
| 395 | commands.push({ | |
| 396 | id: "forges", | |
| 397 | title: "Forges you sent", | |
| 398 | hint: "each forge sent, claimed or not, and its apps", | |
| 399 | run: () => | |
| 400 | runAsync(ctx, async () => { | |
| 401 | ctx.push(forgesScreen(ctx, await clientRows(session.app))); | |
| 402 | }), | |
| 403 | }); | |
| 391 | 404 | commands.push({ |
| 392 | 405 | id: "family", |
| 393 | 406 | title: "Family", |
src/tui/screens/forges.ts added +51
| @@ -0,0 +1,51 @@ | ||
| 1 | /** | |
| 2 | * The forges this one sent (#2211) in a card: one row per client with its | |
| 3 | * claim state and the apps-running note, in the words /forges uses. Never a | |
| 4 | * claim link; that stays on the page. | |
| 5 | */ | |
| 6 | ||
| 7 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 8 | import { binding } from "../app/keys.ts"; | |
| 9 | import { CLAIM_WORDS, claimState, runningWords, type ClientRow } from "../../spawn/clients.ts"; | |
| 10 | import { card } from "../ui/widgets.ts"; | |
| 11 | import { drawFacts } from "./common.ts"; | |
| 12 | ||
| 13 | /** [name, "for Maria · Claimed · 3 apps running"] per row; an empty line when none. */ | |
| 14 | export function forgeLines(rows: readonly ClientRow[]): [string, string][] { | |
| 15 | if (rows.length === 0) return [["", "You have not sent a forge yet"]]; | |
| 16 | return rows.map((r) => [ | |
| 17 | r.name, | |
| 18 | [r.for === null ? null : `for ${r.for}`, CLAIM_WORDS[claimState(r)], runningWords(r)] | |
| 19 | .filter((m): m is string => m !== null) | |
| 20 | .join(" · "), | |
| 21 | ]); | |
| 22 | } | |
| 23 | ||
| 24 | /** The lines that fit in `room` rows; when some do not, the last row says how many. */ | |
| 25 | export function fitLines(lines: [string, string][], room: number): [string, string][] { | |
| 26 | if (lines.length <= room) return lines; | |
| 27 | const kept = lines.slice(0, Math.max(0, room - 1)); | |
| 28 | kept.push(["", `+${lines.length - kept.length} more (see /forges)`]); | |
| 29 | return kept; | |
| 30 | } | |
| 31 | ||
| 32 | export function forgesScreen(ctx: Ctx, rows: readonly ClientRow[]): Screen { | |
| 33 | const lines = forgeLines(rows); | |
| 34 | return { | |
| 35 | title: "Forges you sent", | |
| 36 | keys: [binding(["esc"], "close")], | |
| 37 | render(area, s) { | |
| 38 | const c = ctx.theme.c; | |
| 39 | const w = Math.min(100, Math.max(30, area.w - 6)); | |
| 40 | const h = Math.min(area.h, lines.length + 2); | |
| 41 | const shown = fitLines(lines, h - 2); | |
| 42 | const r = { x: area.x + ((area.w - w) >> 1), y: area.y + ((area.h - h) >> 1), w, h }; | |
| 43 | const inner = card(s, r, c, { title: "Forges you sent", focused: true }); | |
| 44 | drawFacts(s, { x: inner.x + 1, y: inner.y, w: Math.max(0, inner.w - 1), h: inner.h }, c, shown, c.card); | |
| 45 | }, | |
| 46 | key(e) { | |
| 47 | if (e.key === "esc" || e.key === "q") ctx.pop(); | |
| 48 | return true; | |
| 49 | }, | |
| 50 | }; | |
| 51 | } |
src/web/pages/forges.ts +7 −20
| @@ -7,7 +7,7 @@ | ||
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | 9 | import type { Ctx } from "../../http/context.ts"; |
| 10 | import { clientRows, type ClientRow } from "../../spawn/clients.ts"; | |
| 10 | import { CLAIM_WORDS, claimState, clientRows, runningWords, type ClientRow } from "../../spawn/clients.ts"; | |
| 11 | 11 | import { notFoundPage, pageHead } from "../chrome.ts"; |
| 12 | 12 | import { html, type Html } from "../html.ts"; |
| 13 | 13 | import { page } from "../layout.ts"; |
| @@ -17,31 +17,18 @@ import { btn, card, empty, time } from "../ui.ts"; | ||
| 17 | 17 | import { noRoom } from "../../spawn/door.ts"; |
| 18 | 18 | import { claimLinkBody } from "./new-forge.ts"; |
| 19 | 19 | |
| 20 | const STATE: Record<"yes" | "no" | "unknown" | "making", string> = { | |
| 21 | yes: "Claimed", | |
| 22 | no: "Not claimed yet", | |
| 23 | unknown: "Unknown", | |
| 24 | making: "Still being made", | |
| 25 | }; | |
| 26 | ||
| 27 | function stateOf(r: ClientRow): keyof typeof STATE { | |
| 28 | if (!r.made) return "making"; | |
| 29 | return r.claimed === null ? "unknown" : r.claimed ? "yes" : "no"; | |
| 30 | } | |
| 31 | 20 | |
outdated
Remove these two aliases; import and use CLAIM_WORDS and claimState where STATE and stateOf are used. |
||
| 32 | 21 | /** "3 apps running", or which one is not and why. */ |
| 33 | 22 | function runningNote(r: ClientRow): Html | null { |
| 34 | const a = r.running; | |
| 35 | if (a === null || a.total === 0) return null; | |
| 36 | const word = `${a.running} ${a.running === 1 ? "app" : "apps"} running`; | |
| 37 | if (a.notRunning.length === 0) return html`<span data-apps="up">${word}</span>`; | |
| 38 | return html`<span data-apps="down" | |
| 39 | >${word} · ${a.notRunning.map((n, i) => html`${i > 0 ? "; " : ""}${n.slug}: ${n.why}`)}</span | |
| 23 | const words = runningWords(r); | |
| 24 | if (words === null) return null; | |
| 25 | return html`<span data-apps="${r.running!.notRunning.length === 0 ? "up" : "down"}" | |
| 26 | >${words}</span | |
| 40 | 27 | >`; |
| 41 | 28 | } |
| 42 | 29 | |
| 43 | 30 | function forgeRow(r: ClientRow): Html { |
| 44 | const state = stateOf(r); | |
| 31 | const state = claimState(r); | |
| 45 | 32 | const meta = [ |
| 46 | 33 | r.for === null ? null : html`for ${r.for}`, |
| 47 | 34 | r.apps.length === 0 ? null : html`${r.apps.join(", ")}`, |
| @@ -64,7 +51,7 @@ function forgeRow(r: ClientRow): Html { | ||
| 64 | 51 | class="pill" |
| 65 | 52 | ${state === "unknown" ? html`title="It did not answer just now"` : null} |
| 66 | 53 | ${state === "yes" ? html`data-tone="accent"` : null} |
| 67 | >${STATE[state]}</span | |
| 54 | >${CLAIM_WORDS[state]}</span | |
| 68 | 55 | >${state === "no" |
| 69 | 56 | ? html`<form method="post" action="/forges/${r.name}/claim-link"> |
| 70 | 57 | ${btn("Send a fresh link", { size: "sm", title: "A new claim link; the old one stops working" })} |
test/tui-forges.test.ts added +117
| @@ -0,0 +1,117 @@ | ||
| 1 | /** "Forges you sent" in the terminal palette (#2211). */ | |
| 2 | import { afterAll, beforeAll, describe, 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 as Forge } from "../src/app.ts"; | |
| 7 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 8 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 9 | import { fitLines, forgeLines } from "../src/tui/screens/forges.ts"; | |
| 10 | import { runningWords, type ClientRow } from "../src/spawn/clients.ts"; | |
| 11 | import type { TuiIdentity } from "../src/tui/session.ts"; | |
| 12 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 13 | import { quietLogs } from "./helpers/globals.ts"; | |
| 14 | import { settleApp } from "./helpers/tui.ts"; | |
| 15 | ||
| 16 | quietLogs(); | |
| 17 | ||
| 18 | const row = (over: Partial<ClientRow>): ClientRow => ({ | |
| 19 | name: "maria", | |
| 20 | url: "https://maria.test", | |
| 21 | for: "Maria's bakery", | |
| 22 | apps: [], | |
| 23 | sentAt: null, | |
| 24 | made: true, | |
| 25 | claimed: false, | |
| 26 | lastActivityAt: null, | |
| 27 | running: null, | |
| 28 | ...over, | |
| 29 | }); | |
| 30 | ||
| 31 | describe("forges you sent in the terminal", () => { | |
| 32 | let dataDir: string; | |
| 33 | let forge: Forge; | |
| 34 | ||
| 35 | function boot(username: string | null) { | |
| 36 | const terminal = headlessTerminal(140, 32); | |
| 37 | const user = username === null ? undefined : getUserByName(forge.db, username)!; | |
| 38 | const identity: TuiIdentity = { sshUsername: "t", remoteAddress: "127.0.0.1", user }; | |
| 39 | const { app } = createForgeApp(forge, identity, { terminal, exitHooks: false }); | |
| 40 | app.start(); | |
| 41 | const key = (k: string, ctrl = false) => | |
| 42 | app.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false }); | |
| 43 | return { | |
| 44 | text: () => app.screen.toText(), | |
| 45 | stop: () => app.stop(), | |
| 46 | async palette() { | |
| 47 | key("k", true); | |
| 48 | for (const ch of "Forges you sent") key(ch); | |
| 49 | await settleApp(app); | |
| 50 | return app.screen.toText(); | |
| 51 | }, | |
| 52 | async enter() { | |
| 53 | key("enter"); | |
| 54 | await settleApp(app); | |
| 55 | }, | |
| 56 | }; | |
| 57 | } | |
| 58 | ||
| 59 | beforeAll(async () => { | |
| 60 | dataDir = await mkdtemp(join(tmpdir(), "forge-tui-forges-")); | |
| 61 | forge = createApp({ dataDir, port: 0 }); | |
| 62 | for (const [username, adminIfFirst] of [["alice", true], ["bob", false]] as const) { | |
| 63 | const made = await createUser(forge.db, { | |
| 64 | trusted: true, | |
| 65 | username, | |
| 66 | email: `${username}@forge.local`, | |
| 67 | password: "a-good-passphrase", | |
| 68 | adminIfFirst, | |
| 69 | }); | |
| 70 | if (!made.ok) throw new Error(made.error); | |
| 71 | } | |
| 72 | }); | |
| 73 | ||
| 74 | afterAll(async () => { | |
| 75 | forge.close(); | |
| 76 | await rm(dataDir, { recursive: true, force: true }); | |
| 77 | }); | |
| 78 | ||
| 79 | test("an admin is offered the command and sees the empty line", async () => { | |
| 80 | const h = boot("alice"); | |
| 81 | expect(await h.palette()).toContain("each forge sent"); | |
| 82 | await h.enter(); | |
| 83 | expect(h.text()).toContain("You have not sent a forge yet"); | |
| 84 | h.stop(); | |
| 85 | }); | |
| 86 | ||
| 87 | test("a member and a guest are not offered it", async () => { | |
| 88 | for (const who of ["bob", null]) { | |
| 89 | const h = boot(who); | |
| 90 | expect(await h.palette()).not.toContain("each forge sent"); | |
| 91 | h.stop(); | |
| 92 | } | |
| 93 | }); | |
| 94 | ||
| 95 | test("a row says who, the claim state and the apps note; never a link", () => { | |
| 96 | const up = { total: 3, running: 3, notRunning: [] }; | |
| 97 | const down = { total: 2, running: 1, notRunning: [{ slug: "notes", why: "build failed" }] }; | |
| 98 | expect(forgeLines([row({ running: up })])).toEqual([ | |
| 99 | ["maria", "for Maria's bakery · Not claimed yet · 3 apps running"], | |
| 100 | ]); | |
| 101 | expect(forgeLines([row({ claimed: true, running: down, for: null })])).toEqual([ | |
| 102 | ["maria", "Claimed · 1 app running · notes: build failed"], | |
| 103 | ]); | |
| 104 | expect(forgeLines([row({ claimed: null })])[0]![1]).toContain("Unknown"); | |
| 105 | expect(forgeLines([row({ made: false })])[0]![1]).toContain("Still being made"); | |
| 106 | expect(JSON.stringify(forgeLines([row({})]))).not.toContain("http"); | |
| 107 | expect(runningWords(row({ running: { total: 0, running: 0, notRunning: [] } }))).toBeNull(); | |
| 108 | }); | |
| 109 | }); | |
| 110 | ||
| 111 | test("a list taller than the card ends with how many are cut off", () => { | |
| 112 | const lines = Array.from({ length: 10 }, (_, i): [string, string] => [`f${i}`, "x"]); | |
| 113 | expect(fitLines(lines, 10)).toEqual(lines); | |
| 114 | const cut = fitLines(lines, 4); | |
| 115 | expect(cut).toHaveLength(4); | |
| 116 | expect(cut[3]![1]).toBe("+7 more (see /forges)"); | |
| 117 | }); |
new above
offline
Rows past area.h are dropped without a word. Scroll, or end with '+N more'.