Public: anyone can read this; the crew can change it.
The backups page's Server-Timing names its phases: session, each read, render #3449
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3420-the-backups-page-s-server-timing-names-i → main 2 commits fork 3 threads · 3 unresolved
2 files
changed +136
−9
src/web/pages/backups.ts +33 −9
| @@ -30,6 +30,12 @@ import type { Store } from "../../snapshot/store.ts"; | ||
| 30 | 30 | import { snapshotsOf, type SnapshotsHandle } from "../../snapshot/service.ts"; |
| 31 | 31 | import { lastSnapshotOutcome } from "../../snapshot/outcome.ts"; |
| 32 | 32 | import type { Ctx } from "../../http/context.ts"; |
| 33 | import { | |
| 34 | notePhase, | |
| 35 | takeSessionTime, | |
| 36 | timePhase, | |
| 37 | timePhaseAsync, | |
| 38 | } from "../../core/route_timing.ts"; | |
| 33 | 39 | import { notFoundPage, settingsLayout, signInFirst } from "../chrome.ts"; |
| 34 | 40 | import { field, readForm, type Form } from "../form.ts"; |
| 35 | 41 | import { html, when, type Html } from "../html.ts"; |
| @@ -242,15 +248,25 @@ export function domainsMoved(): Response { | ||
| 242 | 248 | return redirect("/settings/backups#domains"); |
| 243 | 249 | } |
| 244 | 250 | |
| 251 | /** Build the page and name the time it took as `render`. */ | |
| 252 | const rendered = (ctx: Ctx, build: () => Response): Response => | |
| 253 | timePhase(ctx.request, "render", build); | |
| 254 | ||
| 245 | 255 | export async function backupsPage(ctx: Ctx): Promise<Response> { |
| 246 | 256 | const denied = requireAdmin(ctx); |
| 247 | 257 | if (denied) return denied; |
| 258 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 248 | 259 | const svc = snapshotsOf(ctx.app); |
| 249 | const form = html`${destinationCard(ctx)}${mirrorCard(ctx)}${domainsCard(ctx)}`; | |
| 250 | if (svc === undefined || svc.store === undefined) | |
| 251 | return shell( | |
| 252 | ctx, | |
| 253 | html`${recoveryLine({ | |
| 260 | const form = html`${timePhase(ctx.request, "destination", () => destinationCard(ctx))}${timePhase( | |
| 261 | ctx.request, | |
| 262 | "mirror", | |
| 263 | () => mirrorCard(ctx), | |
| 264 | )}${timePhase(ctx.request, "domains", () => domainsCard(ctx))}`; | |
| 265 | if (svc === undefined || svc.store === undefined) { | |
| 266 | return rendered(ctx, () => | |
| 267 | shell( | |
| 268 | ctx, | |
The template body here is not indented under the new |
||
| 269 | html`${recoveryLine({ | |
| 254 | 270 | reachable: false, |
| 255 | 271 | unreachable: "no snapshot store is set", |
| 256 | 272 | now: Date.now(), |
| @@ -259,8 +275,11 @@ export async function backupsPage(ctx: Ctx): Promise<Response> { | ||
| 259 | 275 | title: "No snapshots are taken", |
| 260 | 276 | note: "This forge was given nowhere to put them. On the platform an app's bucket (S3_*) is the place; on a machine, FORGE_SNAPSHOT_DIR names a directory. Seal a bucket below, or restart with one set, and a snapshot is taken every night at 08:00 UTC.", |
| 261 | 277 | })}${form}`, |
| 278 | ), | |
| 262 | 279 | ); |
| 263 | return shell(ctx, html`${await movingPart(ctx, svc, svc.store)}${form}`); | |
| 280 | } | |
| 281 | const moving = await movingPart(ctx, svc, svc.store, true); | |
| 282 | return rendered(ctx, () => shell(ctx, html`${moving}${form}`)); | |
| 264 | 283 | } |
| 265 | 284 | |
| 266 | 285 | /** GET /settings/backups/live — the moving part alone, which the page refetches on an event. */ |
| @@ -279,11 +298,14 @@ async function movingPart( | ||
| 279 | 298 | ctx: Ctx, |
The |
||
| 280 | 299 | svc: SnapshotsHandle, |
| 281 | 300 | store: Store, |
| 301 | timed = false, | |
| 282 | 302 | ): Promise<Html> { |
| 283 | 303 | const now = Date.now(); |
| 304 | const read = <T>(name: string, work: () => T): T => | |
| 305 | timed ? timePhase(ctx.request, name, work) : work(); | |
| 284 | 306 | // A failure is kept on disk, so it is shown after a restart too; this |
| 285 | 307 | // process's own failure is newer when the file could not be written. |
| 286 | const kept = lastSnapshotOutcome(ctx.app.config.dataDir); | |
| 308 | const kept = read("outcome", () => lastSnapshotOutcome(ctx.app.config.dataDir)); | |
| 287 | 309 | const last = svc.last(); |
| 288 | 310 | const failing = |
| 289 | 311 | kept?.failingSince !== undefined |
| @@ -296,11 +318,13 @@ async function movingPart( | ||
| 296 | 318 | let snapshots: Awaited<ReturnType<typeof svc.list>> = []; |
| 297 | 319 | let listing: string | undefined; |
| 298 | 320 | try { |
| 299 | snapshots = await svc.list(); | |
| 321 | snapshots = timed | |
| 322 | ? await timePhaseAsync(ctx.request, "snapshots", () => svc.list()) | |
| 323 | : await svc.list(); | |
| 300 | 324 | } catch (error) { |
| 301 | 325 | listing = String(error); |
| 302 | 326 | } |
| 303 | const d = lastDrill(ctx.app.db); | |
| 327 | const d = read("drill", () => lastDrill(ctx.app.db)); | |
| 304 | 328 | const recovery = recoveryLine({ |
| 305 | 329 | reachable: listing === undefined, |
| 306 | 330 | unreachable: "the snapshot store could not be listed", |
test/backups-timing.test.ts added +103
| @@ -0,0 +1,103 @@ | ||
| 1 | /** The backups page names its phases in Server-Timing (#3420). */ | |
| 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 { createApp, type App } from "../src/app.ts"; | |
| 7 | import { createUser } from "../src/core/users.ts"; | |
| 8 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 9 | import { startSnapshots, type SnapshotsHandle } from "../src/snapshot/service.ts"; | |
| 10 | import { dirStore } from "../src/snapshot/store.ts"; | |
| 11 | import { quietLogs } from "./helpers/globals.ts"; | |
| 12 | ||
| 13 | quietLogs(); | |
| 14 | ||
| 15 | let root = ""; | |
| 16 | let app: App; | |
| 17 | let svc: SnapshotsHandle; | |
| 18 | let servers: Servers; | |
| 19 | let cookie = ""; | |
| 20 | ||
| 21 | beforeAll(async () => { | |
| 22 | root = mkdtempSync(join(tmpdir(), "forge-backups-timing-")); | |
| 23 | app = createApp({ dataDir: root, port: 0 }); | |
| 24 | const u = await createUser(app.db, { | |
| 25 | origin: "seed", | |
| 26 | trusted: true, | |
| 27 | username: "alice", | |
| 28 | email: "[email protected]", | |
| 29 | password: "a-good-passphrase", | |
| 30 | adminIfFirst: true, | |
| 31 | }); | |
| 32 | if (!u.ok) throw new Error(u.error); | |
| 33 | svc = startSnapshots(app, dirStore(join(root, "store")), { version: "t", nightly: false }); | |
| 34 | servers = await serve(app); | |
| 35 | const r = await fetch(`${servers.url}/signin`, { | |
| 36 | method: "POST", | |
| 37 | headers: { origin: servers.url }, | |
| 38 | body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }), | |
| 39 | redirect: "manual", | |
| 40 | }); | |
| 41 | cookie = (r.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 42 | }); | |
| 43 | ||
| 44 | afterAll(async () => { | |
| 45 | servers.stop(); | |
| 46 | await svc.stop(); | |
| 47 | app.close(); | |
| 48 | rmSync(root, { recursive: true, force: true }); | |
| 49 | }); | |
| 50 | ||
| 51 | test("the backups page names session, each read and render once each", async () => { | |
| 52 | const res = await fetch(`${servers.url}/settings/backups`, { headers: { cookie } }); | |
| 53 | expect(res.status).toBe(200); | |
| 54 | expect(await res.text()).toContain('id="backups-live"'); | |
| 55 | const parts = (res.headers.get("server-timing") ?? "").split(", "); | |
| 56 | const names = parts.map((p) => p.split(";")[0]!); | |
| 57 | for (const n of ["session", "destination", "mirror", "domains", "outcome", "snapshots", "drill", "render"]) { | |
| 58 | expect(names.filter((x) => x === n).length).toBe(1); | |
| 59 | const part = parts.find((p) => p.startsWith(`${n};`))!; | |
| 60 | expect(Number(/dur=([\d.]+)/.exec(part)?.[1])).not.toBeNaN(); | |
| 61 | } | |
| 62 | }); | |
| 63 | ||
| 64 | test("the live fragment adds no phases", async () => { | |
| 65 | const res = await fetch(`${servers.url}/settings/backups/live`, { headers: { cookie } }); | |
| 66 | expect(res.status).toBe(200); | |
| 67 | expect(res.headers.get("server-timing") ?? "").not.toContain("snapshots"); | |
| 68 | }); | |
| 69 | ||
| 70 | test("with no snapshot store the page names only the reads it makes", async () => { | |
| 71 | const bare = mkdtempSync(join(tmpdir(), "forge-backups-timing-bare-")); | |
| 72 | const other = createApp({ dataDir: bare, port: 0 }); | |
| 73 | const made = await createUser(other.db, { | |
| 74 | origin: "seed", | |
| 75 | trusted: true, | |
| 76 | username: "alice", | |
| 77 | email: "[email protected]", | |
| 78 | password: "a-good-passphrase", | |
| 79 | adminIfFirst: true, | |
| 80 | }); | |
| 81 | if (!made.ok) throw new Error(made.error); | |
| 82 | const bareServers = await serve(other); | |
| 83 | try { | |
| 84 | const r = await fetch(`${bareServers.url}/signin`, { | |
| 85 | method: "POST", | |
| 86 | headers: { origin: bareServers.url }, | |
| 87 | body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }), | |
| 88 | redirect: "manual", | |
| 89 | }); | |
| 90 | const own = (r.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 91 | const res = await fetch(`${bareServers.url}/settings/backups`, { headers: { cookie: own } }); | |
| 92 | expect(res.status).toBe(200); | |
| 93 | expect(await res.text()).toContain("No snapshots are taken"); | |
| 94 | const names = (res.headers.get("server-timing") ?? "").split(", ").map((p) => p.split(";")[0]!); | |
| 95 | for (const n of ["session", "destination", "mirror", "domains", "render"]) | |
| 96 | expect(names.filter((x) => x === n).length).toBe(1); | |
| 97 | for (const n of ["outcome", "snapshots", "drill"]) expect(names).not.toContain(n); | |
| 98 | } finally { | |
| 99 | bareServers.stop(); | |
| 100 | other.close(); | |
| 101 | rmSync(bare, { recursive: true, force: true }); | |
| 102 | } | |
| 103 | }); |
new above
offline
This branch is promised in the pull ("only session, destination, mirror, domains and render") but nothing tests it. Revert the timing here and no test fails. Add the test described in the review body.