Public: anyone can read this; the crew can change it.
The board shows the forge's health as one line: “all fine”, or what needs you, live #358
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/345-the-board-shows-the-forge-s-health-as-on → main 1 commit fork
2 files
changed +126
−0
src/web/pages/board.ts +50
| @@ -13,6 +13,9 @@ import { | ||
| 13 | 13 | type Decision, |
| 14 | 14 | type DecisionKind, |
| 15 | 15 | } from "../../core/decisions.ts"; |
| 16 | import { appsRuntime } from "../../apps/runtime.ts"; | |
| 17 | import { listApps } from "../../core/apps.ts"; | |
| 18 | import { vitals, type Vitals } from "../../kernel/vitals.ts"; | |
| 16 | 19 | import { getIssue, getIssuesByIds, type Issue } from "../../core/issues.ts"; |
| 17 | 20 | import { looksForApps } from "../../core/looks.ts"; |
| 18 | 21 | import { listPulls, mergedPullsForRepos, type Pull } from "../../core/pulls.ts"; |
| @@ -172,6 +175,7 @@ export function boardPage(ctx: Ctx): Response { | ||
| 172 | 175 | ></span> |
| 173 | 176 | <time class="board-clock" id="board-clock"></time> |
| 174 | 177 | </header> |
| 178 | ${forgeStrip(ctx)} | |
| 175 | 179 | <div class="board-grid"> |
| 176 | 180 | ${working(ctx, scope)} ${landed(ctx, scope)} ${decided(ctx, scope)} |
| 177 | 181 | ${today(ctx, scope)} |
| @@ -217,6 +221,49 @@ function region( | ||
| 217 | 221 | </section>`; |
| 218 | 222 | } |
| 219 | 223 | |
| 224 | // ---- the forge ---- | |
| 225 | ||
| 226 | /** Where a need can be acted on, when it can: a stuck run's page. */ | |
| 227 | function needHref(need: string, v: Vitals): string | undefined { | |
| 228 | if (/ stuck\.$/.test(need) && v.stuck[0] !== undefined) | |
| 229 | return `/${v.stuck[0].repo}/runs/${v.stuck[0].runId}`; | |
| 230 | return undefined; | |
| 231 | } | |
| 232 | ||
| 233 | /** | |
| 234 | * The forge's health as one line, for a site admin only: *All fine*, or | |
| 235 | * each thing that needs them. A live region of its own; nobody else gets | |
| 236 | * the element or pays for the reads. | |
| 237 | */ | |
| 238 | function forgeStrip(ctx: Ctx): Html | null { | |
| 239 | if (!ctx.auth.user?.isAdmin) return null; | |
| 240 | const app = ctx.app; | |
| 241 | const runtime = appsRuntime(app); | |
| 242 | const v = vitals({ | |
| 243 | db: app.db, | |
| 244 | dataDir: app.config.dataDir, | |
| 245 | envs: runtime | |
| 246 | ? listApps(app.db).flatMap((record) => runtime.status(record)) | |
| 247 | : [], | |
| 248 | }); | |
| 249 | return html`<div | |
| 250 | class="board-forge" | |
| 251 | id="forge" | |
| 252 | data-live="run.*,app.*" | |
| 253 | data-ok="${v.ok ? "yes" : "no"}" | |
| 254 | role="status" | |
| 255 | > | |
| 256 | ${v.ok | |
| 257 | ? html`<span class="board-forge-ok">All fine</span>` | |
| 258 | : v.needs.map((need) => { | |
| 259 | const href = needHref(need, v); | |
| 260 | return href === undefined | |
| 261 | ? html`<span class="board-forge-need">${need}</span>` | |
| 262 | : html`<a class="board-forge-need" href="${href}">${need}</a>`; | |
| 263 | })} | |
| 264 | </div>`; | |
| 265 | } | |
| 266 | ||
| 220 | 267 | // ---- working now ---- |
| 221 | 268 | |
| 222 | 269 | function working(ctx: Ctx, scope: BoardScope): Html { |
| @@ -519,6 +566,9 @@ export const boardCss = /* css */ ` | ||
| 519 | 566 | .board-live[data-state="open"] .board-dot { background: var(--success); box-shadow: 0 0 0 3px color-mix(in srgb, var(--success) 22%, transparent); } |
| 520 | 567 | .board-live[data-state="reconnecting"] .board-dot { background: var(--attn); } |
| 521 | 568 | .board-clock { font-family: var(--mono); font-size: 13px; font-variant-numeric: tabular-nums; color: var(--muted-fg); min-width: 5ch; text-align: right; } |
| 569 | .board-forge { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 16px; min-height: 28px; padding: 6px var(--pad) 0; font-size: 13px; color: var(--faint); } | |
| 570 | .board-forge[data-ok="no"] { color: var(--attn); } | |
| 571 | a.board-forge-need { color: inherit; text-decoration: underline; text-underline-offset: 3px; } | |
| 522 | 572 | .board-grid { flex: 1; display: grid; gap: var(--pad); padding: 12px var(--pad) var(--pad); min-height: 0; } |
| 523 | 573 | .board-region { min-width: 0; min-height: 0; display: flex; flex-direction: column; } |
| 524 | 574 | .board-h { |
test/board.test.ts +76
| @@ -280,3 +280,79 @@ describe("the board", () => { | ||
| 280 | 280 | expect(home).toContain("Board"); |
| 281 | 281 | }); |
| 282 | 282 | }); |
| 283 | ||
| 284 | describe("the board's forge strip", () => { | |
| 285 | let dataDir: string; | |
| 286 | let app: App; | |
| 287 | let servers: Servers; | |
| 288 | let admin: User; | |
| 289 | let cookies: Record<string, string> = {}; | |
| 290 | ||
| 291 | async function signIn(name: string): Promise<string> { | |
| 292 | const res = await fetch(`${servers.url}/signin`, { | |
| 293 | method: "POST", | |
| 294 | body: new URLSearchParams({ | |
| 295 | username: name, | |
| 296 | password: "a-good-passphrase", | |
| 297 | }), | |
| 298 | redirect: "manual", | |
| 299 | }); | |
| 300 | return (res.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 301 | } | |
| 302 | const board = async (who: string) => | |
| 303 | ( | |
| 304 | await fetch(`${servers.url}/board`, { | |
| 305 | headers: { cookie: cookies[who]! }, | |
| 306 | }) | |
| 307 | ).text(); | |
| 308 | ||
| 309 | beforeAll(async () => { | |
| 310 | dataDir = await mkdtemp(join(tmpdir(), "forge-board-strip-")); | |
| 311 | app = createApp({ dataDir, port: 0 }); | |
| 312 | servers = await serve(app); | |
| 313 | for (const [username, adminIfFirst] of [ | |
| 314 | ["root", true], | |
| 315 | ["bob", false], | |
| 316 | ] as const) { | |
| 317 | const u = await createUser(app.db, { | |
| 318 | username, | |
| 319 | email: `${username}@forge.local`, | |
| 320 | password: "a-good-passphrase", | |
| 321 | adminIfFirst, | |
| 322 | }); | |
| 323 | if (!u.ok) throw new Error(u.error); | |
| 324 | if (username === "root") admin = u.value; | |
| 325 | cookies[username] = await signIn(username); | |
| 326 | } | |
| 327 | }); | |
| 328 | ||
| 329 | afterAll(async () => { | |
| 330 | servers.stop(); | |
| 331 | app.close(); | |
| 332 | await rm(dataDir, { recursive: true, force: true }); | |
| 333 | }); | |
| 334 | ||
| 335 | test("an admin reads All fine; a stuck run becomes a linked sentence; a non-admin sees nothing", async () => { | |
| 336 | const fine = await board("root"); | |
| 337 | expect(fine).toMatch(/id="forge"\s+data-live="run\.\*,app\.\*"/); | |
| 338 | expect(fine).toContain("All fine"); | |
| 339 | ||
| 340 | const made = await createRepo(app, admin, { name: "stuck" }); | |
| 341 | if (!made.ok) throw new Error(String(made.error)); | |
| 342 | const run = createRun(app.db, { repoId: made.value.id, kind: "agent" }); | |
| 343 | claimRun(app.db, run.id); | |
| 344 | // Its last heartbeat is long past: the lease has lapsed. | |
| 345 | app.db | |
| 346 | .query("UPDATE run SET heartbeat_at = ?1 WHERE id = ?2") | |
| 347 | .run("2020-01-01T00:00:00.000Z", run.id); | |
| 348 | const stuck = await board("root"); | |
| 349 | expect(stuck).not.toContain("All fine"); | |
| 350 | expect(stuck).toContain("1 run is stuck."); | |
| 351 | expect(stuck).toContain(`href="/root/stuck/runs/${run.id}"`); | |
| 352 | ||
| 353 | const other = await board("bob"); | |
| 354 | expect(other).not.toContain('id="forge"'); | |
| 355 | expect(other).not.toContain("All fine"); | |
| 356 | expect(other).not.toContain("stuck."); | |
| 357 | }); | |
| 358 | }); |
new above
offline