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

Merged opened this

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

Merged

scout/forge:scout/4089-production-s-toolbar-page-names-the-comm → main 2 commits fork 1 thread · 1 unresolved

  • Merged. scout/4089-production-s-toolbar-page-names-the-comm was merged into main as 3e01f12.

Review

Sign in to review this change.
2 files changed +118 −5
src/web/studio/toolbar.ts +39 −5
@@ -19,18 +19,21 @@ import { addCommentThrough, openIssueThrough } from "../../actions/issues.ts";
1919 import { reportTitle } from "../../actions/page-report.ts";
2020 import { getAppByRepo } from "../../core/apps.ts";
2121 import { getIssue, MAX_BODY } from "../../core/issues.ts";
22 import { canWrite } from "../../core/repos.ts";
22 import { canWrite, type Repo } from "../../core/repos.ts";
2323 import { listRuns } from "../../core/runs.ts";
2424 import { mayView, mayViewId } from "../../core/sealed.ts";
2525 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";
2629 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
2730 import type { Ctx } from "../../http/context.ts";
2831 import { notFoundPage, pageHead, loadRepo, repoBase } from "../chrome.ts";
2932 import { field, readForm } from "../form.ts";
30 import { html, when } from "../html.ts";
33 import { html, when, type Html } from "../html.ts";
3134 import { page } from "../layout.ts";
3235 import { originError } from "../pages/thread.ts";
33 import { btn, card } from "../ui.ts";
36 import { btn, card, envFacts } from "../ui.ts";
3437
3538 /** The longest page address the bar may carry. */
3639 const PAGE_MAX = 512;
@@ -59,8 +62,37 @@ function parseEnv(raw: string): { env: string; pull?: number } | undefined {
5962 const toolbarPath = (base: string, env: string, address: string): string =>
6063 `${base}/toolbar?env=${env}${address === "" ? "" : `&page=${encodeURIComponent(address)}`}`;
6164
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

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).

6294 // GET /:owner/:name/toolbar
63 export function toolbarPage(ctx: Ctx): Response {
95 export async function toolbarPage(ctx: Ctx): Promise<Response> {
6496 const request = ctx.request;
6597 notePhase(request, "session", takeSessionTime(request));
6698 const repo = timePhase(request, "repo", () => loadRepo(ctx));
@@ -79,13 +111,14 @@ export function toolbarPage(ctx: Ctx): Response {
79111 const address = pageAddress(ctx.url.searchParams.get("page") ?? "");
80112 const base = repoBase(repo);
81113 const here = html`${repo.name}, ${pull ? html`preview of pull #${pull.number}` : "production"}`;
114 const liveNote = pull ? html`` : await liveLine(ctx, repo, base);
82115 const home = btn("Home", { href: "/", variant: "ghost" });
83116 const rendering = performance.now();
84117 if (!user) {
85118 const response = page({
86119 ctx,
87120 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>`,
89122 });
90123 notePhase(request, "render", performance.now() - rendering);
91124 return response;
@@ -105,6 +138,7 @@ export function toolbarPage(ctx: Ctx): Response {
105138 const comment = pull ? "Comment on this pull" : "Report this page";
106139 const body = html`<div class="narrow-wide">
107140 ${pageHead(here)}
141 ${liveNote}
108142 ${when(
109143 live !== undefined,
110144 () => 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs