Public: anyone can read this; the crew can change it.
Production's toolbar page names the commit that is live and when it went live #4094
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/4089-production-s-toolbar-page-names-the-comm → main 2 commits fork 1 thread · 1 unresolved
2 files
changed +118
−5
src/web/studio/toolbar.ts +39 −5
| @@ -19,18 +19,21 @@ import { addCommentThrough, openIssueThrough } from "../../actions/issues.ts"; | ||
| 19 | 19 | import { reportTitle } from "../../actions/page-report.ts"; |
| 20 | 20 | import { getAppByRepo } from "../../core/apps.ts"; |
| 21 | 21 | import { getIssue, MAX_BODY } from "../../core/issues.ts"; |
| 22 | import { canWrite } from "../../core/repos.ts"; | |
| 22 | import { canWrite, type Repo } from "../../core/repos.ts"; | |
| 23 | 23 | import { listRuns } from "../../core/runs.ts"; |
| 24 | 24 | import { mayView, mayViewId } from "../../core/sealed.ts"; |
| 25 | 25 | import { mayDirectCrew } from "../../crew/direct.ts"; |
| 26 | import { isSafeOid } from "../../git/guard.ts"; | |
| 27 | import { commitLog } from "../../git/read.ts"; | |
| 28 | import { repoDir } from "../../git/repos.ts"; | |
| 26 | 29 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; |
| 27 | 30 | import type { Ctx } from "../../http/context.ts"; |
| 28 | 31 | import { notFoundPage, pageHead, loadRepo, repoBase } from "../chrome.ts"; |
| 29 | 32 | import { field, readForm } from "../form.ts"; |
| 30 | import { html, when } from "../html.ts"; | |
| 33 | import { html, when, type Html } from "../html.ts"; | |
| 31 | 34 | import { page } from "../layout.ts"; |
| 32 | 35 | import { originError } from "../pages/thread.ts"; |
| 33 | import { btn, card } from "../ui.ts"; | |
| 36 | import { btn, card, envFacts } from "../ui.ts"; | |
| 34 | 37 | |
| 35 | 38 | /** The longest page address the bar may carry. */ |
| 36 | 39 | const PAGE_MAX = 512; |
| @@ -59,8 +62,37 @@ function parseEnv(raw: string): { env: string; pull?: number } | undefined { | ||
| 59 | 62 | const toolbarPath = (base: string, env: string, address: string): string => |
| 60 | 63 | `${base}/toolbar?env=${env}${address === "" ? "" : `&page=${encodeURIComponent(address)}`}`; |
| 61 | 64 | |
| 65 | /** | |
| 66 | * Production's "Live:" line: the commit its latest serving deploy ran (not | |
| 67 | * the branch tip) and when it went live, plus a note when a newer deploy is | |
| 68 | * still coming up. Empty when production never served. | |
| 69 | */ | |
| 70 | async function liveLine(ctx: Ctx, repo: Repo, base: string): Promise<Html> { | |
| 71 | const deploys = listRuns(ctx.app.db, repo.id, { kind: "deploy", env: "production", limit: 20 }); | |
| 72 | const at = deploys.findIndex((r) => r.servingAt !== null && isSafeOid(r.commitOid)); | |
| 73 | const live = deploys[at]; | |
| 74 | if (live === undefined || live.servingAt === null) return html``; | |
| 75 | const coming = deploys | |
| 76 | .slice(0, at) | |
| 77 | .some((r) => (r.state === "queued" || r.state === "running") && r.commitOid !== live.commitOid); | |
| 78 | const log = await commitLog(repoDir(ctx.app.reposRoot, repo.ownerName, repo.name), live.commitOid, { limit: 1 }); | |
| 79 | const subject = log.ok ? (log.value[0]?.subject ?? "") : ""; | |
| 80 | const facts = envFacts(base, { | |
| 81 | env: "production", | |
| 82 | state: "running", | |
| 83 | commitOid: live.commitOid, | |
| 84 | url: "", | |
| 85 | since: live.servingAt, | |
| 86 | runId: live.id, | |
| 87 | }); | |
| 88 | return html`<div data-live> | |
| 89 | <p class="small"><strong>Live:</strong> ${subject}${when(coming, () => html` · a newer commit is deploying`)}</p> | |
| 90 | ${facts} | |
| 91 | </div>`; | |
| 92 | } | |
| 93 | ||
| 62 | 94 | // GET /:owner/:name/toolbar |
| 63 | export function toolbarPage(ctx: Ctx): Response { | |
| 95 | export async function toolbarPage(ctx: Ctx): Promise<Response> { | |
| 64 | 96 | const request = ctx.request; |
| 65 | 97 | notePhase(request, "session", takeSessionTime(request)); |
| 66 | 98 | const repo = timePhase(request, "repo", () => loadRepo(ctx)); |
| @@ -79,13 +111,14 @@ export function toolbarPage(ctx: Ctx): Response { | ||
| 79 | 111 | const address = pageAddress(ctx.url.searchParams.get("page") ?? ""); |
| 80 | 112 | const base = repoBase(repo); |
| 81 | 113 | const here = html`${repo.name}, ${pull ? html`preview of pull #${pull.number}` : "production"}`; |
| 114 | const liveNote = pull ? html`` : await liveLine(ctx, repo, base); | |
| 82 | 115 | const home = btn("Home", { href: "/", variant: "ghost" }); |
| 83 | 116 | const rendering = performance.now(); |
| 84 | 117 | if (!user) { |
| 85 | 118 | const response = page({ |
| 86 | 119 | ctx, |
| 87 | 120 | title: `${repo.name} · forge`, |
| 88 | body: html`<div class="narrow-wide">${pageHead(here)}<div class="form-actions">${home}</div></div>`, | |
| 121 | body: html`<div class="narrow-wide">${pageHead(here)}${liveNote}<div class="form-actions">${home}</div></div>`, | |
| 89 | 122 | }); |
| 90 | 123 | notePhase(request, "render", performance.now() - rendering); |
| 91 | 124 | return response; |
| @@ -105,6 +138,7 @@ export function toolbarPage(ctx: Ctx): Response { | ||
| 105 | 138 | const comment = pull ? "Comment on this pull" : "Report this page"; |
| 106 | 139 | const body = html`<div class="narrow-wide"> |
| 107 | 140 | ${pageHead(here)} |
| 141 | ${liveNote} | |
| 108 | 142 | ${when( |
| 109 | 143 | live !== undefined, |
| 110 | 144 | () => html`<p class="mono" data-working> |
test/toolbar-live.test.ts added +79
| @@ -0,0 +1,79 @@ | ||
| 1 | /** The production toolbar page names the commit that is live (#4089); previews do not. */ | |
| 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 { newApp } from "../src/actions/apps.ts"; | |
| 7 | import { createApp, type App } from "../src/app.ts"; | |
| 8 | import { claimRun, createRun, markServing } from "../src/core/runs.ts"; | |
| 9 | import { getRepo } from "../src/core/repos.ts"; | |
| 10 | import { createUser } from "../src/core/users.ts"; | |
| 11 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 12 | import { commitLog } from "../src/git/read.ts"; | |
| 13 | import { repoDir } from "../src/git/repos.ts"; | |
| 14 | import { proveAll } from "./helpers/verified.ts"; | |
| 15 | import { quietLogs } from "./helpers/globals.ts"; | |
| 16 | ||
| 17 | quietLogs(); | |
| 18 | ||
| 19 | let root = ""; | |
| 20 | let app: App; | |
| 21 | let servers: Servers; | |
| 22 | let cookie = ""; | |
| 23 | let repoId = ""; | |
| 24 | let oid = ""; | |
| 25 | let subject = ""; | |
| 26 | ||
| 27 | const toolbar = async (env = "production", signedIn = true): Promise<string> => | |
| 28 | (await fetch(`${servers.url}/alice/forge/toolbar?env=${env}`, { headers: signedIn ? { cookie } : {} })).text(); | |
| 29 | ||
| 30 | beforeAll(async () => { | |
| 31 | root = mkdtempSync(join(tmpdir(), "forge-toolbar-live-")); | |
| 32 | app = createApp({ dataDir: root, port: 0, forgeRepo: "alice/forge" }); | |
| 33 | const u = await createUser(app.db, { | |
| 34 | origin: "seed", trusted: true, username: "alice", email: "[email protected]", | |
| 35 | password: "a-good-passphrase", adminIfFirst: true, | |
| 36 | }); | |
| 37 | if (!u.ok) throw new Error(u.error); | |
| 38 | proveAll(app); | |
| 39 | const made = await newApp(app, { id: u.value.id, username: "alice" } as never, { name: "forge", description: "x", visibility: "public" }); | |
| 40 | if (!made.ok) throw new Error(made.error); | |
| 41 | servers = await serve(app); | |
| 42 | const signin = await fetch(`${servers.url}/signin`, { | |
| 43 | method: "POST", headers: { origin: servers.url }, | |
| 44 | body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }), redirect: "manual", | |
| 45 | }); | |
| 46 | cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 47 | const repo = getRepo(app.db, "alice", "forge")!; | |
| 48 | repoId = repo.id; | |
| 49 | const log = await commitLog(repoDir(app.reposRoot, "alice", "forge"), repo.defaultBranch, { limit: 1 }); | |
| 50 | if (!log.ok) throw new Error("no commit"); | |
| 51 | oid = log.value[0]!.oid; | |
| 52 | subject = log.value[0]!.subject; | |
| 53 | }); | |
| 54 | afterAll(() => { | |
| 55 | servers.stop(); | |
| 56 | app.close(); | |
| 57 | rmSync(root, { recursive: true, force: true }); | |
| 58 | }); | |
| 59 | ||
| 60 | test("an app never deployed shows no live line", async () => { | |
| 61 | expect(await toolbar()).not.toContain("data-live"); | |
| 62 | }); | |
| 63 | ||
| 64 | test("a deployed app shows its commit, and a newer deploy in flight", async () => { | |
| 65 | const run = createRun(app.db, { repoId, kind: "deploy", env: "production", commitOid: oid }); | |
| 66 | claimRun(app.db, run.id); | |
| 67 | markServing(app.db, run.id); | |
| 68 | const page = await toolbar(); | |
| 69 | expect(page).toContain("data-live"); | |
| 70 | expect(page).toContain(`/commit/${oid}`); | |
| 71 | expect(page).toContain(oid.slice(0, 7)); | |
| 72 | expect(page).toContain(subject); | |
| 73 | expect(page).toContain("just now"); | |
| 74 | expect(page).not.toContain("is deploying"); | |
| 75 | expect(await toolbar("production", false)).toContain("data-live"); | |
| 76 | expect(await toolbar("pull-1")).not.toContain("data-live"); | |
| 77 | createRun(app.db, { repoId, kind: "deploy", env: "production", commitOid: "b".repeat(40) }); | |
| 78 | expect(await toolbar()).toContain("a newer commit is deploying"); | |
| 79 | }); |
new above
offline
Next: a failed newer deploy is invisible here. Include state failed after the live row and say so, like envPhrase does (live · a newer push failed).