Public: anyone can read this; the crew can change it.
A forge admin's page lists the pinned forges: name, version, address, health and last change #10142
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/10106-a-forge-admin-s-page-lists-the-pinned-fo → main 5 commits fork 4 threads · 4 unresolved
10 files
changed +127
−5
docs/features/pinned-forge.md +1 −1
| @@ -7,6 +7,7 @@ A pinned forge is a forge the owner put on their own cloud. It has **no crew** a | ||
| 7 | 7 | - HTTP: `GET /api/v1/admin/pinned` (forges, their changes, the targets), `POST /api/v1/admin/pinned` (deploy), `POST /api/v1/admin/pinned/:name/update`, `POST /api/v1/admin/pinned/:name/rollback`. |
| 8 | 8 | - Terminal: palette → **Pinned forges** (forge admin): `d` deploy, `u` update, `r` roll back; it follows the bus, so a change made at any door shows at once (`src/tui/screens/pinned.ts`). |
| 9 | 9 | - Web: `/deploy-to` (forge admin; linked from Cloud accounts) is a server-rendered form for cloud account, app, source, version and name. The link `/deploy-to?app=owner/name&source=https://…&version=v1.2.3` (`deployToHref` in `src/web/pinned.ts`) lands on it with those filled in after one sign-in; nothing deploys until the button is pressed. The POST is same-origin checked and calls the same `deployPinned` (`src/http/pinned_routes.ts`). |
| 10 | - Web list: `/pinned` (forge admin; 404 otherwise) is server-rendered, one block of plain rows per forge: name, app, version, address (a link), health (the state), when the last check ran (the newest `pinned.check` event; `never checked` before the first), and the last change with who and when, from the same `pinnedForges` read as the API (`src/web/pages/pinned.ts`). The intro links `/deploy-to`; an empty list says so. | |
| 10 | 11 | - MCP: `pinned_forge` with `verb` `list`, `deploy`, `update`, `rollback` (and `check`) — the same words and the same checks (`src/actions/pinned.ts`). |
| 11 | 12 | |
| 12 | 13 | A deploy names a cloud account (Settings → Cloud accounts, **writes allowed**), the app (`owner/name`), a git `source` (https) and a `version` (a 40-character commit id or a tag like `v1.2.3` — never a branch, so nothing moves under it). |
| @@ -49,7 +50,6 @@ Each id is recorded the moment it exists. A first deploy that fails before a dro | ||
| 49 | 50 | |
| 50 | 51 | - No canary result on a real account yet: the script is written, the run needs an owner's token (#8746, see Canary). |
| 51 | 52 | - No TLS or domain: the URL is `http://<droplet IPv4>:<PORT or 3000>/`. |
| 52 | - No page in the web studio lists the pinned forges yet; `/deploy-to` deploys one, and the API, MCP and terminal list them. | |
| 53 | 53 | - AWS and Azure drivers. |
| 54 | 54 | |
| 55 | 55 | ## Canary (#8746) |
forge.json +1 −1
| @@ -4,7 +4,7 @@ | ||
| 4 | 4 | "api.github.com" |
| 5 | 5 | ], |
| 6 | 6 | "app": { |
| 7 | "looks": ["/", "/front", "/explore", "/board", "/notes", "/feed", "/review", "/new", "/audit", "/releases", "/apps", "/repos", "/map", "/system", "/talk", "/flow", "/resilience", "/settings", "/preview-admin/sample", "/preview-admin/sample/pulls/3", "/preview-admin/sample/next", "/preview-admin/sample/bets", "/preview-admin/sample/waves", "/preview-admin/sample/milestones"], | |
| 7 | "looks": ["/", "/front", "/explore", "/board", "/notes", "/feed", "/review", "/new", "/pinned", "/releases", "/apps", "/repos", "/map", "/system", "/talk", "/flow", "/resilience", "/settings", "/preview-admin/sample", "/preview-admin/sample/pulls/3", "/preview-admin/sample/next", "/preview-admin/sample/bets", "/preview-admin/sample/waves", "/preview-admin/sample/milestones"], | |
| 8 | 8 | "looksAsOwner": ["/", "/flow", "/talk", "/system", "/resilience", "/preview-admin/sample", "/preview-admin/sample/pulls/3", "/preview-admin/sample/next", "/preview-admin/sample/bets", "/preview-admin/sample/waves", "/preview-admin/sample/milestones"], |
| 9 | 9 | "run": "bun run src/main.ts", |
| 10 | 10 | "install": "", |
scripts/unreached-allow.json −1
| @@ -1,7 +1,6 @@ | ||
| 1 | 1 | [ |
| 2 | 2 | "src/apps/cloud.ts", |
| 3 | 3 | "src/boards/machine.ts", |
| 4 | "src/core/teams_words.ts", | |
| 5 | 4 | "src/crew/governor_log.ts", |
| 6 | 5 | "src/crew/tries.ts", |
| 7 | 6 | "src/flow/alarm_drift.ts", |
src/core/pinned.ts +9
| @@ -173,3 +173,12 @@ export const portOf = (p: Pick<Pinned, "config">): string => { | ||
| 173 | 173 | |
| 174 | 174 | /** Where a pinned forge is served, once its droplet's address is known; empty before. */ |
| 175 | 175 | export const urlOf = (p: Pick<Pinned, "address" | "config">): string => (p.address === "" ? "" : `http://${p.address}:${portOf(p)}/`); |
| 176 | ||
| 177 | /** When the newest `check` of this forge ran, from the event it recorded; undefined before the first (or once pruned). */ | |
| 178 | export function lastCheckOf(db: Database, name: string): string | undefined { | |
| 179 | return db | |
| 180 | .query<{ created_at: string }, [string]>( | |
| 181 | "SELECT created_at FROM event WHERE kind = 'pinned.check' AND json_extract(payload, '$.name') = ?1 ORDER BY seq DESC LIMIT 1", | |
| 182 | ) | |
| 183 | .get(name)?.created_at; | |
| 184 | } |
src/core/reserved.ts +1
| @@ -66,6 +66,7 @@ export const SYSTEM_NAMES: readonly string[] = [ | ||
| 66 | 66 | "boards", |
| 67 | 67 | "contribute", |
| 68 | 68 | "deploy-to", |
| 69 | "pinned", | |
| 69 | 70 | "pipeline", |
| 70 | 71 | "events", |
| 71 | 72 | "bring", |
src/http/pinned_routes.ts +19 −1
| @@ -5,11 +5,14 @@ | ||
| 5 | 5 | * `deployPinned` the API, MCP and terminal call, admin only and same-origin. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import { deployPinned } from "../actions/pinned.ts"; | |
| 8 | import { deployPinned, pinnedForges } from "../actions/pinned.ts"; | |
| 9 | 9 | import { listCloudAccounts } from "../core/cloud_account.ts"; |
| 10 | import { lastCheckOf } from "../core/pinned.ts"; | |
| 11 | import { getUsersByIds } from "../core/users.ts"; | |
| 10 | 12 | import { notFoundPage, signInFirst } from "../web/chrome.ts"; |
| 11 | 13 | import { field, readForm } from "../web/form.ts"; |
| 12 | 14 | import { flashTo, page } from "../web/layout.ts"; |
| 15 | import { pinnedListPage } from "../web/pages/pinned.ts"; | |
| 13 | 16 | import { deployPage, nameFor, type DeployForm } from "../web/pinned.ts"; |
| 14 | 17 | import type { Handler, Ctx } from "./context.ts"; |
| 15 | 18 | import type { Router } from "./router.ts"; |
| @@ -54,7 +57,22 @@ async function deployToSubmit(ctx: Ctx): Promise<Response> { | ||
| 54 | 57 | return flashTo(ctx, "/deploy-to", { message: `Deployed ${made.value.name} at ${made.value.version.slice(0, 12)}. It changes only when you press Update.` }); |
| 55 | 58 | } |
| 56 | 59 | |
| 60 | function listPage(ctx: Ctx): Response { | |
| 61 | if (!ctx.auth.user) return signInFirst(ctx); | |
| 62 | const forges = pinnedForges(ctx.app, ctx.auth.user); | |
| 63 | if (!forges.ok) return notFoundPage(ctx); | |
| 64 | const users = getUsersByIds(ctx.app.db, forges.value.flatMap((p) => p.changes.slice(0, 1).map((c) => c.by))); | |
| 65 | const names = new Map([...users].map(([id, u]) => [id, u.username])); | |
| 66 | const checks = new Map<string, string>(); | |
| 67 | for (const p of forges.value) { | |
| 68 | const at = lastCheckOf(ctx.app.db, p.name); | |
| 69 | if (at) checks.set(p.name, at); | |
| 70 | } | |
| 71 | return page({ ctx, title: "Pinned forges", body: pinnedListPage(forges.value, { names, checks }) }); | |
| 72 | } | |
| 73 | ||
| 57 | 74 | export function pinnedRoutes(router: Router<Handler>): void { |
| 75 | router.add("GET", "/pinned", listPage); | |
| 58 | 76 | router.add("GET", "/deploy-to", deployToPage); |
| 59 | 77 | router.add("POST", "/deploy-to", deployToSubmit); |
| 60 | 78 | } |
src/web/pages/cloud.ts +1 −1
| @@ -153,7 +153,7 @@ export async function cloudPage(ctx: Ctx): Promise<Response> { | ||
| 153 | 153 | ${settingsLayout( |
| 154 | 154 | accountSections("cloud", { admin: true }), |
| 155 | 155 | html`${accountsCard(accounts, reads, uses)}${readsCard(reads)} |
| 156 | <p class="muted small"><a href="/settings/cloud/audit">Every vendor call today</a> · <a href="/deploy-to">Deploy a pinned forge</a></p>`, | |
| 156 | <p class="muted small"><a href="/settings/cloud/audit">Every vendor call today</a> · <a href="/pinned">Pinned forges</a> · <a href="/deploy-to">Deploy a pinned forge</a></p>`, | |
| 157 | 157 | "Account settings", |
| 158 | 158 | )}`, |
| 159 | 159 | }); |
src/web/pages/pinned.ts added +43
| @@ -0,0 +1,43 @@ | ||
| 1 | /** | |
| 2 | * `/pinned` (#10106): the pinned forges, one block of plain rows each, read | |
| 3 | * from the same `pinnedForges` the API, MCP and terminal use. The last check | |
| 4 | * is when the newest `pinned.check` event for the forge was written. | |
| 5 | */ | |
| 6 | ||
| 7 | import type { PinnedView } from "../../actions/pinned.ts"; | |
| 8 | import { html, type Html } from "../html.ts"; | |
| 9 | import { badge, time, type Tone } from "../ui.ts"; | |
| 10 | ||
| 11 | const TONE: Record<string, Tone> = { running: "success", failed: "danger" }; | |
| 12 | ||
| 13 | /** What the page needs beyond the forges: user id to name, forge name to the time of its last check. */ | |
| 14 | export interface PinnedFacts { | |
| 15 | readonly names: Map<string, string>; | |
| 16 | readonly checks: Map<string, string>; | |
| 17 | } | |
| 18 | ||
| 19 | const fact = (label: string, value: Html): Html => html`<div class="status-row"><dt>${label}</dt><dd>${value}</dd></div>`; | |
| 20 | const none = (text: string): Html => html`<span class="muted">${text}</span>`; | |
| 21 | ||
| 22 | function forge(p: PinnedView, f: PinnedFacts): Html { | |
| 23 | const last = p.changes[0]; | |
| 24 | const checked = f.checks.get(p.name); | |
| 25 | return html`<section> | |
| 26 | <h2 class="mono">${p.name}</h2> | |
| 27 | <dl class="status-rows"> | |
| 28 | ${fact("App", html`<span class="mono">${p.app}</span>`)} | |
outdated
Principle 1: add one plain sentence saying what this page is for under the heading. Principle 8: the heading alone does not say it. |
||
| 29 | ${fact("Version", html`<span class="mono" title="${p.version}">${p.version.slice(0, 12)}</span>`)} | |
| 30 | ${fact("Address", p.url === "" ? none("not read yet") : html`<a class="mono" href="${p.url}" rel="noopener noreferrer">${p.url}</a>`)} | |
outdated
Principles 6 and 7: a card around a table is a box around a list. Use rows (a dl row per forge, or the existing row component) and drop card(). |
||
| 31 | ${fact("Health", badge(p.state, { tone: TONE[p.state] ?? "default" }))} | |
| 32 | ${fact("Last check", checked ? time(checked) : none("never checked"))} | |
| 33 | ${fact("Last change", last ? html`${last.kind} to <span class="mono">${last.toVersion.slice(0, 12)}</span>` : none("none"))} | |
| 34 | ${last ? fact("Changed by", html`${f.names.get(last.by) ?? last.by} ${time(last.at)}`) : null} | |
| 35 | </dl> | |
| 36 | </section>`; | |
| 37 | } | |
| 38 | ||
| 39 | export function pinnedListPage(forges: PinnedView[], f: PinnedFacts): Html { | |
| 40 | return html`<div class="page-head"><h1>Pinned forges</h1></div> | |
| 41 | <p class="muted">Forges you put on your own cloud, each on one version that changes only when you update it. <a href="/deploy-to">Deploy a pinned forge</a>.</p> | |
| 42 | ${forges.length === 0 ? html`<p role="status">No pinned forge yet. Deploy one to a cloud account that allows writes.</p>` : forges.map((p) => forge(p, f))}`; | |
| 43 | } |
test/forge-looks.test.ts +1
| @@ -20,6 +20,7 @@ quietLogs(); | ||
| 20 | 20 | |
| 21 | 21 | /** Top-level routes that are not pages, or are not worth a picture, and why. */ |
| 22 | 22 | const NOT_A_PAGE: Record<string, string> = { |
| 23 | "/audit": "no picture: its slot went to /pinned; /audit.csv is its download, covered by test/audit.test.ts", | |
| 23 | 24 | "/deploy-to": "a form for a forge admin that fills from its link; covered by test/pinned-page.test.ts", |
outdated
Box 4: do not exempt /pinned from looks; the issue asks for a look on the pull at 390px. |
||
| 24 | 25 | "/asked": "no picture: the cap of 24 looks went to /notes, the page that now leads with a person's thoughts; /asked is covered by test/asked.test.ts", |
| 25 | 26 | "/forges": "no picture: the cap of 24 looks went to the preview's /waves and /milestones; the page is covered by its own tests", |
test/pinned-page.test.ts +51
| @@ -107,3 +107,54 @@ describe("deploy-to page", () => { | ||
| 107 | 107 | expect(p.state).toBe("running"); |
| 108 | 108 | }); |
| 109 | 109 | }); |
| 110 | ||
| 111 | describe("pinned list page", () => { | |
| 112 | test("renders none, then one forge; a non-admin gets 404", async () => { | |
| 113 | const dataDir = await mkdtemp(join(tmpdir(), "forge-pinned-list-")); | |
| 114 | const app = createApp({ dataDir, port: 0 }); | |
| 115 | const servers = await serve(app); | |
| 116 | try { | |
| 117 | for (const [username, adminIfFirst] of [["alice", true], ["bob", false]] as const) { | |
| 118 | const made = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@f.test`, password: "a-good-passphrase", adminIfFirst }); | |
| 119 | if (!made.ok) throw new Error(made.error); | |
| 120 | } | |
| 121 | const adminTok = createToken(app.db, getUserByName(app.db, "alice")!.id, "admin", "admin").token; | |
| 122 | const bobTok = createToken(app.db, getUserByName(app.db, "bob")!.id, "admin", "admin").token; | |
| 123 | const get = (tok?: string) => fetch(servers.url + "/pinned", { redirect: "manual", headers: tok ? { authorization: `Bearer ${tok}` } : {} }); | |
| 124 | expect((await get()).status).toBe(303); | |
| 125 | expect((await get(bobTok)).status).toBe(404); | |
| 126 | const empty = await (await get(adminTok)).text(); | |
| 127 | expect(empty).toContain("No pinned forge yet"); | |
| 128 | expect(empty).toContain('href="/deploy-to"'); | |
| 129 | ||
| 130 | cloudNet.fetch = async () => new Response(JSON.stringify({ account: { email: "[email protected]", status: "active" } }), { status: 200 }); | |
| 131 | const added = await addAccount(app, getUserByName(app.db, "alice"), "digitalocean", "main", "dop_v1_9Zq4TxLm7Kp2Vb8nWcR5"); | |
| 132 | if (!added.ok) throw new Error(String(added.error)); | |
| 133 | allowWrite(app, getUserByName(app.db, "alice"), "digitalocean", "main", true); | |
| 134 | net.fetch = async (url) => | |
| 135 | new Response(JSON.stringify(new URL(url).pathname === "/v2/volumes" ? { volume: { id: "vol-1" } } : { droplet: { id: 7 } }), { status: 202 }); | |
| 136 | const r = await fetch(servers.url + "/deploy-to", { | |
| 137 | method: "POST", redirect: "manual", | |
| 138 | headers: { authorization: `Bearer ${adminTok}`, origin: servers.url, "content-type": "application/x-www-form-urlencoded" }, | |
| 139 | body: new URLSearchParams({ name: "acme", account: "main", app: "alice/shop", source: SOURCE, version: V }), | |
| 140 | }); | |
| 141 | expect(r.status).toBe(303); | |
| 142 | const t = await (await get(adminTok)).text(); | |
| 143 | expect(t).toContain("acme"); | |
| 144 | expect(t).toContain("alice/shop"); | |
| 145 | expect(t).toContain(V.slice(0, 12)); | |
| 146 | expect(t).toContain("deploy to"); | |
| 147 | expect(t).toContain("alice"); | |
| 148 | expect(t).toContain("never checked"); | |
| 149 | expect(t).not.toContain("<table"); | |
| 150 | app.bus.append({ kind: "pinned.check", actorId: getUserByName(app.db, "alice")!.id, payload: { name: "acme", state: "running", healthy: true } }); | |
| 151 | const checked = await (await get(adminTok)).text(); | |
| 152 | expect(checked).not.toContain("never checked"); | |
| 153 | expect(checked).toContain("Last check"); | |
| 154 | expect(t).not.toContain("No pinned forge yet"); | |
| 155 | } finally { | |
| 156 | await servers.stop(); | |
| 157 | await rm(dataDir, { recursive: true, force: true }); | |
| 158 | } | |
| 159 | }, 30_000); | |
| 160 | }); |
Box 1: last check time is missing. Health shows only running/failed.