Public: anyone can read this; the crew can change it.

The backups page's Server-Timing names its phases: session, each read, render #3449

Merged opened this

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

Merged

scout/forge:scout/3420-the-backups-page-s-server-timing-names-i → main 2 commits fork 3 threads · 3 unresolved

  • Merged. scout/3420-the-backups-page-s-server-timing-names-i was merged into main as dedee2d.

Review

Sign in to review this change.
2 files changed +136 −9
src/web/pages/backups.ts +33 −9
@@ -30,6 +30,12 @@ import type { Store } from "../../snapshot/store.ts";
3030 import { snapshotsOf, type SnapshotsHandle } from "../../snapshot/service.ts";
3131 import { lastSnapshotOutcome } from "../../snapshot/outcome.ts";
3232 import type { Ctx } from "../../http/context.ts";
33 import {
34 notePhase,
35 takeSessionTime,
36 timePhase,
37 timePhaseAsync,
38 } from "../../core/route_timing.ts";
3339 import { notFoundPage, settingsLayout, signInFirst } from "../chrome.ts";
3440 import { field, readForm, type Form } from "../form.ts";
3541 import { html, when, type Html } from "../html.ts";
@@ -242,15 +248,25 @@ export function domainsMoved(): Response {
242248 return redirect("/settings/backups#domains");
243249 }
244250
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
245255 export async function backupsPage(ctx: Ctx): Promise<Response> {
246256 const denied = requireAdmin(ctx);
247257 if (denied) return denied;
258 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
248259 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,
outdated

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.

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 shell( and rendered( nesting. It is cosmetic only; re-indent it so the diff reads cleanly.

269 html`${recoveryLine({
254270 reachable: false,
255271 unreachable: "no snapshot store is set",
256272 now: Date.now(),
@@ -259,8 +275,11 @@ export async function backupsPage(ctx: Ctx): Promise<Response> {
259275 title: "No snapshots are taken",
260276 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.",
261277 })}${form}`,
278 ),
262279 );
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}`));
264283 }
265284
266285 /** GET /settings/backups/live — the moving part alone, which the page refetches on an event. */
@@ -279,11 +298,14 @@ async function movingPart(
279298 ctx: Ctx,

The timed flag exists only so the /live fragment stays quiet. It works, but the two-way ternaries around the snapshots read are noisy. Consider having the page hand movingPart a read wrapper, or leave it as is if you prefer.

280299 svc: SnapshotsHandle,
281300 store: Store,
301 timed = false,
282302 ): Promise<Html> {
283303 const now = Date.now();
304 const read = <T>(name: string, work: () => T): T =>
305 timed ? timePhase(ctx.request, name, work) : work();
284306 // A failure is kept on disk, so it is shown after a restart too; this
285307 // 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));
287309 const last = svc.last();
288310 const failing =
289311 kept?.failingSince !== undefined
@@ -296,11 +318,13 @@ async function movingPart(
296318 let snapshots: Awaited<ReturnType<typeof svc.list>> = [];
297319 let listing: string | undefined;
298320 try {
299 snapshots = await svc.list();
321 snapshots = timed
322 ? await timePhaseAsync(ctx.request, "snapshots", () => svc.list())
323 : await svc.list();
300324 } catch (error) {
301325 listing = String(error);
302326 }
303 const d = lastDrill(ctx.app.db);
327 const d = read("drill", () => lastDrill(ctx.app.db));
304328 const recovery = recoveryLine({
305329 reachable: listing === undefined,
306330 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs