From f6910f9a3a754bcf6f761850a01c1a95969f47c1 Mon Sep 17 00:00:00 2001 From: Anurag Goel Date: Thu, 24 Sep 2026 08:04:46 -0700 Subject: [PATCH] Add a table view of the UI, with links to the Render Dashboard The table view at /table shows the same runs as the classic view at /. Each view has a button in the top bar that opens the other view. The sites table shows the site, its status, its URL, the time that its run took, the time that the run started, and a delete button. The delete button opens the same dialog as the classic view. The stage table tells what each stage does and where it runs, so that a presenter can talk along with it. The row of the stage that runs now has a tint. The status, the link to the live site, and the error summary are above the stage table, so that the table does not push them down. Each stage links to the run of prompt-to-app in the Render Dashboard. The page of that run lists its subtasks. The Render SDK does not give the ID of a subtask run, so a stage cannot link to its own subtask. The stages that use the sandbox of the run also link to the sandbox. The gateway builds the links, and gives null for a link that it cannot make: - The gateway reads the workflow ID once, from a task run and its task, in the background. Until the read is done, the responses have no workflow link. A Blueprint cannot give the ID: fromService has no id property. - prompt-to-app records its sandbox and the sandbox group of the workspace. In the alpha, a workspace has at most one group. With more than one, the group of a sandbox is not known, so there is no sandbox link. - Local task runs are not in the Dashboard, so local development gets no workflow links. A delete changes updated_at, so a new finished_at column records when a run stopped. The migration copies updated_at into finished_at for the runs that stopped before this change. GET /v1/apps/:runId now also gives finishedAt and links. public/runs.js has the parts that both views share: the prompt form, the poll, the run panel, the stage states, and the delete dialog. app.js and table.js render only what differs. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 24 +++- app/gateway.ts | 94 +++++++++++-- app/render.ts | 19 +++ app/sandbox.ts | 11 ++ app/schema.sql | 18 ++- app/store.ts | 24 +++- app/workflow.ts | 23 +++- docs/FAQ.md | 4 + docs/README.md | 5 + public/app.js | 277 ++------------------------------------ public/index.html | 1 + public/runs.js | 293 +++++++++++++++++++++++++++++++++++++++++ public/style.css | 290 +++++++++++++++++++++++++++++++++++++++- public/table.html | 205 ++++++++++++++++++++++++++++ public/table.js | 125 ++++++++++++++++++ scripts/doctor.ts | 3 + tests/gateway.test.ts | 96 ++++++++++++++ tests/render.test.ts | 39 ++++++ tests/workflow.test.ts | 24 ++++ 19 files changed, 1290 insertions(+), 285 deletions(-) create mode 100644 public/runs.js create mode 100644 public/table.html create mode 100644 public/table.js diff --git a/AGENTS.md b/AGENTS.md index 0209f90..4a80625 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -93,6 +93,18 @@ and is injected as the app namespace; never accept a browser-supplied `user`. deletes only a run in that namespace, and the UI restores selection from local storage while treating Postgres as the source of truth. +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; +`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 +`prompt-to-app`, and a workspace with more than one sandbox group gets no +sandbox link. + ## Repository map Each concern is one file under `app/`. There are no barrels, no path aliases, @@ -112,7 +124,7 @@ logs of a failed deploy. `render` is imported by `claude` (for the MCP URL), by `teardown`, and by `deploy` and `delete`. `blueprint`, `git`, `images`, `teardown`, and `store` are used by `workflow` and the stages; `teardown` uses `render` and `blueprint`; `gateway` uses `store`, `policy`, -and `contracts`. `contracts` is a leaf, and `config` uses only its `slug` +`contracts`, and `render`, for the workflow ID of the Dashboard links. `contracts` is a leaf, and `config` uses only its `slug` schema. Adding an edge that points backwards is a design smell. ```text @@ -143,7 +155,9 @@ app/ schema.sql Schema, applied by scripts/migrate.ts server.ts Gateway entrypoint host.ts Workflows entrypoint -public/ Basic-Auth-protected prompt and deployment-status UI +public/ Basic-Auth-protected prompt and deployment-status UI: + runs.js (shared), index.html + app.js (classic view), + table.html + table.js (table view) templates/ fullstack/ web/ (Vite + React + Tailwind + shadcn/ui), api/ (Hono + pg) scripts/ migrate, doctor, demo, support @@ -422,8 +436,10 @@ 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. `tests/gateway.test.ts` checks this, so a new -stage needs an item and a tooltip. +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. 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 6125a21..f1e0777 100644 --- a/app/gateway.ts +++ b/app/gateway.ts @@ -14,6 +14,7 @@ import { bodyLimit } from "hono/body-limit"; import { apiKey, uiCredentials } from "./config.js"; import { createAppRequestSchema } from "./contracts.js"; import { redactSecrets } from "./policy.js"; +import { workflowIdOfTaskRun } from "./render.js"; import { claimDelete, claimRun, @@ -34,6 +35,12 @@ const RUN_ID = /^[0-9a-f-]{36}$/; const TASK_NAME = "prompt-to-app"; const DELETE_TASK_NAME = "delete-app"; const UNAUTHORIZED = { error: "unauthorized" }; +const DASHBOARD = "https://dashboard.render.com"; +/** A failed read of the workflow ID is tried again after this, not on each poll. */ +const WORKFLOW_ID_RETRY_MS = 60_000; + +/** The ID of the workflow for links to the Render Dashboard, if it is known. */ +type WorkflowIdReader = (taskRunId: string | null) => string | null; export function createGateway(): Hono { const app = new Hono(); @@ -57,6 +64,7 @@ export function createGateway(): Hono { maxSize: MAX_BODY_BYTES, onError: (c) => c.json({ error: "payload too large" }, 413), }); + const workflowId = workflowIdReader(); app.get("/health", (c) => c.json({ status: "ok" })); @@ -71,29 +79,40 @@ export function createGateway(): Hono { app.use("/v1/*", apiAuth); app.post("/v1/apps", capBody, (c) => createRun(c, "/v1/apps")); - app.get("/v1/apps/:runId", readRun); + app.get("/v1/apps/:runId", (c) => readRun(c, workflowId)); app.delete("/v1/apps/:runId", (c) => deleteRun(c, "/v1/apps")); app.use("/ui/*", uiAuth); - app.get("/ui/apps", (c) => listRuns(c, credentials.username)); + app.get("/ui/apps", (c) => listRuns(c, credentials.username, workflowId)); app.post("/ui/apps", capBody, (c) => createRun(c, "/ui/apps", credentials.username), ); - app.get("/ui/apps/:runId", readRun); + app.get("/ui/apps/:runId", (c) => readRun(c, workflowId)); app.delete("/ui/apps/:runId", (c) => deleteRun(c, "/ui/apps", credentials.username), ); + // Two views of the same UI. Each one has a button that opens the other. app.get("/", uiAuth, serveStatic({ path: "./public/index.html" })); + app.get("/table", uiAuth, serveStatic({ path: "./public/table.html" })); app.get("/app.js", uiAuth, serveStatic({ path: "./public/app.js" })); + app.get("/table.js", uiAuth, serveStatic({ path: "./public/table.js" })); + app.get("/runs.js", uiAuth, serveStatic({ path: "./public/runs.js" })); app.get("/style.css", uiAuth, serveStatic({ path: "./public/style.css" })); return app; } -async function listRuns(c: Context, user: string): Promise { +async function listRuns( + c: Context, + user: string, + workflowId: WorkflowIdReader, +): Promise { try { const runs = await listRunsByUser(user); - return c.json({ runs: runs.map(runResponse) }); + const id = workflowId( + runs.find((run) => run.workflowRunId)?.workflowRunId ?? null, + ); + return c.json({ runs: runs.map((run) => runResponse(run, id)) }); } catch (error) { console.error("Failed to list runs:", error); return c.json({ error: "store unavailable" }, 503); @@ -167,7 +186,10 @@ async function createRun( ); } -async function readRun(c: Context): Promise { +async function readRun( + c: Context, + workflowId: WorkflowIdReader, +): Promise { const runId = c.req.param("runId"); if (!runId || !RUN_ID.test(runId)) return c.json({ error: "not found" }, 404); @@ -184,7 +206,7 @@ async function readRun(c: Context): Promise { if (!current) return c.json({ error: "not found" }, 404); run = current; } - return c.json(runResponse(run)); + return c.json(runResponse(run, workflowId(run.workflowRunId))); } catch (error) { console.error("Failed to read run:", error); return c.json({ error: "store unavailable" }, 503); @@ -279,13 +301,20 @@ export interface RunResponse { summary: string | null; createdAt: string; updatedAt: string; + /** When the run stopped, or null while it runs. */ + finishedAt: string | null; + /** Pages in the Render Dashboard, or null for a link that cannot be made yet. */ + links: { workflowRun: string | null; sandbox: string | null }; } /** * The public shape of a run. Summaries are model text, and progress can hold * the error of a failed Render read, so redact both. */ -export function runResponse(run: RunRecord): RunResponse { +export function runResponse( + run: RunRecord, + workflowId: string | null = null, +): RunResponse { return { runId: run.id, status: run.status, @@ -299,6 +328,55 @@ export function runResponse(run: RunRecord): RunResponse { summary: run.summary ? redactSecrets(run.summary) : null, createdAt: run.createdAt, updatedAt: run.updatedAt, + finishedAt: run.finishedAt, + links: { + // The task run that owns the status: prompt-to-app, or delete-app while + // the app is deleted. The Dashboard shows the subtasks of a run on its + // page. The SDK does not give the ID of a subtask run. + workflowRun: + workflowId && run.workflowRunId + ? `${DASHBOARD}/wf/${encodeURIComponent(workflowId)}/runs/${encodeURIComponent(run.workflowRunId)}` + : null, + sandbox: + run.sandboxGroupId && run.sandboxId + ? `${DASHBOARD}/sandbox-group/${encodeURIComponent(run.sandboxGroupId)}/sandboxes/${encodeURIComponent(run.sandboxId)}` + : null, + }, + }; +} + +/** + * Every task run of the factory belongs to the same workflow, so the gateway + * reads its ID once, from the task run of any run. The read does not delay a + * response: until it is done, the responses have no workflow link. A local + * task run is not in the Dashboard, so local development gets no workflow + * links. + */ +function workflowIdReader(): WorkflowIdReader { + let workflowId: string | null = null; + let reading = false; + return (taskRunId) => { + if ( + workflowId || + reading || + !taskRunId || + process.env.RENDER_USE_LOCAL_DEV === "true" + ) { + return workflowId; + } + reading = true; + workflowIdOfTaskRun(taskRunId).then( + (id) => { + workflowId = id; + }, + (error) => { + console.error("Failed to read the workflow ID:", error); + setTimeout(() => { + reading = false; + }, WORKFLOW_ID_RETRY_MS).unref(); + }, + ); + return null; }; } diff --git a/app/render.ts b/app/render.ts index 70643c8..f5f7d8a 100644 --- a/app/render.ts +++ b/app/render.ts @@ -387,6 +387,25 @@ async function readApi(path: string, what: string): Promise { return (await response.json()) as T; } +/* ── Workflows ────────────────────────────────────────────────────────── */ + +/** + * The ID of the workflow that a task run belongs to, for links to the Render + * Dashboard. A task run names only its task, so this reads the task too. + */ +export async function workflowIdOfTaskRun(taskRunId: string): Promise { + const run = await readApi<{ taskId: string }>( + `/task-runs/${encodeURIComponent(taskRunId)}`, + `Reading task run ${taskRunId}`, + ); + const task = await readApi<{ workflowId?: string }>( + `/tasks/${encodeURIComponent(run.taskId)}`, + `Reading task ${run.taskId}`, + ); + if (!task.workflowId) throw new Error(`Task ${run.taskId} names no workflow`); + return task.workflowId; +} + /* ── Blueprints ───────────────────────────────────────────────────────── */ export interface BlueprintRecord { diff --git a/app/sandbox.ts b/app/sandbox.ts index 1bb3991..85adf5c 100644 --- a/app/sandbox.ts +++ b/app/sandbox.ts @@ -143,6 +143,17 @@ export function connectSandbox(sandboxId: string): Sandbox { return new Sandbox(sandboxId); } +/** + * The sandbox group of the workspace, for the link to a sandbox in the Render + * Dashboard. A sandbox does not name its group, but in the alpha a workspace + * has at most one. With more than one, the group of a sandbox is not known. + */ +export async function sandboxGroupId(): Promise { + const { client, ownerId } = api(); + const groups = await client.listGroups({ ownerId }); + return groups.length === 1 ? groups[0].sandboxGroup.id : null; +} + /* ── Postgres in the sandbox ──────────────────────────────────────────── */ /** diff --git a/app/schema.sql b/app/schema.sql index 11f3d28..77c5250 100644 --- a/app/schema.sql +++ b/app/schema.sql @@ -20,14 +20,30 @@ create table if not exists runs ( -- Path of the app's own Blueprint inside the apps repository. blueprint_path text, summary text, + -- The sandbox of the run and its sandbox group, for the link to the + -- sandbox in the Render Dashboard. + sandbox_id text, + sandbox_group_id text, created_at timestamptz not null default now(), - updated_at timestamptz not null default now() + updated_at timestamptz not null default now(), + -- When the run stopped. The UI shows how long it took. A delete changes + -- updated_at, so updated_at cannot tell. + finished_at timestamptz ); -- Keep migrations safe for databases created before these columns existed. alter table runs add column if not exists progress text; alter table runs add column if not exists workflow_run_id text; 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; + +-- 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. +update runs set finished_at = updated_at + where finished_at is null + and status in ('deployed', 'awaiting_blueprint', 'build_failed', 'deploy_failed', 'failed'); -- Makes the concurrency count in claimRun cheap. create index if not exists runs_running diff --git a/app/store.ts b/app/store.ts index 5562a74..91d9efc 100644 --- a/app/store.ts +++ b/app/store.ts @@ -56,8 +56,13 @@ export interface RunRecord { apiUrl: string | null; blueprintPath: string | null; summary: string | null; + /** The sandbox of the run and its sandbox group, for a dashboard link. */ + sandboxId: string | null; + sandboxGroupId: string | null; createdAt: string; updatedAt: string; + /** When the run stopped, or null while it runs. */ + finishedAt: string | null; } export type ClaimResult = @@ -76,7 +81,8 @@ export type DeleteClaim = const COLUMNS = `id, idempotency_key, prompt, user_name, status, stage, progress, workflow_run_id, app_name, web_url, api_url, blueprint_path, summary, - created_at, updated_at`; + sandbox_id, sandbox_group_id, + created_at, updated_at, finished_at`; let pool: pg.Pool | undefined; @@ -329,6 +335,17 @@ export async function deleteRuns(user: string, appName: string): Promise { ); } +/** Cosmetic, as the stage is: the UI links to the sandbox in the Dashboard. */ +export async function setRunSandbox( + id: string, + sandbox: { id: string; groupId: string | null }, +): Promise { + await db().query( + "update runs set sandbox_id = $2, sandbox_group_id = $3 where id = $1", + [id, sandbox.id, sandbox.groupId], + ); +} + export async function setRunUrls( id: string, urls: { webUrl: string | null; apiUrl: string | null }, @@ -351,7 +368,7 @@ export async function finishRun( set status = $2, stage = case when $2 in ('deployed', 'awaiting_blueprint') then 'done' else stage end, progress = null, - summary = $3, updated_at = now() + summary = $3, updated_at = now(), finished_at = now() where id = $1`, [id, status, details.summary ?? null], ); @@ -395,7 +412,10 @@ function rowToRun(row: Record): RunRecord { apiUrl: (row.api_url as string | null) ?? null, blueprintPath: (row.blueprint_path as string | null) ?? null, summary: (row.summary as string | null) ?? null, + sandboxId: (row.sandbox_id as string | null) ?? null, + sandboxGroupId: (row.sandbox_group_id as string | null) ?? null, createdAt: (row.created_at as Date).toISOString(), updatedAt: (row.updated_at as Date).toISOString(), + finishedAt: row.finished_at ? (row.finished_at as Date).toISOString() : null, }; } diff --git a/app/workflow.ts b/app/workflow.ts index 9df8ef7..dbf2975 100644 --- a/app/workflow.ts +++ b/app/workflow.ts @@ -34,12 +34,18 @@ import { awaitDeployment } from "./deploy.js"; import { initAppDir } from "./git.js"; import { collectImages } from "./images.js"; import { oneLine, publishAppTask } from "./publish.js"; -import { createSandbox, ensureSandboxPostgres } from "./sandbox.js"; +import { + createSandbox, + ensureSandboxPostgres, + type Sandbox, + sandboxGroupId, +} from "./sandbox.js"; import { claimRunApp, deleteRuns, failDelete, finishRun, + setRunSandbox, setRunStage, } from "./store.js"; import { materializeTemplate } from "./templates.js"; @@ -128,6 +134,7 @@ async function run( timeoutSeconds: SANDBOX_TIMEOUT_SECONDS, }); try { + await recordSandbox(runId, sandbox); const appDir = appPath(user, appName); // Every agent path resolves against this, so it has to exist first. // The agents can run any command in this sandbox, so it gets no clone @@ -214,6 +221,20 @@ async function run( } } +/** + * The UI links to the sandbox in the Render Dashboard. Only the link needs + * this, so a failure does not stop the run. + */ +async function recordSandbox(runId: string, sandbox: Sandbox): Promise { + const groupId = await sandboxGroupId().catch((error) => { + console.error("Failed to read the sandbox group:", error); + return null; + }); + await setRunSandbox(runId, { id: sandbox.id, groupId }).catch((error) => + console.error("Failed to record the sandbox of the run:", error), + ); +} + interface DeleteResult { status: "deleted"; user: string; diff --git a/docs/FAQ.md b/docs/FAQ.md index 60b0dcf..0d5247f 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -74,6 +74,10 @@ Architecture overview and how Render products fit together: Waiting For Deploys, Smoke Testing, and Done. - 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. - 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`. diff --git a/docs/README.md b/docs/README.md index b47d809..59b10de 100644 --- a/docs/README.md +++ b/docs/README.md @@ -299,6 +299,11 @@ real, billable resources. Local development changes where orchestration runs; 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. 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/app.js b/public/app.js index 375b4d8..afddb87 100644 --- a/public/app.js +++ b/public/app.js @@ -1,84 +1,18 @@ -const form = document.querySelector("#prompt-form"); -const runPanel = document.querySelector("#run-panel"); +import { activeStatuses, formatDate, label, startRunsPage, truncate } from "/runs.js"; + const runList = document.querySelector("#run-list"); -const emptyHistory = document.querySelector("#empty-history"); -const refreshRuns = document.querySelector("#refresh-runs"); -const status = document.querySelector("#status"); -const activity = document.querySelector("#activity"); const stages = document.querySelector("#stages"); -const progress = document.querySelector("#progress"); -const result = document.querySelector("#result"); -const resultName = document.querySelector("#result-name"); -const webUrl = document.querySelector("#web-url"); -const summary = document.querySelector("#summary"); -const runDetails = document.querySelector("#run-details"); -const submit = form.querySelector("button"); const deleteButton = document.querySelector("#delete-run"); -const deleteDialog = document.querySelector("#delete-dialog"); -const deleteForm = document.querySelector("#delete-form"); -const deleteTitle = document.querySelector("#delete-title"); -const deleteDescription = document.querySelector("#delete-description"); -const deleteConfirmField = document.querySelector("#delete-confirm-field"); -const deleteAppName = document.querySelector("#delete-app-name"); -const deleteConfirm = document.querySelector("#delete-confirm"); -const deleteSubmit = document.querySelector("#delete-submit"); -const deleteCancel = document.querySelector("#delete-cancel"); - -/** A task still owns these runs, so the page keeps polling them. */ -const activeStatuses = ["running", "deleting"]; -const healthyStatuses = [...activeStatuses, "deployed", "awaiting_blueprint"]; -/** - * The stage list is static HTML, with the tooltip of each stage. A poll - * changes only the class of each stage. It does not replace the stages, so an - * open tooltip stays open and a focused stage keeps the focus. - */ -const stageItems = [...stages.querySelectorAll("li")]; -const stageOrder = stageItems.map((item) => item.dataset.stage); +/** The run that the run panel shows, for its delete button. */ +let shownRun = null; -let runs = []; -let selectedRunId = null; -let pollGeneration = 0; - -form.addEventListener("submit", async (event) => { - event.preventDefault(); - setBusy(true); - runPanel.hidden = false; - status.textContent = "Submitting prompt"; - - try { - const response = await fetch("/ui/apps", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ prompt: form.prompt.value }), - }); - const body = await response.json(); - if (!response.ok || !body.runId) { - throw new Error(body.detail || body.error || `Request failed (${response.status})`); - } - form.reset(); - await loadRuns(body.runId); - } catch (error) { - showFailure(error instanceof Error ? error.message : String(error)); - } -}); - -refreshRuns.addEventListener("click", () => { - loadRuns(selectedRunId).catch((error) => showFailure(error.message)); -}); +const page = startRunsPage({ renderHistory, renderRun }); deleteButton.addEventListener("click", () => { - const run = runs.find((candidate) => candidate.runId === selectedRunId); - if (run) openDeleteDialog(run); -}); - -deleteConfirm.addEventListener("input", () => { - deleteSubmit.disabled = - deleteConfirm.value.trim() !== deleteForm.dataset.appName; + if (shownRun) page.openDeleteDialog(shownRun); }); -deleteCancel.addEventListener("click", () => deleteDialog.close()); - /** * Escape closes an open stage tooltip, and the pointer and the focus stay * where they are (WCAG 1.4.13). The next stage that the pointer or the focus @@ -91,85 +25,7 @@ for (const type of ["pointerover", "focusin"]) { stages.addEventListener(type, () => stages.classList.remove("tips-closed")); } -deleteForm.addEventListener("submit", async (event) => { - event.preventDefault(); - deleteDialog.close(); - try { - await deleteRun(deleteForm.dataset.runId); - } catch (error) { - showFailure(error instanceof Error ? error.message : String(error)); - } -}); - -/** - * An app is deleted on Render and in the apps repository, with all of its - * runs, and the delete cannot be undone. So the dialog asks for the app's - * name, as hosting dashboards do before such a delete. - */ -function openDeleteDialog(run) { - const appName = run.appName || ""; - deleteForm.dataset.runId = run.runId; - deleteForm.dataset.appName = appName; - deleteTitle.textContent = appName - ? `Delete ${titleFromSlug(appName)}?` - : "Delete this run?"; - deleteDescription.textContent = appName - ? "This deletes the app's services and databases on Render, with all their data, " + - "and removes its files from the apps repository. Every run of this app leaves your " + - "history. You cannot undo this." - : "This run did not create an app, so only the run leaves your history."; - deleteConfirmField.hidden = !appName; - deleteAppName.textContent = appName; - deleteConfirm.value = ""; - deleteSubmit.disabled = Boolean(appName); - deleteDialog.showModal(); - (appName ? deleteConfirm : deleteSubmit).focus(); -} - -async function deleteRun(runId) { - const response = await fetch(`/ui/apps/${encodeURIComponent(runId)}`, { - method: "DELETE", - }); - const body = await response.json().catch(() => ({})); - if (!response.ok) { - throw new Error(body.error || `Delete failed (${response.status})`); - } - if (body.status === "deleted") { - await runRemoved(); - return; - } - // Every run of the app is deleting now, so read them all again. - await loadRuns(runId); -} - -/** The run is gone: a delete finished, or the run had no app to delete. */ -async function runRemoved() { - selectedRunId = null; - pollGeneration += 1; - runPanel.hidden = true; - await loadRuns(); -} - -async function loadRuns(preferredRunId) { - const response = await fetch("/ui/apps"); - if (!response.ok) throw new Error(`Could not load run history (${response.status})`); - const body = await response.json(); - runs = body.runs || []; - renderHistory(); - - const nextRunId = - (preferredRunId && runs.some((run) => run.runId === preferredRunId) - ? preferredRunId - : null) || - (selectedRunId && runs.some((run) => run.runId === selectedRunId) - ? selectedRunId - : null) || - runs[0]?.runId; - if (nextRunId) await selectRun(nextRunId); -} - -function renderHistory() { - emptyHistory.hidden = runs.length > 0; +function renderHistory(runs, selectedRunId, { select }) { runList.replaceChildren( ...runs.map((run) => { const button = document.createElement("button"); @@ -189,129 +45,14 @@ function renderHistory() { meta.className = "run-meta"; meta.append(state, ` · ${formatDate(run.createdAt)}`); button.append(name, meta); - button.addEventListener("click", () => selectRun(run.runId)); + button.addEventListener("click", () => select(run.runId)); return button; }), ); } -async function selectRun(runId) { - selectedRunId = runId; - localStorage.setItem("vibe-code-selected-run", runId); - pollGeneration += 1; - const generation = pollGeneration; - renderHistory(); - await poll(runId, generation); -} - -async function poll(runId, generation) { - while (generation === pollGeneration && runId === selectedRunId) { - const response = await fetch(`/ui/apps/${encodeURIComponent(runId)}`); - if (generation !== pollGeneration) return; - // A delete that finished removed the run. - if (response.status === 404) { - await runRemoved(); - return; - } - if (!response.ok) throw new Error(`Status check failed (${response.status})`); - const run = await response.json(); - upsertRun(run); - renderRun(run); - renderHistory(); - if (!activeStatuses.includes(run.status)) return; - await new Promise((resolve) => setTimeout(resolve, 5000)); - } -} - -function upsertRun(run) { - const index = runs.findIndex((candidate) => candidate.runId === run.runId); - if (index === -1) runs.unshift(run); - else runs[index] = run; -} - function renderRun(run) { - runPanel.hidden = false; - runPanel.classList.toggle("failed", !healthyStatuses.includes(run.status)); - status.textContent = - run.status === "running" ? label(run.stage || "queued") : label(run.status); - progress.textContent = run.progress || ""; - activity.hidden = !activeStatuses.includes(run.status); + shownRun = run; deleteButton.hidden = activeStatuses.includes(run.status); deleteButton.textContent = run.appName ? "Delete app" : "Delete run"; - setBusy(run.status === "running"); - // The stages are those of a build, so a delete does not show them. - stages.hidden = ["deleting", "delete_failed"].includes(run.status); - - const current = stageOrder.indexOf(run.stage); - stageItems.forEach((item, index) => { - let state = ""; - if (index < current || run.status === "deployed") state = "complete"; - if (index === current && run.status === "running") state = "active"; - if ( - index === current && - !["running", "deployed", "awaiting_blueprint"].includes(run.status) - ) { - state = "failed-stage"; - } - item.className = state; - }); - - const deployed = run.status === "deployed" && Boolean(run.urls?.web); - result.hidden = !deployed; - if (deployed) { - resultName.textContent = titleFromSlug(run.appName) || "Your website"; - summary.textContent = resultSummary(run.summary); - webUrl.href = run.urls.web; - } - - const showDetails = - !activeStatuses.includes(run.status) && - run.status !== "deployed" && - Boolean(run.summary); - runDetails.hidden = !showDetails; - runDetails.textContent = showDetails ? run.summary : ""; -} - -function setBusy(busy) { - submit.disabled = busy; - submit.textContent = busy ? "Building…" : "Build and deploy"; -} - -function showFailure(message) { - runPanel.hidden = false; - runPanel.classList.add("failed"); - status.textContent = "Request failed"; - progress.textContent = message; - setBusy(false); -} - -function label(value) { - return value.replaceAll("_", " ").replace(/\b\w/g, (character) => character.toUpperCase()); -} - -function truncate(value, length) { - return value.length > length ? `${value.slice(0, length - 1)}…` : value; -} - -function titleFromSlug(value) { - return value ? label(value.replaceAll("-", " ")) : ""; -} - -function resultSummary(value) { - if (!value) return "Your website is ready."; - const built = value.split(/\n\s*\n/)[0].replace(/\s+/g, " ").trim(); - return truncate(built, 220); } - -function formatDate(value) { - return new Intl.DateTimeFormat(undefined, { - month: "short", - day: "numeric", - hour: "numeric", - minute: "2-digit", - }).format(new Date(value)); -} - -loadRuns(localStorage.getItem("vibe-code-selected-run")).catch((error) => - showFailure(error.message), -); diff --git a/public/index.html b/public/index.html index d90f078..15992ac 100644 --- a/public/index.html +++ b/public/index.html @@ -21,6 +21,7 @@
Vibe Code Demo + Table view
diff --git a/public/runs.js b/public/runs.js new file mode 100644 index 0000000..3135f16 --- /dev/null +++ b/public/runs.js @@ -0,0 +1,293 @@ +/** + * What the two views of the UI share: the prompt form, the history of runs, + * the poll of the selected run, the run panel, the stages, and the delete + * dialog. The two pages use the same IDs for these elements. Each view renders + * its own history, and it can add to the run panel. + */ + +/** A task still owns these runs, so the page keeps polling them. */ +export const activeStatuses = ["running", "deleting"]; +const healthyStatuses = [...activeStatuses, "deployed", "awaiting_blueprint"]; + +/** + * Start the page. `view.renderHistory(runs, selectedRunId, actions)` renders + * the history, and `view.renderRun(run, actions)`, if the view has it, adds to + * the run panel. `actions`, which this also returns, has `select(runId)` and + * `openDeleteDialog(run)`. + */ +export function startRunsPage(view) { + const form = document.querySelector("#prompt-form"); + const runPanel = document.querySelector("#run-panel"); + const emptyHistory = document.querySelector("#empty-history"); + const refreshRuns = document.querySelector("#refresh-runs"); + const status = document.querySelector("#status"); + const activity = document.querySelector("#activity"); + const stages = document.querySelector("#stages"); + const progress = document.querySelector("#progress"); + const result = document.querySelector("#result"); + const resultName = document.querySelector("#result-name"); + const webUrl = document.querySelector("#web-url"); + const summary = document.querySelector("#summary"); + const runDetails = document.querySelector("#run-details"); + const submit = form.querySelector("button"); + const deleteDialog = document.querySelector("#delete-dialog"); + const deleteForm = document.querySelector("#delete-form"); + const deleteTitle = document.querySelector("#delete-title"); + const deleteDescription = document.querySelector("#delete-description"); + const deleteConfirmField = document.querySelector("#delete-confirm-field"); + const deleteAppName = document.querySelector("#delete-app-name"); + const deleteConfirm = document.querySelector("#delete-confirm"); + const deleteSubmit = document.querySelector("#delete-submit"); + const deleteCancel = document.querySelector("#delete-cancel"); + + /** + * The stages are static HTML. A poll changes only the class of each stage. + * It does not replace the stages, so the live region of the run panel + * does not read them again, and a focused stage keeps the focus. + */ + const stageItems = [...stages.querySelectorAll("[data-stage]")]; + const stageOrder = stageItems.map((item) => item.dataset.stage); + + const actions = { select: selectRun, openDeleteDialog }; + let runs = []; + let selectedRunId = null; + let pollGeneration = 0; + + form.addEventListener("submit", async (event) => { + event.preventDefault(); + setBusy(true); + runPanel.hidden = false; + status.textContent = "Submitting prompt"; + + try { + const response = await fetch("/ui/apps", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ prompt: form.prompt.value }), + }); + const body = await response.json(); + if (!response.ok || !body.runId) { + throw new Error(body.detail || body.error || `Request failed (${response.status})`); + } + form.reset(); + await loadRuns(body.runId); + } catch (error) { + showFailure(error instanceof Error ? error.message : String(error)); + } + }); + + refreshRuns.addEventListener("click", () => { + loadRuns(selectedRunId).catch((error) => showFailure(error.message)); + }); + + deleteConfirm.addEventListener("input", () => { + deleteSubmit.disabled = + deleteConfirm.value.trim() !== deleteForm.dataset.appName; + }); + + deleteCancel.addEventListener("click", () => deleteDialog.close()); + + deleteForm.addEventListener("submit", async (event) => { + event.preventDefault(); + deleteDialog.close(); + try { + await deleteRun(deleteForm.dataset.runId); + } catch (error) { + showFailure(error instanceof Error ? error.message : String(error)); + } + }); + + /** + * An app is deleted on Render and in the apps repository, with all of its + * runs, and the delete cannot be undone. So the dialog asks for the app's + * name, as hosting dashboards do before such a delete. + */ + function openDeleteDialog(run) { + const appName = run.appName || ""; + deleteForm.dataset.runId = run.runId; + deleteForm.dataset.appName = appName; + deleteTitle.textContent = appName + ? `Delete ${titleFromSlug(appName)}?` + : "Delete this run?"; + deleteDescription.textContent = appName + ? "This deletes the app's services and databases on Render, with all their data, " + + "and removes its files from the apps repository. Every run of this app leaves your " + + "history. You cannot undo this." + : "This run did not create an app, so only the run leaves your history."; + deleteConfirmField.hidden = !appName; + deleteAppName.textContent = appName; + deleteConfirm.value = ""; + deleteSubmit.disabled = Boolean(appName); + deleteDialog.showModal(); + (appName ? deleteConfirm : deleteSubmit).focus(); + } + + async function deleteRun(runId) { + const response = await fetch(`/ui/apps/${encodeURIComponent(runId)}`, { + method: "DELETE", + }); + const body = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(body.error || `Delete failed (${response.status})`); + } + if (body.status === "deleted") { + await runRemoved(); + return; + } + // Every run of the app is deleting now, so read them all again. + await loadRuns(runId); + } + + /** The run is gone: a delete finished, or the run had no app to delete. */ + async function runRemoved() { + selectedRunId = null; + pollGeneration += 1; + runPanel.hidden = true; + await loadRuns(); + } + + async function loadRuns(preferredRunId) { + const response = await fetch("/ui/apps"); + if (!response.ok) throw new Error(`Could not load run history (${response.status})`); + const body = await response.json(); + runs = body.runs || []; + renderHistory(); + + const nextRunId = + (preferredRunId && runs.some((run) => run.runId === preferredRunId) + ? preferredRunId + : null) || + (selectedRunId && runs.some((run) => run.runId === selectedRunId) + ? selectedRunId + : null) || + runs[0]?.runId; + if (nextRunId) await selectRun(nextRunId); + } + + function renderHistory() { + emptyHistory.hidden = runs.length > 0; + view.renderHistory(runs, selectedRunId, actions); + } + + async function selectRun(runId) { + selectedRunId = runId; + localStorage.setItem("vibe-code-selected-run", runId); + pollGeneration += 1; + const generation = pollGeneration; + renderHistory(); + await poll(runId, generation); + } + + async function poll(runId, generation) { + while (generation === pollGeneration && runId === selectedRunId) { + const response = await fetch(`/ui/apps/${encodeURIComponent(runId)}`); + if (generation !== pollGeneration) return; + // A delete that finished removed the run. + if (response.status === 404) { + await runRemoved(); + return; + } + if (!response.ok) throw new Error(`Status check failed (${response.status})`); + const run = await response.json(); + upsertRun(run); + renderRun(run); + renderHistory(); + if (!activeStatuses.includes(run.status)) return; + await new Promise((resolve) => setTimeout(resolve, 5000)); + } + } + + function upsertRun(run) { + const index = runs.findIndex((candidate) => candidate.runId === run.runId); + if (index === -1) runs.unshift(run); + else runs[index] = run; + } + + function renderRun(run) { + runPanel.hidden = false; + runPanel.classList.toggle("failed", !healthyStatuses.includes(run.status)); + status.textContent = + run.status === "running" ? label(run.stage || "queued") : label(run.status); + progress.textContent = run.progress || ""; + activity.hidden = !activeStatuses.includes(run.status); + setBusy(run.status === "running"); + // The stages are those of a build, so a delete does not show them. + stages.hidden = ["deleting", "delete_failed"].includes(run.status); + + const current = stageOrder.indexOf(run.stage); + stageItems.forEach((item, index) => { + let state = ""; + if (index < current || run.status === "deployed") state = "complete"; + if (index === current && run.status === "running") state = "active"; + if ( + index === current && + !["running", "deployed", "awaiting_blueprint"].includes(run.status) + ) { + state = "failed-stage"; + } + item.className = state; + }); + + const deployed = run.status === "deployed" && Boolean(run.urls?.web); + result.hidden = !deployed; + if (deployed) { + resultName.textContent = titleFromSlug(run.appName) || "Your website"; + summary.textContent = resultSummary(run.summary); + webUrl.href = run.urls.web; + } + + const showDetails = + !activeStatuses.includes(run.status) && + run.status !== "deployed" && + Boolean(run.summary); + runDetails.hidden = !showDetails; + runDetails.textContent = showDetails ? run.summary : ""; + + view.renderRun?.(run, actions); + } + + function setBusy(busy) { + submit.disabled = busy; + submit.textContent = busy ? "Building…" : "Build and deploy"; + } + + function showFailure(message) { + runPanel.hidden = false; + runPanel.classList.add("failed"); + status.textContent = "Request failed"; + progress.textContent = message; + setBusy(false); + } + + loadRuns(localStorage.getItem("vibe-code-selected-run")).catch((error) => + showFailure(error.message), + ); + return actions; +} + +export function label(value) { + return value.replaceAll("_", " ").replace(/\b\w/g, (character) => character.toUpperCase()); +} + +export function truncate(value, length) { + return value.length > length ? `${value.slice(0, length - 1)}…` : value; +} + +function titleFromSlug(value) { + return value ? label(value.replaceAll("-", " ")) : ""; +} + +function resultSummary(value) { + if (!value) return "Your website is ready."; + const built = value.split(/\n\s*\n/)[0].replace(/\s+/g, " ").trim(); + return truncate(built, 220); +} + +export function formatDate(value) { + return new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }).format(new Date(value)); +} diff --git a/public/style.css b/public/style.css index a4cde90..4a37c83 100644 --- a/public/style.css +++ b/public/style.css @@ -96,6 +96,13 @@ code { height: 20px; } +/* The button that opens the other view of the UI. */ +.view-switch { + margin-left: auto; + text-decoration: none; + white-space: nowrap; +} + .topbar-label { padding-left: 16px; border-left: 1px solid var(--border); @@ -659,11 +666,243 @@ input { .danger-button:focus-visible, .run-item:focus-visible, .stage-label:focus-visible, +.site-name:focus-visible, +.site-url:focus-visible, +.stage-links a:focus-visible, .primary-link:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +/* + * The table view. Its tables have a mono header row, 1px rules, and no fills, + * as Render's tables do. The row of the selected site, and the row of the + * stage that runs now, have a neutral tint. + */ +.sites-panel { + display: flex; + flex-direction: column; + gap: 16px; +} + +.table-scroll { + overflow-x: auto; +} + +.sites-table, +.stage-table { + width: 100%; + border-collapse: collapse; +} + +.sites-table th, +.sites-table td, +.stage-table th, +.stage-table td { + padding: 10px 12px; + border-bottom: 1px solid var(--border); + text-align: left; + vertical-align: top; +} + +.sites-table thead th, +.stage-table thead th { + padding-top: 0; + color: var(--text-faint); + font-family: var(--font-mono); + font-size: 12px; + font-weight: 500; + line-height: 16px; + letter-spacing: 0.02em; + text-transform: uppercase; + white-space: nowrap; +} + +/* On a narrow screen, the sites table scrolls sideways in its panel. */ +.sites-table { + min-width: 760px; +} + +.sites-table tbody th, +.sites-table td { + color: var(--text-secondary); + font-size: 14px; + font-weight: 400; + line-height: 20px; + vertical-align: middle; +} + +.sites-table tbody tr { + cursor: pointer; +} + +.sites-table tbody tr:hover { + background: var(--hover-tint); +} + +.sites-table tbody tr.selected { + background: var(--selected); +} + +.site-name { + padding: 0; + border: 0; + background: none; + color: var(--text); + font: inherit; + font-size: 15px; + text-align: left; + white-space: nowrap; + cursor: pointer; +} + +.sites-table tr.selected .site-name { + font-weight: 500; +} + +.sites-table .run-state, +.sites-table td:nth-child(4), +.sites-table td:nth-child(5) { + font-family: var(--font-mono); + font-size: 12px; + letter-spacing: 0; + white-space: nowrap; +} + +.site-url { + display: block; + max-width: 300px; + overflow: hidden; + color: var(--link); + text-decoration: none; + text-overflow: ellipsis; + white-space: nowrap; +} + +.site-url:hover, +.stage-links a:hover { + color: var(--link-hover); + text-decoration: underline; +} + +.table-button { + min-height: 28px; + padding: 0 10px; + font-size: 13px; +} + +.none { + color: var(--text-faint); +} + +.stage-table { + table-layout: fixed; +} + +.stage-table thead th:nth-child(1) { + width: 18%; +} + +.stage-table thead th:nth-child(3) { + width: 24%; +} + +.stage-table thead th:nth-child(4) { + width: 14%; +} + +/* The name of a stage, after a square that shows its state. */ +.stage-table tbody th { + position: relative; + padding-left: 32px; + color: var(--text-faint); + font-family: var(--font-mono); + font-size: 13px; + font-weight: 400; + line-height: 20px; + letter-spacing: 0; +} + +.stage-table tbody th::before { + position: absolute; + top: 15px; + left: 12px; + width: 10px; + height: 10px; + border: 1px solid var(--border-strong); + content: ""; +} + +.stage-table td { + color: var(--text-secondary); + font-size: 14px; + line-height: 20px; +} + +.stage-table .stage-where { + color: var(--text); +} + +.stage-where span { + display: block; + color: var(--text-faint); + font-size: 13px; + line-height: 18px; +} + +.stage-links a { + display: block; + color: var(--link); + text-decoration: none; +} + +.stage-table tr.complete th { + color: var(--success); +} + +.stage-table tr.complete th::before { + border-color: var(--success); + background: var(--success); +} + +.stage-table tr.active { + background: var(--selected); +} + +.stage-table tr.active th, +.stage-table tr.active td { + color: var(--text); +} + +.stage-table tr.active th { + font-weight: 500; +} + +.stage-table tr.active th::before { + border-color: var(--text); + background: var(--text); + animation: pulse 1.2s var(--ease) infinite; +} + +.stage-table tr.failed-stage th { + color: var(--danger); +} + +.stage-table tr.failed-stage th::before { + border-color: var(--danger); + background: var(--danger); +} + +/* Text for a screen reader only, such as the header of the delete column. */ +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} + @keyframes pulse { 50% { opacity: 0.25; @@ -672,7 +911,8 @@ input { @media (prefers-reduced-motion: reduce) { .activity, - .stages li.active::before { + .stages li.active::before, + .stage-table tr.active th::before { animation: none; } @@ -742,6 +982,54 @@ input { width: 100%; min-height: 44px; } + + /* Each stage is a block: its name, 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; + } + + .stage-table thead { + display: none; + } + + .stage-table tbody { + border-top: 1px solid var(--border); + } + + .stage-table tbody tr { + padding: 10px 0; + border-bottom: 1px solid var(--border); + } + + .stage-table th, + .stage-table td { + padding: 0 12px 0 32px; + border-bottom: 0; + } + + .stage-table td { + margin-top: 4px; + } + + .stage-table tbody th::before { + top: 5px; + } + + .stage-links a { + display: inline; + margin-right: 16px; + } +} + +/* A phone has no room for the name of the demo beside the view button. */ +@media (max-width: 479px) { + .topbar-label { + display: none; + } } /* Last, so that it wins over each display value above. */ diff --git a/public/table.html b/public/table.html new file mode 100644 index 0000000..4a90c2f --- /dev/null +++ b/public/table.html @@ -0,0 +1,205 @@ + + + + + + + Vibe Code Demo + + + + + + + + +
+ + Vibe Code Demo + Classic view +
+ +
+
+

