From e112c0cb85a8662729445fb0ddfa75382939bdd8 Mon Sep 17 00:00:00 2001 From: Anurag Goel Date: Thu, 24 Sep 2026 08:46:55 -0700 Subject: [PATCH] Show the time of each stage in the table view The stage table of the table view has a new Time column, next to the name of each stage. A stage that stopped shows how long it took. The stage that runs now shows a timer that counts its seconds. A stage that the run goes into again, as building after a failed verification, shows the sum of its times. On a phone, the time is on the line of the name of the stage. The classic view does not change. A new stage_history column records when the run goes into each stage: each time that setRunStage() changes the stage, it adds one item with the stage and the time. GET /v1/apps/:runId and GET /ui/apps now also give stageHistory: each stage that the run went into, in order, with the time that it started and the time that it stopped. A stage stops when the next stage starts, and the last stage stops when the run stops. The stage that runs now has no stop time. table.js adds up the times of each stage from the last poll. Between polls, a timer adds the time since the stage that runs now started. The timer runs four times each second, because a timer that runs once each second can skip a second when it runs late. A time of one hour or more now also shows its seconds, in the sites table too. Runs from before this change have no stage times. Only the pre-deploy command of the gateway applies the schema, so a run that starts on the new version of the workflows service before that migration is done fails at its first stage. Submit the prompt again after the deploy. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 15 +++++--- app/gateway.ts | 16 +++++++++ app/schema.sql | 7 ++++ app/store.ts | 26 ++++++++++++-- docs/FAQ.md | 10 +++--- docs/README.md | 7 ++-- public/style.css | 33 +++++++++++++++--- public/table.html | 16 +++++++-- public/table.js | 55 +++++++++++++++++++++++++++-- scripts/doctor.ts | 1 + tests/gateway.test.ts | 81 +++++++++++++++++++++++++++++++++++++++++-- 11 files changed, 242 insertions(+), 25 deletions(-) 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