Public: anyone can read this; the crew can change it.
The Release keeper panel on /releases speaks the home page's language, no boxed card #10008
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9998-the-release-keeper-panel-on-releases-spe → main 1 commit fork
3 files
changed +48
−4
docs/design/areas/releases.md +7
| @@ -8,3 +8,10 @@ narrow screen a row's text column shrinks and its title and meta lines end in an | ||
| 8 | 8 | page wider. Free text on the page (an owner's hold and its reason, a keeper's reason) sits inside |
| 9 | 9 | `[data-releases]` (an attribute, not a class), which breaks long words anywhere. The hold's reason input is capped at |
| 10 | 10 | 100% of its box. The keeper panel's own table keeps the existing `.table-scroll` box. No fixed width. No new class. |
| 11 | ||
| 12 | ## The Release keeper, no card (#9998) | |
| 13 | ||
| 14 | `keeperPanel()` in `src/web/pages/keeper-panel.ts` is a `<section class="settings-block" id="keeper">` with an | |
| 15 | `<h2>Release keeper</h2>`, not a `.card`: one plain `#keeper-blocked` line saying what holds the next cut, the | |
| 16 | releases table in its `.table-scroll`, and for an owner the Hold form and each owner hold's Lift form (same-origin | |
| 17 | POSTs, unchanged). No new class. `test/keeper-language.test.ts` holds the shape. |
src/web/pages/keeper-panel.ts +7 −4
| @@ -2,6 +2,7 @@ | ||
| 2 | 2 | * The release keeper on /releases (#7557): what stops the next cut, and |
| 3 | 3 | * each release it cut with its stage, the known-good version it rolls back |
| 4 | 4 | * to, and what the probes counted. Nothing when it has done nothing. |
| 5 | * A plain section under an h2, no card (#9998). | |
| 5 | 6 | */ |
| 6 | 7 | |
| 7 | 8 | import type { Database } from "bun:sqlite"; |
| @@ -10,7 +11,7 @@ import { secondsWords, withLandedToLive } from "../../keeper/landed.ts"; | ||
| 10 | 11 | import { listKeeperReleases, type Stage } from "../../keeper/record.ts"; |
| 11 | 12 | import { stallWords } from "../../perf/boot_stall.ts"; |
| 12 | 13 | import { html, when, type Html } from "../html.ts"; |
| 13 | import { badge, btn, card, time, type Tone } from "../ui.ts"; | |
| 14 | import { badge, btn, time, type Tone } from "../ui.ts"; | |
| 14 | 15 | |
| 15 | 16 | const STAGE: Record<Stage, { label: string; tone: Tone }> = { |
| 16 | 17 | rolling: { label: "rolling", tone: "attn" }, |
| @@ -19,7 +20,7 @@ const STAGE: Record<Stage, { label: string; tone: Tone }> = { | ||
| 19 | 20 | rolled_back: { label: "rolled back", tone: "danger" }, |
| 20 | 21 | }; |
| 21 | 22 | |
| 22 | /** `mayHold` is `mayRollBack`: the card then offers the owner's hold and its lift. */ | |
| 23 | /** `mayHold` is `mayRollBack`: the section then offers the owner's hold and its lift. */ | |
| 23 | 24 | export function keeperPanel(db: Database, mayHold = false): Html | undefined { |
| 24 | 25 | const releases = withLandedToLive(db, listKeeperReleases(db)); |
| 25 | 26 | const holds = standingHolds(db); |
| @@ -39,7 +40,8 @@ export function keeperPanel(db: Database, mayHold = false): Html | undefined { | ||
| 39 | 40 | <td>${r.secondsDark} s</td> |
| 40 | 41 | </tr>${when(r.reason !== "", () => html`<tr><td colspan="9" class="small muted">${r.reason}</td></tr>`)}`; |
| 41 | 42 | }); |
| 42 | return card(html`<h2>Release keeper</h2> | |
| 43 | return html`<section class="settings-block" id="keeper"> | |
| 44 | <h2>Release keeper</h2> | |
| 43 | 45 | <p class="small ${blocked === undefined ? "muted" : ""}" id="keeper-blocked">${blocked ?? "Nothing holds the next cut."}</p> |
| 44 | 46 | ${when(rows.length > 0, () => html`<div class="table-scroll" role="region" tabindex="0" aria-label="Keeper releases"><table class="table" data-sticky-lead> |
| 45 | 47 | <thead><tr><th>Version</th><th>Cut</th><th>Landed to live</th><th>Known good</th><th>Roll non-200</th><th>Probes / pages</th><th>Worst</th><th>Stall, first 5 min</th><th>Dark</th></tr></thead> |
| @@ -50,5 +52,6 @@ export function keeperPanel(db: Database, mayHold = false): Html | undefined { | ||
| 50 | 52 | )} |
| 51 | 53 | <form method="post" action="/releases/hold" class="small"> |
| 52 | 54 | <label>Hold releases <input name="reason" maxlength="${MAX_REASON}" required placeholder="why"></label> ${btn("Hold", { type: "submit" })} |
| 53 | </form>`)}`); | |
| 55 | </form>`)} | |
| 56 | </section>`; | |
| 54 | 57 | } |
test/keeper-language.test.ts added +34
| @@ -0,0 +1,34 @@ | ||
| 1 | /** | |
| 2 | * The Release keeper on /releases speaks the home page's language (#9998): | |
| 3 | * a plain section under an h2, no boxed card. What holds the next cut, the | |
| 4 | * releases table and the owner's hold forms are unchanged. | |
| 5 | */ | |
| 6 | import { expect, test } from "bun:test"; | |
| 7 | import { setHold } from "../src/keeper/hold.ts"; | |
| 8 | import { tick } from "../src/keeper/keeper.ts"; | |
| 9 | import { keeperPanel } from "../src/web/pages/keeper-panel.ts"; | |
| 10 | import { keeperForge } from "./helpers/keeper.ts"; | |
| 11 | ||
| 12 | const HEAD = | |
| 13 | "<thead><tr><th>Version</th><th>Cut</th><th>Landed to live</th><th>Known good</th><th>Roll non-200</th><th>Probes / pages</th><th>Worst</th><th>Stall, first 5 min</th><th>Dark</th></tr></thead>"; | |
| 14 | ||
| 15 | test("the keeper is a section with an h2, no card, with its blocked line and its table", async () => { | |
| 16 | const f = await keeperForge(); | |
| 17 | await tick(f.ports); | |
| 18 | const out = String(keeperPanel(f.app.db)); | |
| 19 | expect(out).toMatch(/^<section[^>]*>\s*<h2>Release keeper<\/h2>/); | |
| 20 | expect(out).not.toContain('class="card'); | |
| 21 | expect(out).toContain('id="keeper-blocked"'); | |
| 22 | expect(out).toContain("Nothing holds the next cut."); | |
| 23 | expect(out).toContain(HEAD); | |
| 24 | }); | |
| 25 | ||
| 26 | test("an owner's hold still shows with its same-origin Lift form, outside any card", async () => { | |
| 27 | const f = await keeperForge(); | |
| 28 | const h = setHold(f.app.db, { kind: "owner", version: "", reason: "freeze for the demo", at: f.world.now }); | |
| 29 | const out = String(keeperPanel(f.app.db, true)); | |
| 30 | expect(out).not.toContain('class="card'); | |
| 31 | expect(out).toContain(`<form method="post" action="/releases/hold/${h.id}/lift"`); | |
| 32 | expect(out).toContain('action="/releases/hold"'); | |
| 33 | expect(out).toMatch(/id="keeper-blocked">[^<]*freeze for the demo/); | |
| 34 | }); |