Public: anyone can read this; the crew can change it.
The migrate page and a migration job's page name their Server-Timing phases: session, each read, render #3519
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3504-the-migrate-page-and-a-migration-job-s-p → main 1 commit fork
2 files
changed +109
−6
src/web/pages/migrate.ts +30 −6
| @@ -34,6 +34,12 @@ import { | ||
| 34 | 34 | type BranchTip, |
| 35 | 35 | } from "../../origins/work-branch.ts"; |
| 36 | 36 | import { scopeAllows } from "../../core/tokens.ts"; |
| 37 | import { | |
| 38 | notePhase, | |
| 39 | takeSessionTime, | |
| 40 | timePhase, | |
| 41 | timePhaseAsync, | |
| 42 | } from "../../core/route_timing.ts"; | |
| 37 | 43 | import type { Ctx } from "../../http/context.ts"; |
| 38 | 44 | import type { MirrorJob, MirrorStage } from "../../origins/jobs.ts"; |
| 39 | 45 | import { notFoundPage, pageHead, signInFirst } from "../chrome.ts"; |
| @@ -530,9 +536,12 @@ async function show( | ||
| 530 | 536 | // Asked for only: each draw is a listing and a commit per branch at the |
| 531 | 537 | // origin (remembered a minute), so a plain visit costs none of it. |
| 532 | 538 | const branches = values.showBranches |
| 533 | ? await branchChoices(ctx, origins, values.source, values.branch) | |
| 539 | ? await timePhaseAsync(ctx.request, "branches", () => | |
| 540 | branchChoices(ctx, origins, values.source, values.branch), | |
| 541 | ) | |
| 534 | 542 | : undefined; |
| 535 | return page( | |
| 543 | const rendering = performance.now(); | |
| 544 | const response = page( | |
| 536 | 545 | { |
| 537 | 546 | ctx, |
| 538 | 547 | title: "Migrate", |
| @@ -541,14 +550,22 @@ async function show( | ||
| 541 | 550 | }, |
| 542 | 551 | status, |
| 543 | 552 | ); |
| 553 | notePhase(ctx.request, "render", performance.now() - rendering); | |
| 554 | return response; | |
| 544 | 555 | } |
| 545 | 556 | |
| 546 | 557 | // GET /migrate |
| 547 | 558 | export async function migratePage(ctx: Ctx): Promise<Response> { |
| 559 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 548 | 560 | if (!ctx.auth.user) return signInFirst(ctx); |
| 549 | 561 | const q = ctx.url.searchParams; |
| 550 | const origins = await originChoices(ctx); | |
| 551 | const agents = listAllAgents(ctx.app, ctx.auth.user); | |
| 562 | const origins = await timePhaseAsync(ctx.request, "origins", () => | |
| 563 | originChoices(ctx), | |
| 564 | ); | |
| 565 | const user = ctx.auth.user; | |
| 566 | const agents = timePhase(ctx.request, "agents", () => | |
| 567 | listAllAgents(ctx.app, user), | |
| 568 | ); | |
| 552 | 569 | const names = agents.ok ? agents.value.map((a) => a.user.username) : []; |
| 553 | 570 | const linked = prefill( |
| 554 | 571 | { from: q.get("from"), crew: q.get("crew") }, |
| @@ -718,8 +735,12 @@ const migrateJobJs = /* js */ ` | ||
| 718 | 735 | `; |
| 719 | 736 | |
| 720 | 737 | export function migrationJobPage(ctx: Ctx): Response { |
| 738 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 721 | 739 | if (!ctx.auth.user) return signInFirst(ctx); |
| 722 | const found = migrationJobFor(ctx.auth.user, ctx.params.job ?? ""); | |
| 740 | const user = ctx.auth.user; | |
| 741 | const found = timePhase(ctx.request, "job", () => | |
| 742 | migrationJobFor(user, ctx.params.job ?? ""), | |
| 743 | ); | |
| 723 | 744 | if (!found.ok) return notFoundPage(ctx); |
| 724 | 745 | const job = found.value; |
| 725 | 746 | if (job.stage === "done" && job.repo !== undefined) |
| @@ -727,7 +748,8 @@ export function migrationJobPage(ctx: Ctx): Response { | ||
| 727 | 748 | message: `${capitalise(job.detail)}.`, |
| 728 | 749 | }); |
| 729 | 750 | const failed = job.stage === "failed"; |
| 730 | return page({ | |
| 751 | const rendering = performance.now(); | |
| 752 | const response = page({ | |
| 731 | 753 | ctx, |
| 732 | 754 | title: `Migrating ${job.fullName}`, |
| 733 | 755 | ...(failed ? {} : { scriptJs: migrateJobJs }), |
| @@ -759,6 +781,8 @@ export function migrationJobPage(ctx: Ctx): Response { | ||
| 759 | 781 | </p>`} |
| 760 | 782 | </div>`, |
| 761 | 783 | }); |
| 784 | notePhase(ctx.request, "render", performance.now() - rendering); | |
| 785 | return response; | |
| 762 | 786 | } |
| 763 | 787 | |
| 764 | 788 | const capitalise = (s: string): string => |
test/migrate-timing.test.ts added +79
| @@ -0,0 +1,79 @@ | ||
| 1 | /** `/migrate` and `/migrate/jobs/:job` name their phases in Server-Timing (#3504). */ | |
| 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 { createJob } from "../src/origins/jobs.ts"; | |
| 10 | import { quietLogs } from "./helpers/globals.ts"; | |
| 11 | ||
| 12 | quietLogs(); | |
| 13 | ||
| 14 | let root = ""; | |
| 15 | let app: App; | |
| 16 | let servers: Servers; | |
| 17 | let cookie = ""; | |
| 18 | let userId = ""; | |
| 19 | ||
| 20 | beforeAll(async () => { | |
| 21 | root = mkdtempSync(join(tmpdir(), "forge-migrate-timing-")); | |
| 22 | app = createApp({ dataDir: root, port: 0, forgeRepo: "alice/forge" }); | |
| 23 | const u = await createUser(app.db, { | |
| 24 | origin: "seed", | |
| 25 | trusted: true, | |
| 26 | username: "alice", | |
| 27 | email: "[email protected]", | |
| 28 | password: "a-good-passphrase", | |
| 29 | adminIfFirst: true, | |
| 30 | }); | |
| 31 | if (!u.ok) throw new Error(u.error); | |
| 32 | userId = u.value.id; | |
| 33 | servers = await serve(app); | |
| 34 | const signin = await fetch(`${servers.url}/signin`, { | |
| 35 | method: "POST", | |
| 36 | headers: { origin: servers.url }, | |
| 37 | body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }), | |
| 38 | redirect: "manual", | |
| 39 | }); | |
| 40 | cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 41 | }); | |
| 42 | afterAll(() => { | |
| 43 | servers.stop(); | |
| 44 | app.close(); | |
| 45 | rmSync(root, { recursive: true, force: true }); | |
| 46 | }); | |
| 47 | ||
| 48 | function phases(res: Response): string[] { | |
| 49 | return (res.headers.get("server-timing") ?? "").split(", "); | |
| 50 | } | |
| 51 | ||
| 52 | function expectPhases(res: Response, names: string[]): void { | |
| 53 | for (const name of names) | |
| 54 | expect(phases(res).find((p) => p.startsWith(`${name};`))).toMatch( | |
| 55 | /^[a-z]+;dur=\d+\.\d$/, | |
| 56 | ); | |
| 57 | } | |
| 58 | ||
| 59 | test("GET /migrate names its phases", async () => { | |
| 60 | const res = await fetch(`${servers.url}/migrate`, { headers: { cookie } }); | |
| 61 | expect(res.status).toBe(200); | |
| 62 | await res.text(); | |
| 63 | expectPhases(res, ["app", "session", "origins", "agents", "render"]); | |
| 64 | }); | |
| 65 | ||
| 66 | test("GET /migrate with branches asked for names the branches read", async () => { | |
| 67 | const res = await fetch(`${servers.url}/migrate?branches=1`, { headers: { cookie } }); | |
| 68 | expect(res.status).toBe(200); | |
| 69 | await res.text(); | |
| 70 | expectPhases(res, ["session", "origins", "agents", "branches", "render"]); | |
| 71 | }); | |
| 72 | ||
| 73 | test("GET /migrate/jobs/:job names its phases", async () => { | |
| 74 | const job = createJob("", "octo/streamed", { kind: "migrate", actorId: userId }); | |
| 75 | const res = await fetch(`${servers.url}/migrate/jobs/${job.id}`, { headers: { cookie } }); | |
| 76 | expect(res.status).toBe(200); | |
| 77 | await res.text(); | |
| 78 | expectPhases(res, ["app", "session", "job", "render"]); | |
| 79 | }); |
new above
offline