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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3675-apps-shows-each-app-s-time-from-new-app → main 2 commits fork 2 threads · 2 unresolved
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, | ||
| 255 | 255 | |
| 256 | 256 | ### How long the golden path took |
| 257 | 257 | |
| 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). | |
| 259 | 259 | |
| 260 | 260 | ### Every app's visitors |
| 261 | 261 |
src/apps/golden_path.ts +26
| @@ -49,6 +49,32 @@ export function goldenPath(db: Database, app: Pick<AppRecord, "repoId" | "create | ||
| 49 | 49 | }; |
| 50 | 50 | } |
| 51 | 51 | |
| 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 | ||
| 52 | 78 | /** `14s`, `2m 14s`, `9m`, `1h 5m`, `3d`: two units at most. */ |
| 53 | 79 | export function spanWords(ms: number): string { |
| 54 | 80 | 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"; | ||
| 29 | 29 | import { appNameJs, deriveAppName } from "./apps-name.ts"; |
| 30 | 30 | import { envPull, previewEnv, type EnvName } from "../../apps/data.ts"; |
| 31 | 31 | 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"; | |
| 33 | 33 | import { appHostSuffix } from "../../apps/host.ts"; |
| 34 | 34 | import { appsForRepos, type AppRecord } from "../../core/apps.ts"; |
| 35 | 35 | import { accessAllows, listOrgsForUser, orgAccess } from "../../core/orgs.ts"; |
| @@ -181,6 +181,7 @@ export async function appsList(ctx: Ctx): Promise<Response> { | ||
| 181 | 181 | shelfRows( |
| 182 | 182 | ctx, |
| 183 | 183 | shown.map((e) => e.repo), |
| 184 | { firstPreview: firstPreviews(ctx.app.db, shown.map((e) => e.record)) }, | |
| 184 | 185 | ), |
| 185 | 186 | ); |
| 186 | 187 | const cost = timePhase(ctx.request, "cost", () => |
src/web/studio/shelf.ts +8 −1
| @@ -24,6 +24,7 @@ | ||
| 24 | 24 | * told-but-stuck when nobody on the crew can take it. |
| 25 | 25 | */ |
| 26 | 26 | |
| 27 | import { spanWords } from "../../apps/golden_path.ts"; | |
| 27 | 28 | import { appsRuntime, type EnvStatus, envsFor } from "../../apps/runtime.ts"; |
| 28 | 29 | import { appsForRepos } from "../../core/apps.ts"; |
| 29 | 30 | import { reposWithBuilder } from "../../core/agents.ts"; |
| @@ -60,6 +61,9 @@ export interface ShelfReads { | ||
| 60 | 61 | /** The page's memo of production's history, when the page words more |
| 61 | 62 | * than the shelf — the home's own rows ask the same question. */ |
| 62 | 63 | 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; | |
| 63 | 67 | } |
| 64 | 68 | |
| 65 | 69 | /** |
| @@ -291,6 +295,7 @@ export async function shelfRows( | ||
| 291 | 295 | const title = mine |
| 292 | 296 | ? repo.name |
| 293 | 297 | : html`<span class="muted">${repo.ownerName}/</span>${repo.name}`; |
| 298 | const first = reads.firstPreview?.get(repo.id); | |
| 294 | 299 | return row({ |
| 295 | 300 | href: repoBase(repo), |
| 296 | 301 | nav: true, |
| @@ -299,7 +304,9 @@ export async function shelfRows( | ||
| 299 | 304 | titleAttr: repo.description || repo.name, |
| 300 | 305 | // The word leads and the description follows, so the meta line's one |
| 301 | 306 | // 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 | |
| 303 | 310 | ? html` · ${repo.description}` |
| 304 | 311 | : null}`, |
| 305 | 312 | trail: raw( |
test/golden-path.test.ts +20 −1
| @@ -6,7 +6,7 @@ import { tmpdir } from "node:os"; | ||
| 6 | 6 | import { join } from "node:path"; |
| 7 | 7 | import { createApp, type App } from "../src/app.ts"; |
| 8 | 8 | 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"; | |
| 10 | 10 | import { appPace } from "../src/actions/apps.ts"; |
| 11 | 11 | import { getRepo } from "../src/core/repos.ts"; |
| 12 | 12 | import { createApp as insertApp, type AppRecord } from "../src/core/apps.ts"; |
| @@ -119,4 +119,23 @@ describe("the golden path's figures", () => { | ||
| 119 | 119 | expect(spanWords(3_900_000)).toBe("1h 5m"); |
| 120 | 120 | expect(spanWords(3 * 86_400_000)).toBe("3d"); |
| 121 | 121 | }); |
| 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 | }); | |
| 122 | 141 | }); |
test/web-apps.test.ts +19
| @@ -23,6 +23,7 @@ import { commitToBranch } from "../src/actions/repos.ts"; | ||
| 23 | 23 | import { AppsRuntime } from "../src/apps/runtime.ts"; |
| 24 | 24 | import { getAgentByName, listCrew } from "../src/core/agents.ts"; |
| 25 | 25 | import { listAppEnvNames } from "../src/core/app_env.ts"; |
| 26 | import { spanWords } from "../src/apps/golden_path.ts"; | |
| 26 | 27 | import { getAppByRepo } from "../src/core/apps.ts"; |
| 27 | 28 | import { getIssue } from "../src/core/issues.ts"; |
| 28 | 29 | import { getRepo, type Repo } from "../src/core/repos.ts"; |
| @@ -902,6 +903,24 @@ describe("apps, runs and the crew on the web", () => { | ||
| 902 | 903 | app.db.query("DELETE FROM run WHERE id = 'gp-strip'").run(); |
| 903 | 904 | }); |
| 904 | 905 | |
| 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 | ||
| 905 | 924 | // ---- CSRF ---- |
| 906 | 925 | |
| 907 | 926 | test("every new POST refuses a cross-origin submission", async () => { |
new above
offline