Public: anyone can read this; the crew can change it.
Settings › Hooks speaks the home page's language #9829
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9762-settings-hooks-speaks-the-home-page-s-la → main 2 commits fork
3 files
changed +141
−112
docs/design/areas/settings.md +2
| @@ -116,3 +116,5 @@ title, each with a glyph (Trusted with a check, Ends soon with a clock, Lapsed w | ||
| 116 | 116 | `data-state` is `open`, or `draft` once lapsed, and Remove and Revert landings sit in its trail as |
| 117 | 117 | `.row-fields` forms. With no entry, one plain sentence: no one is trusted, so every pull waits for review. The forms post to the same routes as |
| 118 | 118 | before. `test/trusted-sources.test.ts` holds the shape. |
| 119 | ||
| 120 | Settings › Hooks (`hooksPage()` in `src/web/pages/hooks.ts`, #9762) adds no component class and has no card or table: Webhooks and Add a webhook are each a `<section class="settings-block">` with an `<h2>`. Each hook is a `row()` in `<ul class="list setting-list">` (URL as title; events, state, added date and "unsigned" as meta; Send ping and a confirmed Delete in the trail), with its recent deliveries as nested child rows, Redeliver in the trail of a failed one. With no hook, one plain sentence. The secret is never drawn. Pinned by `test/hooks-rows.test.ts`. |
src/web/pages/hooks.ts +66 −112
| @@ -15,11 +15,10 @@ import { html, when } from "../html.ts"; | ||
| 15 | 15 | import { flashTo } from "../layout.ts"; |
| 16 | 16 | import { |
| 17 | 17 | btn, |
| 18 | card, | |
| 19 | 18 | confirmAction, |
| 20 | 19 | dot, |
| 21 | empty, | |
| 22 | 20 | field as formField, |
| 21 | row, | |
| 23 | 22 | time, |
| 24 | 23 | } from "../ui.ts"; |
| 25 | 24 | import { adminForm, settingsPageResponse, timedAdminRepo } from "./settings.ts"; |
| @@ -36,127 +35,82 @@ export function hooksPage(ctx: Ctx): Response { | ||
| 36 | 35 | const rendering = performance.now(); |
| 37 | 36 | const base = repoBase(repo); |
| 38 | 37 | |
| 38 | const deliveryRow = (hookId: string, d: ReturnType<typeof recentDeliveries>[number]) => | |
| 39 | row({ | |
| 40 | title: html`<span class="mono">${d.kind}</span>`, | |
| 41 | meta: html`${dot(d.state === "ok" ? "success" : d.state === "failed" ? "danger" : "attn")} | |
| 42 | ${d.state} · ${d.attempts} ${d.attempts === 1 ? "attempt" : "attempts"} · ${d.lastError ?? d.lastStatus ?? "—"}`, | |
| 43 | trail: when( | |
| 44 | d.state === "failed", | |
| 45 | () => html`<form method="post" action="${base}/settings/hooks/${hookId}/deliveries/${d.id}/redeliver"> | |
| 46 | ${btn("Redeliver", { size: "sm" })} | |
| 47 | </form>`, | |
| 48 | ), | |
| 49 | }); | |
| 50 | ||
| 39 | 51 | const list = |
| 40 | 52 | hooks.length === 0 |
| 41 | ? empty({ | |
| 42 | icon: "webhook", | |
| 43 | title: "No webhooks yet", | |
| 44 | note: "Every event this repository produces can be POSTed to a URL you control, signed and retried.", | |
| 45 | }) | |
| 46 | : html`${hooks.map((hook) => { | |
| 47 | const deliveries = recent.get(hook.id) ?? []; | |
| 48 | return card( | |
| 49 | deliveries.length === 0 | |
| 50 | ? html`<p class="muted small">No deliveries yet.</p>` | |
| 51 | : html`<div class="table-scroll"> | |
| 52 | <table class="table" data-dense> | |
| 53 | <thead> | |
| 54 | <tr> | |
| 55 | <th>Event</th> | |
| 56 | <th>State</th> | |
| 57 | <th>Attempts</th> | |
| 58 | <th>Last status</th> | |
| 59 | <th><span class="sr-only">Actions</span></th> | |
| 60 | </tr> | |
| 61 | </thead> | |
| 62 | <tbody> | |
| 63 | ${deliveries.map( | |
| 64 | (d) => | |
| 65 | html`<tr> | |
| 66 | <td class="mono">${d.kind}</td> | |
| 67 | <td> | |
| 68 | ${dot( | |
| 69 | d.state === "ok" | |
| 70 | ? "success" | |
| 71 | : d.state === "failed" | |
| 72 | ? "danger" | |
| 73 | : "attn", | |
| 74 | )} | |
| 75 | ${d.state} | |
| 76 | </td> | |
| 77 | <td>${d.attempts}</td> | |
| 78 | <td>${d.lastError ?? d.lastStatus ?? "—"}</td> | |
| 79 | <td> | |
| 80 | ${when( | |
| 81 | d.state === "failed", | |
| 82 | () => | |
| 83 | html`<form | |
| 84 | method="post" | |
| 85 | action="${base}/settings/hooks/${hook.id}/deliveries/${d.id}/redeliver" | |
| 86 | > | |
| 87 | ${btn("Redeliver", { size: "sm" })} | |
| 88 | </form>`, | |
| 89 | )} | |
| 90 | </td> | |
| 91 | </tr>`, | |
| 92 | )} | |
| 93 | </tbody> | |
| 94 | </table> | |
| 95 | </div>`, | |
| 96 | { | |
| 97 | pad: deliveries.length === 0, | |
| 98 | head: html`<span class="grow truncate mono" title="${hook.url}" | |
| 99 | >${hook.url}</span | |
| 100 | > | |
| 101 | <form method="post" action="${base}/settings/hooks/${hook.id}/ping"> | |
| 53 | ? html`<p class="small"> | |
| 54 | No webhooks yet. Every event this repository produces can be POSTed to a URL you control, signed and retried. | |
| 55 | </p>` | |
| 56 | : html`<ul class="list setting-list"> | |
| 57 | ${hooks.map((hook) => { | |
| 58 | const deliveries = recent.get(hook.id) ?? []; | |
| 59 | return row({ | |
| 60 | title: html`<span class="mono">${hook.url}</span>`, | |
| 61 | titleAttr: hook.url, | |
| 62 | meta: html`<span class="mono">${hook.kinds}</span> · ${dot(hook.active ? "success" : "attn")}${hook.active | |
| 63 | ? "active" | |
| 64 | : "paused"} · added ${time(hook.createdAt)}${when(!hook.hasSecret, () => html` · unsigned`)}${when( | |
| 65 | deliveries.length === 0, | |
| 66 | () => html` · No deliveries yet.`, | |
| 67 | )}`, | |
| 68 | trail: html`<form method="post" action="${base}/settings/hooks/${hook.id}/ping"> | |
| 102 | 69 | ${btn("Send ping", { size: "sm" })} |
| 103 | 70 | </form> |
| 104 | <span class="badge" data-variant="secondary" | |
| 105 | >${hook.kinds}</span | |
| 106 | > | |
| 107 | <span class="badge" | |
| 108 | >${dot(hook.active ? "success" : "attn")}${hook.active | |
| 109 | ? "active" | |
| 110 | : "paused"}</span | |
| 111 | > | |
| 112 | 71 | ${confirmAction({ |
| 113 | 72 | action: `${base}/settings/hooks/${hook.id}/delete`, |
| 114 | 73 | verb: "Delete webhook", |
| 115 | 74 | size: "sm", |
| 116 | 75 | })}`, |
| 117 | foot: html`<span class="muted small" | |
| 118 | >Added | |
| 119 | ${time(hook.createdAt)}${when( | |
| 120 | !hook.hasSecret, | |
| 121 | () => html` · unsigned`, | |
| 122 | )}</span | |
| 123 | >`, | |
| 124 | }, | |
| 125 | ); | |
| 126 | })}`; | |
| 76 | children: deliveries.map((d) => deliveryRow(hook.id, d)), | |
| 77 | }); | |
| 78 | })} | |
| 79 | </ul>`; | |
| 127 | 80 | |
| 128 | const create = card( | |
| 129 | html`<form method="post" action="${base}/settings/hooks"> | |
| 130 | ${formField({ | |
| 131 | label: "Payload URL", | |
| 132 | name: "url", | |
| 133 | type: "url", | |
| 134 | required: true, | |
| 135 | placeholder: "https://example.com/hook", | |
| 136 | hint: "Every delivery is a POST with a JSON body.", | |
| 137 | })} | |
| 138 | ${formField({ | |
| 139 | label: "Events", | |
| 140 | name: "kinds", | |
| 141 | value: "*", | |
| 142 | mono: true, | |
| 143 | hint: "A space-separated list of kinds; * means everything. e.g. push issue.* pull.merged", | |
| 144 | })} | |
| 145 | ${formField({ | |
| 146 | label: "Signing secret", | |
| 147 | name: "secret", | |
| 148 | mono: true, | |
| 149 | hint: "Optional — one is generated for you. Deliveries carry HMAC-SHA256 in X-Forge-Signature-256.", | |
| 150 | })} | |
| 151 | <div class="form-actions"> | |
| 152 | ${btn("Add webhook", { variant: "primary" })} | |
| 153 | </div> | |
| 154 | </form>`, | |
| 155 | { head: html`<h2>New webhook</h2>` }, | |
| 156 | ); | |
| 81 | const create = html`<form method="post" action="${base}/settings/hooks"> | |
| 82 | ${formField({ | |
| 83 | label: "Payload URL", | |
| 84 | name: "url", | |
| 85 | type: "url", | |
| 86 | required: true, | |
| 87 | placeholder: "https://example.com/hook", | |
| 88 | hint: "Every delivery is a POST with a JSON body.", | |
| 89 | })} | |
| 90 | ${formField({ | |
| 91 | label: "Events", | |
| 92 | name: "kinds", | |
| 93 | value: "*", | |
| 94 | mono: true, | |
| 95 | hint: "A space-separated list of kinds; * means everything. e.g. push issue.* pull.merged", | |
| 96 | })} | |
| 97 | ${formField({ | |
| 98 | label: "Signing secret", | |
| 99 | name: "secret", | |
| 100 | mono: true, | |
| 101 | hint: "Optional — one is generated for you. Deliveries carry HMAC-SHA256 in X-Forge-Signature-256.", | |
| 102 | })} | |
| 103 | <div class="form-actions">${btn("Add webhook", { variant: "primary" })}</div> | |
| 104 | </form>`; | |
| 157 | 105 | |
| 158 | const body = html`${list} | |
| 159 | <div class="mt">${create}</div>`; | |
| 106 | const body = html`<section class="settings-block"> | |
| 107 | <h2>Webhooks</h2> | |
| 108 | ${list} | |
| 109 | </section> | |
| 110 | <section class="settings-block"> | |
| 111 | <h2>Add a webhook</h2> | |
| 112 | ${create} | |
| 113 | </section>`; | |
| 160 | 114 | const response = settingsPageResponse(ctx, repo, "hooks", "Webhooks", body); |
| 161 | 115 | notePhase(ctx.request, "render", performance.now() - rendering); |
| 162 | 116 | return response; |
test/hooks-rows.test.ts added +73
| @@ -0,0 +1,73 @@ | ||
| 1 | /** Settings › Hooks is headings over rows, not cards (#9762). */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtempSync, rmSync } from "node:fs"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createRepo } from "../src/actions/repos.ts"; | |
| 7 | import { createApp, type App } from "../src/app.ts"; | |
| 8 | import { createUser } from "../src/core/users.ts"; | |
| 9 | import { createWebhook } from "../src/core/webhooks.ts"; | |
| 10 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 11 | import { quietLogs } from "./helpers/globals.ts"; | |
| 12 | ||
| 13 | quietLogs(); | |
| 14 | ||
| 15 | let root = ""; | |
| 16 | let app: App; | |
| 17 | let servers: Servers; | |
| 18 | let cookie = ""; | |
| 19 | let repoId = ""; | |
| 20 | ||
| 21 | async function page(): Promise<string> { | |
| 22 | const html = await (await fetch(`${servers.url}/alice/a/settings/hooks`, { headers: { cookie } })).text(); | |
| 23 | return html.slice(html.indexOf("<main"), html.indexOf("</main>")); | |
| 24 | } | |
| 25 | ||
| 26 | beforeAll(async () => { | |
| 27 | root = mkdtempSync(join(tmpdir(), "forge-hooks-rows-")); | |
| 28 | app = createApp({ dataDir: root, port: 0 }); | |
| 29 | const u = await createUser(app.db, { | |
| 30 | origin: "seed", trusted: true, username: "alice", email: "[email protected]", | |
| 31 | password: "a-good-passphrase", adminIfFirst: true, | |
| 32 | }); | |
| 33 | if (!u.ok) throw new Error(u.error); | |
| 34 | const r = await createRepo(app, u.value, { name: "a", visibility: "public" }); | |
| 35 | if (!r.ok) throw new Error("repo"); | |
| 36 | repoId = r.value.id; | |
| 37 | servers = await serve(app); | |
| 38 | const signin = await fetch(`${servers.url}/signin`, { | |
| 39 | method: "POST", | |
| 40 | headers: { origin: servers.url }, | |
| 41 | body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }), | |
| 42 | redirect: "manual", | |
| 43 | }); | |
| 44 | cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 45 | }); | |
| 46 | afterAll(() => { | |
| 47 | servers.stop(); | |
| 48 | app.close(); | |
| 49 | rmSync(root, { recursive: true, force: true }); | |
| 50 | }); | |
| 51 | ||
| 52 | test("with no hook: two headed blocks, one plain sentence, the add form, no box", async () => { | |
| 53 | const main = await page(); | |
| 54 | for (const h of ["Webhooks", "Add a webhook"]) expect(main).toContain(`<h2>${h}</h2>`); | |
| 55 | expect(main).toContain("No webhooks yet."); | |
| 56 | expect(main).toContain('action="/alice/a/settings/hooks"'); | |
| 57 | for (const boxed of ['class="card', "card-head", "card-body", 'class="empty', "<table"]) expect(main).not.toContain(boxed); | |
| 58 | }); | |
| 59 | ||
| 60 | test("a hook is a row with its URL, events, ping and a confirmed delete; the secret is not shown", async () => { | |
| 61 | const made = createWebhook(app.db, { repoId, url: "https://a.example/h", secret: "s3cret-value", kinds: "push" }); | |
| 62 | if (!made.ok) throw new Error("hook"); | |
| 63 | const main = await page(); | |
| 64 | const block = main.slice(main.indexOf("<h2>Webhooks</h2>"), main.indexOf("<h2>Add a webhook</h2>")); | |
| 65 | expect(block).toContain('<ul class="list setting-list">'); | |
| 66 | expect(block).toContain("https://a.example/h"); | |
| 67 | expect(block).toContain("push"); | |
| 68 | expect(block).toContain("No deliveries yet."); | |
| 69 | expect(block).toContain(`/alice/a/settings/hooks/${made.value.webhook.id}/ping`); | |
| 70 | expect(block).toContain(`/-/confirm?to=${encodeURIComponent(`/alice/a/settings/hooks/${made.value.webhook.id}/delete`)}`); | |
| 71 | expect(main).not.toContain("s3cret-value"); | |
| 72 | for (const boxed of ['class="card', "card-head", "<table"]) expect(main).not.toContain(boxed); | |
| 73 | }); |