Render Workflows demo

+

Describe it. Ship it.

+

+ Submit an app idea and follow it from design through a live Render deployment. +

+
+ +
+
+ + +
+ +
+
+
+ +
+
+
+

History

+

Your sites

+
+ +
+

No sites yet.

+ +
+ + + + +
+
+

Delete

+

Delete this app?

+
+

+
+ + +
+
+ + +
+
+
+
+ + + diff --git a/public/table.js b/public/table.js new file mode 100644 index 0000000..6a70750 --- /dev/null +++ b/public/table.js @@ -0,0 +1,125 @@ +import { activeStatuses, formatDate, label, startRunsPage, truncate } from "/runs.js"; + +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 links in the stage table, so that a poll changes them only when they change. */ +let shownLinks = null; + +startRunsPage({ renderHistory, renderRun }); + +function renderHistory(runs, selectedRunId, { select, openDeleteDialog }) { + sites.hidden = runs.length === 0; + siteRows.replaceChildren( + ...runs.map((run) => { + const selected = run.runId === selectedRunId; + const row = document.createElement("tr"); + row.className = selected ? "selected" : ""; + // A click anywhere on the row selects it, except on its link or its button. + row.addEventListener("click", (event) => { + if (!event.target.closest("a, button")) select(run.runId); + }); + + const name = document.createElement("button"); + name.type = "button"; + name.className = "site-name"; + name.textContent = run.appName || truncate(run.prompt, 34); + name.setAttribute("aria-pressed", String(selected)); + name.addEventListener("click", () => select(run.runId)); + const nameCell = document.createElement("th"); + nameCell.scope = "row"; + nameCell.append(name); + + const state = document.createElement("span"); + state.className = "run-state"; + state.dataset.status = run.status; + state.textContent = label(run.status); + + const remove = document.createElement("button"); + remove.type = "button"; + remove.className = "danger-button table-button"; + remove.textContent = "Delete"; + remove.setAttribute("aria-label", `Delete ${run.appName || "this run"}`); + remove.hidden = activeStatuses.includes(run.status); + remove.addEventListener("click", () => openDeleteDialog(run)); + + row.append( + nameCell, + cell(state), + cell(run.urls?.web ? siteLink(run.urls.web) : none()), + cell(generationTime(run)), + cell(formatDate(run.createdAt)), + cell(remove), + ); + return row; + }), + ); +} + +/** + * 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) { + const links = JSON.stringify(run.links ?? {}); + if (links === shownLinks) return; + shownLinks = links; + for (const linkCell of linkCells) { + const anchors = linkCell.dataset.links + .split(" ") + .filter((name) => run.links?.[name]) + .map((name) => dashboardLink(run.links[name], linkLabels[name])); + linkCell.replaceChildren(...(anchors.length > 0 ? anchors : [none()])); + } +} + +function cell(content) { + const td = document.createElement("td"); + td.append(content); + return td; +} + +function siteLink(url) { + const anchor = dashboardLink(url, new URL(url).host); + anchor.className = "site-url"; + anchor.title = url; + return anchor; +} + +function dashboardLink(url, text) { + const anchor = document.createElement("a"); + anchor.href = url; + anchor.target = "_blank"; + anchor.rel = "noreferrer"; + anchor.textContent = text; + return anchor; +} + +function none() { + const span = document.createElement("span"); + span.className = "none"; + span.textContent = "—"; + return span; +} + +/** How long the run took to build and deploy, or how long it has run so far. */ +function generationTime(run) { + const end = run.finishedAt + ? Date.parse(run.finishedAt) + : run.status === "running" + ? Date.now() + : null; + return end === null ? "—" : formatDuration(end - Date.parse(run.createdAt)); +} + +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 (minutes > 0) return `${minutes}m ${seconds % 60}s`; + return `${seconds}s`; +} diff --git a/scripts/doctor.ts b/scripts/doctor.ts index 8a8789c..d1e65e9 100644 --- a/scripts/doctor.ts +++ b/scripts/doctor.ts @@ -340,6 +340,9 @@ async function checkPostgres(): Promise { "workflow_run_id", "web_url", "blueprint_path", + "sandbox_id", + "sandbox_group_id", + "finished_at", ].filter((name) => !columns.has(name)); record( missing.length === 0 diff --git a/tests/gateway.test.ts b/tests/gateway.test.ts index 3cd4cb5..f017536 100644 --- a/tests/gateway.test.ts +++ b/tests/gateway.test.ts @@ -14,6 +14,7 @@ const mocks = vi.hoisted(() => ({ setWorkflowRunId: vi.fn(), startTask: vi.fn(), getTaskRun: vi.fn(), + workflowIdOfTaskRun: vi.fn(), })); vi.mock("../app/store.js", () => ({ @@ -30,6 +31,10 @@ vi.mock("../app/store.js", () => ({ setWorkflowRunId: mocks.setWorkflowRunId, })); +vi.mock("../app/render.js", () => ({ + workflowIdOfTaskRun: mocks.workflowIdOfTaskRun, +})); + vi.mock("@renderinc/sdk", () => ({ Render: class { workflows = { @@ -70,6 +75,7 @@ beforeEach(() => { mocks.startTask.mockResolvedValue({ taskRunId: "trn-1" }); mocks.claimWorkflowCheck.mockResolvedValue(false); mocks.setWorkflowRunId.mockResolvedValue(undefined); + mocks.workflowIdOfTaskRun.mockResolvedValue("wfl-1"); process.env.FACTORY_API_KEY = KEY; process.env.UI_USERNAME = "demo"; @@ -77,6 +83,7 @@ beforeEach(() => { process.env.RENDER_WORKFLOW_SLUG = "wfs-1"; delete process.env.UI_AUTH_DISABLED; delete process.env.NODE_ENV; + delete process.env.RENDER_USE_LOCAL_DEV; }); describe("health", () => { @@ -242,6 +249,38 @@ describe("browser UI", () => { } }); + it.each(["/table", "/table.js", "/runs.js", "/app.js"])( + "requires Basic Auth for %s", + async (path) => { + expect((await createGateway().request(path)).status).toBe(401); + const response = await createGateway().request(path, { + headers: { authorization }, + }); + expect(response.status).toBe(200); + }, + ); + + it("explains each run stage in the table view, with its dashboard links", async () => { + const { RUN_STAGES } = + await vi.importActual("../app/store.js"); + const response = await createGateway().request("/table", { + headers: { authorization }, + }); + const html = await response.text(); + const rows = [...html.matchAll(/([\s\S]*?)<\/tr>/g)]; + + 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. + 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(cells).toMatch(//); + } + }); + it("allows the explicit auth bypass only outside production", async () => { process.env.UI_AUTH_DISABLED = "true"; expect((await createGateway().request("/")).status).toBe(200); @@ -364,6 +403,60 @@ describe("status", () => { }, ); + /** Two reads of the run from one gateway, as the polls of the UI do. */ + async function readTwice() { + const gateway = createGateway(); + const read = async () => + (await ( + await gateway.request(`/v1/apps/${RUN_ID}`, { + headers: { authorization: `Bearer ${KEY}` }, + }) + ).json()) as { finishedAt: string; links: Record }; + return [await read(), await read()]; + } + + it("links the run to its task run and its sandbox in the Render Dashboard", async () => { + mocks.getRun.mockResolvedValue( + storedRun({ sandboxId: "sbx-1", sandboxGroupId: "sbg-1" }), + ); + + const [first, second] = await readTwice(); + + // The workflow ID is read after the first response, and only once. + expect(first.links.workflowRun).toBeNull(); + expect(mocks.workflowIdOfTaskRun).toHaveBeenCalledTimes(1); + expect(mocks.workflowIdOfTaskRun).toHaveBeenCalledWith("trn-1"); + expect(second.finishedAt).toBe("2026-01-01T00:09:00.000Z"); + expect(second.links).toEqual({ + workflowRun: "https://dashboard.render.com/wf/wfl-1/runs/trn-1", + sandbox: "https://dashboard.render.com/sandbox-group/sbg-1/sandboxes/sbx-1", + }); + }); + + it("gives no link that it cannot make", async () => { + const logged = vi.spyOn(console, "error").mockImplementation(() => {}); + mocks.getRun.mockResolvedValue(storedRun({ sandboxId: "sbx-1" })); + mocks.workflowIdOfTaskRun.mockRejectedValue(new Error("403")); + + const [, second] = await readTwice(); + logged.mockRestore(); + + expect(second.links).toEqual({ workflowRun: null, sandbox: null }); + // A failed read is tried again after a minute, not on each poll. + expect(mocks.workflowIdOfTaskRun).toHaveBeenCalledTimes(1); + }); + + // A local task run is not in the Dashboard. + it("gives no workflow link in local development", async () => { + process.env.RENDER_USE_LOCAL_DEV = "true"; + mocks.getRun.mockResolvedValue(storedRun()); + + const [, second] = await readTwice(); + + expect(second.links.workflowRun).toBeNull(); + expect(mocks.workflowIdOfTaskRun).not.toHaveBeenCalled(); + }); + it("404s an id that is not a run id without querying Postgres", async () => { const response = await createGateway().request("/v1/apps/not-a-uuid", { headers: { authorization: `Bearer ${KEY}` }, @@ -389,8 +482,11 @@ function storedRun(overrides: Record = {}) { apiUrl: null, blueprintPath: "apps/demo/furniture-catalog/render.yaml", summary: "Deployed.", + sandboxId: null, + sandboxGroupId: null, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:10:00.000Z", + finishedAt: "2026-01-01T00:09:00.000Z", ...overrides, }; } diff --git a/tests/render.test.ts b/tests/render.test.ts index dd14a0b..e243f8e 100644 --- a/tests/render.test.ts +++ b/tests/render.test.ts @@ -10,6 +10,7 @@ import { pageScripts, waitForDeploy, waitForServices, + workflowIdOfTaskRun, } from "../app/render.js"; const REST_API = "https://api.render.com/v1"; @@ -581,3 +582,41 @@ describe("findBlueprint", () => { expect(urls).toHaveLength(1); }); }); + +/** The gateway links each run to its task run in the Render Dashboard. */ +describe("workflowIdOfTaskRun", () => { + beforeEach(() => vi.stubEnv("RENDER_API_KEY", "rnd_test")); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + }); + + function renderHas(task: Record) { + const fetchMock = vi.fn(async (url: string | URL | Request) => + String(url).includes("/task-runs/") + ? Response.json({ id: "trn-1", taskId: "tsk-1" }) + : Response.json(task), + ); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; + } + + it("reads the task of the task run, and gives its workflow", async () => { + const fetchMock = renderHas({ id: "tsk-1", workflowId: "wfl-1" }); + + expect(await workflowIdOfTaskRun("trn-1")).toBe("wfl-1"); + expect(fetchMock.mock.calls.map(([url]) => String(url))).toEqual([ + `${REST_API}/task-runs/trn-1`, + `${REST_API}/tasks/tsk-1`, + ]); + }); + + it("fails when the task names no workflow", async () => { + renderHas({ id: "tsk-1" }); + + await expect(workflowIdOfTaskRun("trn-1")).rejects.toThrow( + "Task tsk-1 names no workflow", + ); + }); +}); diff --git a/tests/workflow.test.ts b/tests/workflow.test.ts index f6a4e58..9d8944d 100644 --- a/tests/workflow.test.ts +++ b/tests/workflow.test.ts @@ -47,6 +47,8 @@ const mocks = vi.hoisted(() => ({ deleteRuns: vi.fn(async () => {}), failDelete: vi.fn(async () => {}), finishRun: vi.fn(async () => {}), + setRunSandbox: vi.fn(async () => {}), + sandboxGroupId: vi.fn(async (): Promise => "sbg-test"), })); vi.mock("../app/agents.js", () => ({ @@ -90,6 +92,7 @@ vi.mock("../app/store.js", () => ({ failDelete: mocks.failDelete, finishRun: mocks.finishRun, setDeleteProgress: vi.fn(async () => {}), + setRunSandbox: mocks.setRunSandbox, setRunStage: vi.fn(async () => {}), setRunUrls: vi.fn(async () => {}), touchRun: vi.fn(async () => {}), @@ -113,6 +116,7 @@ vi.mock("../app/sandbox.js", async (importOriginal) => ({ ...(await importOriginal()), createSandbox: mocks.createSandbox, connectSandbox: mocks.connectSandbox, + sandboxGroupId: mocks.sandboxGroupId, })); vi.mock("../app/teardown.js", () => ({ @@ -1484,12 +1488,32 @@ describe("promptToApp", () => { vi.restoreAllMocks(); }); + // Only a link in the UI needs the sandbox record. + it("runs on when it cannot record the sandbox", async () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + mocks.sandboxGroupId.mockRejectedValueOnce(new Error("forbidden")); + mocks.setRunSandbox.mockRejectedValueOnce(new Error("store down")); + + const result = await promptToApp.func(tasks, INPUT); + + expect(result.status, result.summary).toBe("awaiting_blueprint"); + expect(mocks.setRunSandbox).toHaveBeenCalledWith("run-1", { + id: build.sandbox.id, + groupId: null, + }); + }); + it("verifies the app and then publishes it", async () => { const { context, runs } = recordSubtasks(); const result = await promptToApp.func(context, INPUT); expect(result.status, result.summary).toBe("awaiting_blueprint"); + // The UI links to the sandbox of the run in the Render Dashboard. + expect(mocks.setRunSandbox).toHaveBeenCalledWith("run-1", { + id: build.sandbox.id, + groupId: "sbg-test", + }); expect(runs.map(({ name }) => name)).toEqual([ "architect", "builder",