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

/apps shows each app's time from New app to its first preview, beside its name #3772

Merged opened this

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

Merged

scout/forge:scout/3675-apps-shows-each-app-s-time-from-new-app → main 2 commits fork 2 threads · 2 unresolved

  • Merged. scout/3675-apps-shows-each-app-s-time-from-new-app was merged into main as 7a62717.

Review

Sign in to review this change.
6 files changed +76 −4
README.md +1 −1
@@ -255,7 +255,7 @@ Every page links one static, immutable script (`src/web/beacon.ts`, under 2 KB,
255255
256256 ### How long the golden path took
257257
258 An app's repository home wears the pace on its app strip, the terminal's **Deploys** screen shows it under the title, and `GET /api/v1/repos/:owner/:name/app` returns it as `firstPreviewMs` and `firstProductionChangeMs`. It measures milliseconds from New app to the first preview that served, and from New app to the first production deploy that began after the first merged pull; a figure is left out until it exists. Anyone who can read the repository sees it (`goldenPath` in `src/apps/golden_path.ts`, `appPace` for the terminal).
258 An app's repository home wears the pace on its app strip, the terminal's **Deploys** screen shows it under the title, `/apps` (and any shelf of apps) says `first preview 14s` in each row's meta line once one has served (one grouped query, `firstPreviews`; the terminal has no apps list of its own to carry it), and `GET /api/v1/repos/:owner/:name/app` returns it as `firstPreviewMs` and `firstProductionChangeMs`. It measures milliseconds from New app to the first preview that served, and from New app to the first production deploy that began after the first merged pull; a figure is left out until it exists. Anyone who can read the repository sees it (`goldenPath` in `src/apps/golden_path.ts`, `appPace` for the terminal).
259259
260260 ### Every app's visitors
261261
src/apps/golden_path.ts +26
@@ -49,6 +49,32 @@ export function goldenPath(db: Database, app: Pick<AppRecord, "repoId" | "create
4949 };
5050 }
5151
52 /**
53 * The first-preview figure for many apps in one query, keyed by repo id; an
54 * app with none (or an unreadable birth date) has no entry.
55 */
56 export function firstPreviews(
57 db: Database,
58 apps: readonly Pick<AppRecord, "repoId" | "createdAt">[],
59 ): Map<string, number> {
60 const out = new Map<string, number>();
61 if (apps.length === 0) return out;
62 const marks = apps.map(() => "?").join(",");
63 const rows = db
64 .query<{ repo_id: string; at: string | null }, string[]>(
65 `SELECT repo_id, MIN(serving_at) AS at FROM run
66 WHERE repo_id IN (${marks}) AND kind = 'deploy' AND env LIKE 'pull-%' AND serving_at IS NOT NULL
67 GROUP BY repo_id`,
68 )
69 .all(...apps.map((a) => a.repoId));
70 const born = new Map(apps.map((a) => [a.repoId, Date.parse(a.createdAt)]));
71 for (const r of rows) {
72 const ms = Date.parse(r.at ?? "") - (born.get(r.repo_id) ?? NaN);
73 if (!Number.isNaN(ms)) out.set(r.repo_id, Math.max(0, ms));
74 }
75 return out;
76 }
77
5278 /** `14s`, `2m 14s`, `9m`, `1h 5m`, `3d`: two units at most. */
5379 export function spanWords(ms: number): string {
5480 const s = Math.round(Math.max(0, ms) / 1000);
src/web/pages/apps.ts +2 −1
@@ -29,7 +29,7 @@ import { planningAvailable } from "../../crew/plan.ts";
2929 import { appNameJs, deriveAppName } from "./apps-name.ts";
3030 import { envPull, previewEnv, type EnvName } from "../../apps/data.ts";
3131 import { appsRuntime, type EnvStatus, envsFor } from "../../apps/runtime.ts";
32 import { goldenPath, goldenPathLine } from "../../apps/golden_path.ts";
32 import { firstPreviews, goldenPath, goldenPathLine } from "../../apps/golden_path.ts";
3333 import { appHostSuffix } from "../../apps/host.ts";
3434 import { appsForRepos, type AppRecord } from "../../core/apps.ts";
3535 import { accessAllows, listOrgsForUser, orgAccess } from "../../core/orgs.ts";
@@ -181,6 +181,7 @@ export async function appsList(ctx: Ctx): Promise<Response> {
181181 shelfRows(
182182 ctx,
183183 shown.map((e) => e.repo),
184 { firstPreview: firstPreviews(ctx.app.db, shown.map((e) => e.record)) },
184185 ),
185186 );
186187 const cost = timePhase(ctx.request, "cost", () =>
src/web/studio/shelf.ts +8 −1
@@ -24,6 +24,7 @@
2424 * told-but-stuck when nobody on the crew can take it.
2525 */
2626
27 import { spanWords } from "../../apps/golden_path.ts";
2728 import { appsRuntime, type EnvStatus, envsFor } from "../../apps/runtime.ts";
2829 import { appsForRepos } from "../../core/apps.ts";
2930 import { reposWithBuilder } from "../../core/agents.ts";
@@ -60,6 +61,9 @@ export interface ShelfReads {
6061 /** The page's memo of production's history, when the page words more
6162 * than the shelf — the home's own rows ask the same question. */
6263 readonly history?: ServingHistory | undefined;
64 /** Milliseconds from New app to the first preview, by repo id; a row
65 * wears it beside its name only when it has one. */
66 readonly firstPreview?: ReadonlyMap<string, number> | undefined;
6367 }
6468
6569 /**
@@ -291,6 +295,7 @@ export async function shelfRows(
291295 const title = mine
292296 ? repo.name
293297 : html`<span class="muted">${repo.ownerName}/</span>${repo.name}`;
298 const first = reads.firstPreview?.get(repo.id);
294299 return row({
295300 href: repoBase(repo),
296301 nav: true,
@@ -299,7 +304,9 @@ export async function shelfRows(
299304 titleAttr: repo.description || repo.name,
300305 // The word leads and the description follows, so the meta line's one
301306 // ellipsis falls on the description (whole in the title), never the word.
302 meta: html`${happening}${repo.description
307 meta: html`${happening}${first === undefined
308 ? null
309 : html` · first preview ${spanWords(first)}`}${repo.description
303310 ? html` · ${repo.description}`
304311 : null}`,
305312 trail: raw(
test/golden-path.test.ts +20 −1
@@ -6,7 +6,7 @@ import { tmpdir } from "node:os";
66 import { join } from "node:path";
77 import { createApp, type App } from "../src/app.ts";
88 import { createRepo } from "../src/actions/repos.ts";
9 import { goldenPath, goldenPathLine, spanWords } from "../src/apps/golden_path.ts";
9 import { firstPreviews, goldenPath, goldenPathLine, spanWords } from "../src/apps/golden_path.ts";
1010 import { appPace } from "../src/actions/apps.ts";
1111 import { getRepo } from "../src/core/repos.ts";
1212 import { createApp as insertApp, type AppRecord } from "../src/core/apps.ts";
@@ -119,4 +119,23 @@ describe("the golden path's figures", () => {
119119 expect(spanWords(3_900_000)).toBe("1h 5m");
120120 expect(spanWords(3 * 86_400_000)).toBe("3d");
121121 });
122
123 test("firstPreviews reads many apps in one query and matches goldenPath", async () => {
124 const apps = [await fresh(), await fresh(), await fresh()];
125 deploy(apps[0]!.repoId, "pull-1", 0, 9_000);
126 deploy(apps[0]!.repoId, "pull-2", 0, 30_000);
127 deploy(apps[1]!.repoId, "pull-1", 0, null);
128 let queries = 0;
129 const counted = new Proxy(app.db, {
130 get(target, key) {
131 if (key === "query" || key === "prepare") queries += 1;
132 const value = Reflect.get(target, key, target);
133 return typeof value === "function" ? value.bind(target) : value;
134 },
135 });
136 const found = firstPreviews(counted, apps);
137 expect(queries).toBe(1);
138 expect([...found]).toEqual([[apps[0]!.repoId, 9_000]]);
139 expect(goldenPath(app.db, apps[0]!).firstPreviewMs).toBe(9_000);
140 });
122141 });
test/web-apps.test.ts +19
@@ -23,6 +23,7 @@ import { commitToBranch } from "../src/actions/repos.ts";
2323 import { AppsRuntime } from "../src/apps/runtime.ts";
2424 import { getAgentByName, listCrew } from "../src/core/agents.ts";
2525 import { listAppEnvNames } from "../src/core/app_env.ts";
26 import { spanWords } from "../src/apps/golden_path.ts";
2627 import { getAppByRepo } from "../src/core/apps.ts";
2728 import { getIssue } from "../src/core/issues.ts";
2829 import { getRepo, type Repo } from "../src/core/repos.ts";
@@ -902,6 +903,24 @@ describe("apps, runs and the crew on the web", () => {
902903 app.db.query("DELETE FROM run WHERE id = 'gp-strip'").run();
903904 });
904905
906 test("/apps says the first preview's time beside the name, and nothing without one", async () => {
907 app.db.query("UPDATE run SET serving_at = NULL WHERE env LIKE 'pull-%'").run();
908 expect(await bodyOf(alice, "/apps")).not.toContain("first preview");
909 app.db
910 .query(
911 `INSERT INTO run (id, repo_id, kind, env, state, created_at, serving_at)
912 VALUES ('gp-list', ?1, 'deploy', 'pull-78', 'ok', ?2, ?2)`,
913 )
914 .run(hello.id, new Date(Date.now() + 60_000).toISOString());
915 const listed = words(await bodyOf(alice, "/apps"));
916 const secs = Math.round((Date.now() + 60_000 - Date.parse(getAppByRepo(app.db, hello.id)!.createdAt)) / 1000);
917 // Created a minute before "now", give or take the seconds the test took.
918 const figures = [secs - 2, secs - 1, secs, secs + 1, secs + 2].map((n) => spanWords(n * 1000));
919 expect(figures.some((f) => listed.includes(`· first preview ${f}`))).toBe(true);
920 expect(listed).toMatch(/class="row-meta"[^]*first preview/);
921 app.db.query("DELETE FROM run WHERE id = 'gp-list'").run();
922 });
923
905924 // ---- CSRF ----
906925
907926 test("every new POST refuses a cross-origin submission", async () => {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs