diff --git a/AGENTS.md b/AGENTS.md index 4a80625..4945d47 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -97,8 +97,13 @@ The UI has two views of the same runs, and each has a button that opens the other. The classic view at `/` explains each stage in a tooltip. The table view at `/table` shows the sites in a table, with each URL, the time that each run took, and a delete button. It shows the stages in a table that tells what -each stage does and where it runs, with links to the workflow run and the -sandbox in the Render Dashboard. `public/runs.js` has what the views share; +each stage does, where it runs, and how long it took, with links to the +workflow run and the sandbox in the Render Dashboard. A timer counts the +seconds of the stage that runs now. `setRunStage()` adds each stage that a run +goes into, with the time, to `stage_history`. The gateway gives each item the +time that it stopped: when the next item started, or when the run stopped. A +stage that the run goes into again, as building after a failed verification, +shows the sum of its times. `public/runs.js` has what the views share; `app.js` and `table.js` render only what differs. The gateway builds the links from IDs in Postgres and gives null for a link that it cannot make: the SDK does not give the ID of a subtask run, so each stage links to the run of @@ -437,9 +442,9 @@ API. The API checks cannot see the hostname that a browser uses. Each stage in `RUN_STAGES` has an item in the stage list of `public/index.html`, in the same order, with a tooltip that tells what the stage does and where it runs. It also has a row in the stage table of -`public/table.html`, which tells the same and names the Dashboard links of -the stage. `tests/gateway.test.ts` checks both, so a new stage needs an item -and a row. +`public/table.html`, which tells the same, has a cell for the time of the +stage, and names the Dashboard links of the stage. `tests/gateway.test.ts` +checks both, so a new stage needs an item and a row. When a deploy fails, the deploy manager diagnoses it from the logs of that deploy, which workflow code gives it. `fetchDeployLogs()` reads them in the diff --git a/app/gateway.ts b/app/gateway.ts index f1e0777..c5db24b 100644 --- a/app/gateway.ts +++ b/app/gateway.ts @@ -293,6 +293,16 @@ export interface RunResponse { status: string; stage: string | null; progress: string | null; + /** + * Each time that the run went into a stage, in order. A stage that the run + * went into again, as building after a failed verification, has one more + * item. `finishedAt` is null for the stage that runs now. + */ + stageHistory: { + stage: string; + startedAt: string; + finishedAt: string | null; + }[]; prompt: string; user: string; appName: string | null; @@ -320,6 +330,12 @@ export function runResponse( status: run.status, stage: run.stage, progress: run.progress ? redactSecrets(run.progress) : null, + // A stage stops when the next stage starts, and the last stage stops + // when the run stops. + stageHistory: run.stageHistory.map((item, index, history) => ({ + ...item, + finishedAt: history[index + 1]?.startedAt ?? run.finishedAt, + })), prompt: run.prompt, user: run.user, appName: run.appName, diff --git a/app/schema.sql b/app/schema.sql index 77c5250..af338cf 100644 --- a/app/schema.sql +++ b/app/schema.sql @@ -10,6 +10,12 @@ create table if not exists runs ( -- Where the run is right now, for GET /v1/apps/:runId. stage text, progress text, + -- When the run went into each stage, in order, for the time of each stage + -- in the UI: [{"stage": "designing", "started_at": "..."}]. A stage that + -- the run goes into again, as building after a failed verification, gets + -- one more item. A stage stops when the next one starts, and the last one + -- stops at finished_at. + stage_history jsonb not null default '[]', -- The Workflows task run that owns the status: prompt-to-app while the -- run is running, and delete-app while its app is deleting. workflow_run_id text, @@ -38,6 +44,7 @@ alter table runs add column if not exists workflow_checked_at timestamptz; alter table runs add column if not exists sandbox_id text; alter table runs add column if not exists sandbox_group_id text; alter table runs add column if not exists finished_at timestamptz; +alter table runs add column if not exists stage_history jsonb not null default '[]'; -- A run that stopped before finished_at existed stopped when it was last -- updated. A run that a delete changed after that gets no time. diff --git a/app/store.ts b/app/store.ts index 91d9efc..747405e 100644 --- a/app/store.ts +++ b/app/store.ts @@ -50,6 +50,11 @@ export interface RunRecord { status: RunStatus; stage: RunStage | null; progress: string | null; + /** + * When the run went into each stage, in order. A stage that the run went + * into again has one more item. + */ + stageHistory: { stage: RunStage; startedAt: string }[]; workflowRunId: string | null; appName: string | null; webUrl: string | null; @@ -79,7 +84,7 @@ export type DeleteClaim = | { claimed: false; reason: "missing" }; const COLUMNS = `id, idempotency_key, prompt, user_name, status, stage, progress, - workflow_run_id, + stage_history, workflow_run_id, app_name, web_url, api_url, blueprint_path, summary, sandbox_id, sandbox_group_id, created_at, updated_at, finished_at`; @@ -173,13 +178,22 @@ export async function claimRun(input: { : { claimed: false, reason: "at_capacity" }; } +/** + * Each change of stage adds the stage and the time to stage_history, so that + * the UI can show how long each stage took. + */ export async function setRunStage( id: string, stage: RunStage, progress: string | null = null, ): Promise { await db().query( - "update runs set stage = $2, progress = $3, updated_at = now() where id = $1", + `update runs + set stage = $2, progress = $3, + stage_history = stage_history || + jsonb_build_array(jsonb_build_object('stage', $2::text, 'started_at', now())), + updated_at = now() + where id = $1`, [id, stage, progress], ); } @@ -406,6 +420,14 @@ function rowToRun(row: Record): RunRecord { status: row.status as RunStatus, stage: (row.stage as RunStage | null) ?? null, progress: (row.progress as string | null) ?? null, + // Postgres writes each time in the JSON with the offset of its session. + // Give it in UTC, as the other times of the run are. + stageHistory: ( + row.stage_history as { stage: RunStage; started_at: string }[] + ).map((item) => ({ + stage: item.stage, + startedAt: new Date(item.started_at).toISOString(), + })), workflowRunId: (row.workflow_run_id as string | null) ?? null, appName: (row.app_name as string | null) ?? null, webUrl: (row.web_url as string | null) ?? null, diff --git a/docs/FAQ.md b/docs/FAQ.md index 0d5247f..5ade152 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -75,12 +75,14 @@ Architecture overview and how Render products fit together: - Each stage has a tooltip that tells what the stage does and where it runs. Hover over the stage, or go to it with the Tab key. - For a talk track, click **Table view**. It shows the stages as a table: what - each stage does, where it runs, and links to the workflow run and the sandbox - in the Render Dashboard. The row of the stage that runs now has a tint. The - sites table shows each URL and how long each run took. + each stage does, where it runs, how long it took, and links to the workflow + run and the sandbox in the Render Dashboard. The row of the stage that runs + now has a tint and a timer that counts its seconds. The sites table shows + each URL and how long each run took. - A typical run takes **5–10 minutes**. The builder takes the largest part. - The CLI `npm run demo` follows the status endpoint and prints final URLs. -- If a run looks stuck, `GET /v1/apps/:runId` shows the current `stage` and `progress`. +- If a run looks stuck, `GET /v1/apps/:runId` shows the current `stage` and `progress`, + and `stageHistory` shows when each stage started and stopped. - If your network stops, the run continues on Render. Reload the page, and the UI shows the same run again. diff --git a/docs/README.md b/docs/README.md index 59b10de..10de691 100644 --- a/docs/README.md +++ b/docs/README.md @@ -301,9 +301,10 @@ it does not emulate the Render data plane. Open `http://localhost:3000` and sign in with `UI_USERNAME` and `UI_PASSWORD`. The **Table view** button opens `/table`, which shows the same runs as tables: the sites with their URLs, the time that each run took, and a delete button, -and the stages with what each one does, where it runs, and links to the -workflow run and the sandbox in the Render Dashboard. Local task runs are not -in the Dashboard, so local development shows no workflow links. +and the stages with what each one does, where it runs, how long it took, and +links to the workflow run and the sandbox in the Render Dashboard. A timer +counts the seconds of the stage that runs now. Local task runs are not in the +Dashboard, so local development shows no workflow links. The UI calls same-origin `/ui` endpoints; `FACTORY_API_KEY` stays on the gateway and is never delivered to browser JavaScript. `UI_USERNAME` is also the generated-app namespace: a user named `jacob` creates apps under diff --git a/public/style.css b/public/style.css index 4a37c83..dd81028 100644 --- a/public/style.css +++ b/public/style.css @@ -803,11 +803,16 @@ input { width: 18%; } -.stage-table thead th:nth-child(3) { - width: 24%; +/* The time column is wide enough for "1h 12m 34s". */ +.stage-table thead th:nth-child(2) { + width: 104px; } .stage-table thead th:nth-child(4) { + width: 24%; +} + +.stage-table thead th:nth-child(5) { width: 14%; } @@ -839,6 +844,15 @@ input { line-height: 20px; } +/* All digits have the same width, so the timer does not move as it counts. */ +.stage-table .stage-time { + font-family: var(--font-mono); + font-size: 13px; + font-variant-numeric: tabular-nums; + letter-spacing: 0; + white-space: nowrap; +} + .stage-table .stage-where { color: var(--text); } @@ -983,10 +997,12 @@ input { min-height: 44px; } - /* Each stage is a block: its name, what it does, where it runs, and links. */ + /* + * Each stage is a block: its name and its time on one line, and then what + * it does, where it runs, and links. + */ .stage-table, .stage-table tbody, - .stage-table tbody tr, .stage-table th, .stage-table td { display: block; @@ -1001,6 +1017,8 @@ input { } .stage-table tbody tr { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; padding: 10px 0; border-bottom: 1px solid var(--border); } @@ -1012,9 +1030,16 @@ input { } .stage-table td { + grid-column: 1 / -1; margin-top: 4px; } + .stage-table .stage-time { + grid-column: 2; + margin-top: 0; + padding-left: 12px; + } + .stage-table tbody th::before { top: 5px; } diff --git a/public/table.html b/public/table.html index 4a90c2f..eb8d211 100644 --- a/public/table.html +++ b/public/table.html @@ -100,13 +100,15 @@

Your website

+ @@ -115,60 +117,70 @@

Your website

+ + + + + + + + + + diff --git a/public/table.js b/public/table.js index 6a70750..e286ce0 100644 --- a/public/table.js +++ b/public/table.js @@ -4,11 +4,23 @@ const sites = document.querySelector("#sites"); const siteRows = document.querySelector("#site-rows"); const linkCells = [...document.querySelectorAll("#stages [data-links]")]; const linkLabels = { workflowRun: "Workflow run", sandbox: "Sandbox" }; +/** The time cell of each stage, by the name of the stage. */ +const timeCells = new Map( + [...document.querySelectorAll("#stages tr[data-stage]")].map((row) => [ + row.dataset.stage, + row.querySelector(".stage-time"), + ]), +); /** The links in the stage table, so that a poll changes them only when they change. */ let shownLinks = null; +/** The time of each stage of the shown run, from the last poll. */ +let stageTimes = new Map(); startRunsPage({ renderHistory, renderRun }); +// Four times each second, so that the timer shows each second. A timer that +// runs once each second can skip a second when it runs late. +setInterval(renderTimes, 250); function renderHistory(runs, selectedRunId, { select, openDeleteDialog }) { sites.hidden = runs.length === 0; @@ -58,12 +70,50 @@ function renderHistory(runs, selectedRunId, { select, openDeleteDialog }) { ); } +function renderRun(run) { + stageTimes = timesOfStages(run.stageHistory ?? []); + renderTimes(); + renderLinks(run); +} + +/** + * How long the run was in each stage. A stage that the run went into more + * than once adds up its times. `runningSince` is when the stage that runs now + * started, or null for a stage that stopped. + */ +function timesOfStages(history) { + const times = new Map(); + for (const { stage, startedAt, finishedAt } of history) { + const time = times.get(stage) ?? { elapsed: 0, runningSince: null }; + if (finishedAt) time.elapsed += Date.parse(finishedAt) - Date.parse(startedAt); + else time.runningSince = Date.parse(startedAt); + times.set(stage, time); + } + return times; +} + +/** + * A poll gives the times of the stages. Between polls, the timer adds the + * time since the stage that runs now started. A stage that did not start has + * no time. + */ +function renderTimes() { + const now = Date.now(); + for (const [stage, timeCell] of timeCells) { + const time = stageTimes.get(stage); + const running = time && time.runningSince !== null ? now - time.runningSince : 0; + const text = time ? formatDuration(time.elapsed + running) : ""; + // The timer runs four times each second, so write only a time that changed. + if (timeCell.textContent !== text) timeCell.textContent = text; + } +} + /** * Each stage links to the pages in the Render Dashboard where it runs: the * workflow run, which lists its subtasks, and the sandbox of the run. The * gateway gives null for a link that it cannot make. */ -function renderRun(run) { +function renderLinks(run) { const links = JSON.stringify(run.links ?? {}); if (links === shownLinks) return; shownLinks = links; @@ -115,11 +165,12 @@ function generationTime(run) { return end === null ? "—" : formatDuration(end - Date.parse(run.createdAt)); } +/** Always with the seconds, so that the timer of a stage shows each second. */ function formatDuration(milliseconds) { const seconds = Math.max(0, Math.round(milliseconds / 1000)); const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); - if (hours > 0) return `${hours}h ${minutes}m`; + if (hours > 0) return `${hours}h ${minutes}m ${seconds % 60}s`; if (minutes > 0) return `${minutes}m ${seconds % 60}s`; return `${seconds}s`; } diff --git a/scripts/doctor.ts b/scripts/doctor.ts index d1e65e9..98a5af1 100644 --- a/scripts/doctor.ts +++ b/scripts/doctor.ts @@ -343,6 +343,7 @@ async function checkPostgres(): Promise { "sandbox_id", "sandbox_group_id", "finished_at", + "stage_history", ].filter((name) => !columns.has(name)); record( missing.length === 0 diff --git a/tests/gateway.test.ts b/tests/gateway.test.ts index f017536..dcb8f7e 100644 --- a/tests/gateway.test.ts +++ b/tests/gateway.test.ts @@ -260,7 +260,7 @@ describe("browser UI", () => { }, ); - it("explains each run stage in the table view, with its dashboard links", async () => { + it("explains each run stage in the table view, with its time and its dashboard links", async () => { const { RUN_STAGES } = await vi.importActual("../app/store.js"); const response = await createGateway().request("/table", { @@ -270,13 +270,17 @@ describe("browser UI", () => { const rows = [...html.matchAll(/([\s\S]*?)<\/tr>/g)]; expect(html).toContain(''); + expect(html).toContain(''); expect(rows.map(([, stage]) => stage)).toEqual(RUN_STAGES); for (const [, , cells] of rows) { - // The name, what the stage does, where it runs, and its links. + // The name, the time, what the stage does, where it runs, and its + // links. table.js fills the time and the links. const text = [...cells.matchAll(/]*>([\s\S]*?)<\/t[hd]>/g)].map( ([, content]) => content.replace(/<[^>]+>/g, "").trim(), ); - expect(text.slice(0, 3).every(Boolean)).toBe(true); + expect(text).toHaveLength(5); + expect([text[0], text[2], text[3]].every(Boolean)).toBe(true); + expect(cells).toContain(''); expect(cells).toMatch(/
StageTime What it does Where it runs Dashboard
Designing The architect agent plans the app and its Render resources. It cannot create anything. Render Workflows architect task
Provisioning Starts a real Postgres for the builder and the checks. An app without a database skips this stage. Render Sandbox started by the prompt-to-app task
Curating Downloads openly licensed photographs from Wikimedia Commons into the app. No model picks a URL. Render Workflows prompt-to-app task
Building The builder agent writes the app and runs commands. It also fixes what verification finds. Render Workflows + Sandbox builder task; its tools act in the sandbox
Verifying Builds, boots, and queries the app as Render will, from only the files that a commit holds. Render Workflows + Sandbox verify-app task; the builds run in the sandbox
Publishing Copies the app into a clean clone of the apps repository, commits it with its Blueprint, and pushes to GitHub. Render Workflows + Sandbox publish-app task, in a second sandbox
Waiting For Services The push starts a Blueprint sync. Render creates the app’s project and services from render.yaml. Render Blueprints the prompt-to-app task waits on the API
Waiting For Deploys Render builds and deploys each service. If a deploy fails, the deploy manager diagnoses it and the builder fixes it. Render repairs run in the deploy-manager and builder tasks
Smoke Testing Render says that the app is live. Now the workflow checks its public URLs, its data, and CORS. Render Workflows prompt-to-app task
Done The app passed every check and is live. Render the app’s own project
Time