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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/10106-a-forge-admin-s-page-lists-the-pinned-fo → main 5 commits fork 4 threads · 4 unresolved

  • Merged. mason/10106-a-forge-admin-s-page-lists-the-pinned-fo was merged into main as 547ca0f.

Review

Sign in to review this change.
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
77 - 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`.
88 - 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`).
99 - 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.
1011 - MCP: `pinned_forge` with `verb` `list`, `deploy`, `update`, `rollback` (and `check`) — the same words and the same checks (`src/actions/pinned.ts`).
1112
1213 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
4950
5051 - No canary result on a real account yet: the script is written, the run needs an owner's token (#8746, see Canary).
5152 - 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.
5353 - AWS and Azure drivers.
5454
5555 ## Canary (#8746)
forge.json +1 −1
@@ -4,7 +4,7 @@
44 "api.github.com"
55 ],
66 "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"],
88 "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"],
99 "run": "bun run src/main.ts",
1010 "install": "",
scripts/unreached-allow.json −1
@@ -1,7 +1,6 @@
11 [
22 "src/apps/cloud.ts",
33 "src/boards/machine.ts",
4 "src/core/teams_words.ts",
54 "src/crew/governor_log.ts",
65 "src/crew/tries.ts",
76 "src/flow/alarm_drift.ts",
src/core/pinned.ts +9
@@ -173,3 +173,12 @@ export const portOf = (p: Pick<Pinned, "config">): string => {
173173
174174 /** Where a pinned forge is served, once its droplet's address is known; empty before. */
175175 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[] = [
6666 "boards",
6767 "contribute",
6868 "deploy-to",
69 "pinned",
6970 "pipeline",
7071 "events",
7172 "bring",
src/http/pinned_routes.ts +19 −1
@@ -5,11 +5,14 @@
55 * `deployPinned` the API, MCP and terminal call, admin only and same-origin.
66 */
77
8 import { deployPinned } from "../actions/pinned.ts";
8 import { deployPinned, pinnedForges } from "../actions/pinned.ts";
99 import { listCloudAccounts } from "../core/cloud_account.ts";
10 import { lastCheckOf } from "../core/pinned.ts";
11 import { getUsersByIds } from "../core/users.ts";
1012 import { notFoundPage, signInFirst } from "../web/chrome.ts";
1113 import { field, readForm } from "../web/form.ts";
1214 import { flashTo, page } from "../web/layout.ts";
15 import { pinnedListPage } from "../web/pages/pinned.ts";
1316 import { deployPage, nameFor, type DeployForm } from "../web/pinned.ts";
1417 import type { Handler, Ctx } from "./context.ts";
1518 import type { Router } from "./router.ts";
@@ -54,7 +57,22 @@ async function deployToSubmit(ctx: Ctx): Promise<Response> {
5457 return flashTo(ctx, "/deploy-to", { message: `Deployed ${made.value.name} at ${made.value.version.slice(0, 12)}. It changes only when you press Update.` });
5558 }
5659
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
5774 export function pinnedRoutes(router: Router<Handler>): void {
75 router.add("GET", "/pinned", listPage);
5876 router.add("GET", "/deploy-to", deployToPage);
5977 router.add("POST", "/deploy-to", deployToSubmit);
6078 }
src/web/pages/cloud.ts +1 −1
@@ -153,7 +153,7 @@ export async function cloudPage(ctx: Ctx): Promise<Response> {
153153 ${settingsLayout(
154154 accountSections("cloud", { admin: true }),
155155 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>`,
157157 "Account settings",
158158 )}`,
159159 });
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 {
outdated

Box 1: last check time is missing. Health shows only running/failed.

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();
2020
2121 /** Top-level routes that are not pages, or are not worth a picture, and why. */
2222 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",
2324 "/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.

2425 "/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",
2526 "/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", () => {
107107 expect(p.state).toBe("running");
108108 });
109109 });
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs