Public: anyone can read this; the crew can change it.
An app's settings page's Server-Timing names its phases: session, vitals, domains, render #3243
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3240-an-app-s-settings-page-s-server-timing-n → main 1 commit fork 1 thread · 1 unresolved
2 files
changed +66
−9
src/web/pages/settings.ts +14 −9
| @@ -51,6 +51,7 @@ import { | ||
| 51 | 51 | updateRepoSettings, |
| 52 | 52 | } from "../../actions/repos.ts"; |
| 53 | 53 | import { appsRuntime } from "../../apps/runtime.ts"; |
| 54 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; | |
| 54 | 55 | import { appVitals, shiftText, speedText as ms, type AppVitals } from "../../apps/vitals.ts"; |
| 55 | 56 | import type { PreviewData } from "../../apps/data.ts"; |
| 56 | 57 | import { |
| @@ -581,10 +582,15 @@ function appSettingsView( | ||
| 581 | 582 | }, |
| 582 | 583 | ); |
| 583 | 584 | if (record === undefined) return form; |
| 584 | const domains = listAppDomains(ctx.app, ctx.auth.user, repo); | |
| 585 | const domains = timePhase(ctx.request, "domains", () => | |
| 586 | listAppDomains(ctx.app, ctx.auth.user, repo), | |
| 587 | ); | |
| 588 | const vitals = timePhase(ctx.request, "vitals", () => | |
| 589 | appVitals(ctx.app.db, record.id), | |
| 590 | ); | |
| 585 | 591 | return html`${form} |
| 586 | 592 | ${when(listed?.ok === true, () => html`<div class="mt">${appMembersView(base, values.access, named)}</div>`)} |
| 587 | <div class="mt">${appVisitorsView(appVitals(ctx.app.db, record.id))}</div> | |
| 593 | <div class="mt">${appVisitorsView(vitals)}</div> | |
| 588 | 594 | <div class="mt"> |
| 589 | 595 | ${appEnvView(base, listAppEnvNames(ctx.app.db, record.id))} |
| 590 | 596 | </div> |
| @@ -880,14 +886,13 @@ const appValues = (repo: Repo, record: AppRecord | undefined): AppValues => ({ | ||
| 880 | 886 | export function appSettingsPage(ctx: Ctx): Response { |
| 881 | 887 | const repo = loadAdminRepo(ctx); |
| 882 | 888 | if (!repo) return repoNotFound(ctx); |
| 889 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 883 | 890 | const record = getAppByRepo(ctx.app.db, repo.id); |
| 884 | return settingsPageResponse( | |
| 885 | ctx, | |
| 886 | repo, | |
| 887 | "app", | |
| 888 | "App", | |
| 889 | appSettingsView(ctx, repo, record, appValues(repo, record)), | |
| 890 | ); | |
| 891 | const view = appSettingsView(ctx, repo, record, appValues(repo, record)); | |
| 892 | const rendering = performance.now(); | |
| 893 | const response = settingsPageResponse(ctx, repo, "app", "App", view); | |
| 894 | notePhase(ctx.request, "render", performance.now() - rendering); | |
| 895 | return response; | |
| 891 | 896 | } |
| 892 | 897 | |
| 893 | 898 | // POST /:owner/:name/settings/app — enable, or change. |
test/app-settings-timing.test.ts added +52
| @@ -0,0 +1,52 @@ | ||
| 1 | /** An app's settings page names its phases in Server-Timing (#3240). */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { createRepo } from "../src/actions/repos.ts"; | |
| 8 | import { createApp as insertApp } from "../src/core/apps.ts"; | |
| 9 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 10 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 11 | ||
| 12 | let dataDir = ""; | |
| 13 | let app: App, servers: Servers, bearer = ""; | |
| 14 | ||
| 15 | beforeAll(async () => { | |
| 16 | dataDir = await mkdtemp(join(tmpdir(), "forge-atime-")); | |
| 17 | app = createApp({ dataDir, port: 0 }); | |
| 18 | servers = await serve(app); | |
| 19 | const admin = await seedAdmin(app, { username: "alice", email: "[email protected]", password: "correct-horse-battery" }); | |
| 20 | if (!admin.ok) throw new Error(admin.error); | |
| 21 | const made = await createRepo(app, admin.value, { name: "notes", visibility: "public", description: "" }); | |
| 22 | if (!made.ok) throw new Error(String(made.error)); | |
| 23 | const row = insertApp(app.db, { repoId: made.value.id, slug: "notes", access: "public" }); | |
| 24 | if (!row.ok) throw new Error(row.error); | |
| 25 | const minted = await fetch(`${servers.url}/api/v1/tokens`, { | |
| 26 | method: "POST", | |
| 27 | headers: { "content-type": "application/json" }, | |
| 28 | body: JSON.stringify({ adminConfirm: true, username: "alice", password: "correct-horse-battery", name: "cli", scope: "admin" }), | |
| 29 | }); | |
| 30 | bearer = ((await minted.json()) as { token: string }).token; | |
| 31 | }); | |
| 32 | ||
| 33 | afterAll(async () => { | |
| 34 | servers.stop(); | |
| 35 | app.close(); | |
| 36 | await rm(dataDir, { recursive: true, force: true }); | |
| 37 | }); | |
| 38 | ||
| 39 | test("the app settings page names session, vitals, domains and render once each", async () => { | |
| 40 | const res = await fetch(`${servers.url}/alice/notes/settings/app`, { headers: { authorization: `Bearer ${bearer}` } }); | |
| 41 | const page = await res.text(); | |
| 42 | expect(res.status).toBe(200); | |
| 43 | expect(page).toContain("Domains"); | |
| 44 | expect(res.headers.get("x-content-type-options")).toBe("nosniff"); | |
| 45 | const parts = (res.headers.get("server-timing") ?? "").split(", "); | |
| 46 | const names = parts.map((p) => p.split(";")[0]!); | |
| 47 | for (const n of ["session", "vitals", "domains", "render"]) { | |
| 48 | expect(names.filter((x) => x === n).length).toBe(1); | |
| 49 | const part = parts.find((p) => p.startsWith(`${n};`))!; | |
| 50 | expect(Number(/dur=([\d.]+)/.exec(part)?.[1])).not.toBeNaN(); | |
| 51 | } | |
| 52 | }); |
new above
offline
renderonly timessettingsPageResponse. The view is built before it, and the DB reads are timed on their own. If the view builder does other eager work, that time is not named. Fine for now, but it is worth a one-line comment.