From 36b21d25008c7e38d4d117cf83c5ca9b041c43ce Mon Sep 17 00:00:00 2001 From: Dillon Lillehaug Date: Sun, 13 Sep 2026 14:17:24 +0000 Subject: [PATCH 1/2] Show update progress from submission through rollout completion --- .../updates/tests/commands-worker.test.js | 9 +++ core/core/updates/worker.js | 6 +- core/dashboard/frontend/src/pages/Updates.tsx | 73 +++++++++++++++---- core/dashboard/playwright.updates.config.cjs | 2 +- .../tests/browser/update-progress.spec.cjs | 63 ++++++++++++++++ 5 files changed, 137 insertions(+), 16 deletions(-) create mode 100644 core/dashboard/tests/browser/update-progress.spec.cjs diff --git a/core/core/updates/tests/commands-worker.test.js b/core/core/updates/tests/commands-worker.test.js index 44312d2..e5a563b 100644 --- a/core/core/updates/tests/commands-worker.test.js +++ b/core/core/updates/tests/commands-worker.test.js @@ -32,6 +32,15 @@ test('rollout submission captures the fleet and rechecks the release before acti f.state.rollout = { status: 'running', actor: 'owner', digest: 'a'.repeat(64) }; await f.worker.tick(); assert.equal(f.events.at(-1)[0], 'step'); }); +test('runtime and backup failures reach the update status without exposing arbitrary errors', async t => { + const f=fixture(t); + for(const [error,expected] of [['directory_runtime_not_ready','release_runtime_not_ready'],['directory_backup_unsafe','release_backup_unsafe'],['private host detail','release_operation_failed']]) { + f.options.invoke=async()=>{throw new Error(error);}; + const job=f.request('rollout','dsp','a'.repeat(64),expected); + await new UpdateWorker(f.options).tick(); + assert.equal(f.commands.list().find(row=>row.id===job.id).failure,expected); + } +}); test('revoked owners cannot execute queued updates or advance a fleet', async t => { const f = fixture(t), job = f.request('update_core', 'core'); job.actor = 'revoked'; f.commands.save(job); await f.worker.tick(); assert.equal(f.commands.list()[0].failure, 'release_actor_forbidden'); assert.deepEqual(f.events, []); diff --git a/core/core/updates/worker.js b/core/core/updates/worker.js index 7fb376c..f0a2e32 100644 --- a/core/core/updates/worker.js +++ b/core/core/updates/worker.js @@ -55,7 +55,11 @@ class UpdateWorker { if (job) { job.status = 'running'; this.commands.save(job); try { await this.execute(job); job.status = 'completed'; job.failure = null; } - catch (error) { job.status = 'failed'; job.failure = /^release_[a-z_]+$/.test(error.message) ? error.message : 'release_operation_failed'; } + catch (error) { + job.status = 'failed'; + const known = { directory_runtime_not_ready: 'release_runtime_not_ready', directory_backup_unsafe: 'release_backup_unsafe' }; + job.failure = /^release_[a-z_]+$/.test(error.message) ? error.message : Object.hasOwn(known, error.message) ? known[error.message] : 'release_operation_failed'; + } job.completedAt = this.clock(); this.commands.save(job); return; } const state = this.releases.state(); diff --git a/core/dashboard/frontend/src/pages/Updates.tsx b/core/dashboard/frontend/src/pages/Updates.tsx index a0760ab..8c0d82d 100644 --- a/core/dashboard/frontend/src/pages/Updates.tsx +++ b/core/dashboard/frontend/src/pages/Updates.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from "react"; import { useQuery } from "@tanstack/react-query"; -import { ArrowUpCircle, FlaskConical, Pause, Play, RefreshCw, Server } from "lucide-react"; +import { ArrowUpCircle, CheckCircle2, CircleAlert, LoaderCircle, Pause, Play, RefreshCw } from "lucide-react"; import { idempotent, queryClient, request } from "@/lib/api"; import { Button } from "@/components/ui/button"; import { ErrorNotice, Loading, Notice, PageHeading } from "@/components/shared"; @@ -28,10 +28,25 @@ const failureText: Record = { release_baseline_required: "The installed version must be registered before updates can begin.", release_fleet_changed: "The DSP list changed. Review it and start rollout again.", release_verification_failed: "The release could not be verified. Check the worker’s GitHub connection and retry.", + release_runtime_not_ready: "A DSP runtime did not pass its readiness check. Review its runtime status before retrying the update.", + release_backup_unsafe: "The DSP backup check found a file it could not safely copy. Resolve the backup issue before retrying.", }; +const actionNames: Record = { + refresh: "Release check", update_core: "Core update", update_dev: "Dev update", + rollout: "Rollout", pause: "Pause request", resume: "Resume request", recover: "Update recovery", +}; +const phases: Record = { + preparing: "Preparing the update", draining: "Stopping services and backing up private data", + starting: "Installing the release and checking service health", restoring: "Restoring the previous version", + failed: "Recovery is required before updates can continue", +}; +function Spinner() { + return ; +} export function Updates({ hash }: { hash: string }) { const [selected, setSelected] = useState(null); - const [sending, setSending] = useState(false); + const [pending, setPending] = useState<{ action: string; product: Product; id?: string } | null>(null); + const sending = pending !== null; const [error, setError] = useState(null); const query = useQuery({ queryKey: ["independent-updates", selected], queryFn: () => request(`/api/platform/updates${selected ? `?releaseId=${encodeURIComponent(selected)}` : ""}`), @@ -39,6 +54,9 @@ export function Updates({ hash }: { hash: string }) { const view = query.data; const initialCore = useRef(undefined); const coreDigest = view?.tracks?.core.installedDigest; + useEffect(() => { + if (pending?.id && view?.jobs?.some(job => job.id === pending.id)) setPending(null); + }, [pending, view?.jobs]); useEffect(() => { if (view?.mode !== "independent") return; const previous = initialCore.current; @@ -48,28 +66,50 @@ export function Updates({ hash }: { hash: string }) { if (view && view.mode !== "independent") return ; async function command(action: string, digest: string | null = null, product: Product = "core") { if (sending) return; - setSending(true); setError(null); + setPending({ action, product }); setError(null); try { - await idempotent(`updates:${action}:${product}:${digest}`, "/api/platform/updates", { action, product, digest }); + const updated = await idempotent(`updates:${action}:${product}:${digest}`, "/api/platform/updates", { action, product, digest }); + const job = updated.jobs.find(item => item.action === action && item.product === product); + setPending(job ? { action, product, id: job.id } : null); await queryClient.invalidateQueries({ queryKey: ["independent-updates"] }); - } catch (cause) { setError(cause); } - finally { setSending(false); } + } catch (cause) { setError(cause); setPending(null); } } const activeJob = view?.jobs.find(job => ["queued", "running"].includes(job.status)); - const recentFailure = view?.jobs[0]?.status === "failed" ? view.jobs[0].failure : null; + const lastJob = view?.jobs[0]; const updatingCore = activeJob?.action === "update_core" || view?.operation?.product === "core"; + const rolloutActive = view?.rollout?.status === "running"; + let status: { title: string; detail: string; tone: "working" | "success" | "attention" } | null = null; + if (pending) status = { title: pending.id ? "Request received" : `Sending ${actionNames[pending.action]?.toLowerCase() || "update request"}…`, + detail: pending.id ? "Waiting for the worker’s latest status. You can leave this page and return to check progress." : "Submitting your request. Please wait.", tone: "working" }; + else if (view?.operation) status = { title: view.operation.product === "core" ? "Updating Core" : `Updating ${view.operation.dspName || view.dev.name}`, + detail: `${phases[view.operation.phase] || "Applying the update"}.${rolloutActive ? ` ${view.rollout!.updated} of ${view.rollout!.total} DSPs updated.` : ""}`, + tone: ["failed", "restoring"].includes(view.operation.phase) ? "attention" : "working" }; + else if (activeJob) status = { title: `${actionNames[activeJob.action] || "Update"} ${activeJob.status === "queued" ? "queued" : "in progress"}`, + detail: activeJob.status === "queued" ? "Waiting for the update worker to start." : ["rollout", "update_dev", "update_core", "refresh"].includes(activeJob.action) + ? "Checking the release and preparing the update. This can take a few minutes. Progress updates automatically." + : "The worker is processing your request. Progress updates automatically.", tone: "working" }; + else if (lastJob?.status === "failed") status = { title: `${actionNames[lastJob.action] || "Update"} failed`, + detail: failureText[lastJob.failure || ""] || "The request could not finish. Review the current state, then retry or recover.", tone: "attention" }; + else if (view?.rollout && (view.rollout.status !== "completed" || !lastJob || ["rollout", "resume", "pause"].includes(lastJob.action))) status = { title: rolloutActive ? "Rolling out update" : view.rollout.status === "completed" ? "Rollout complete" : "Rollout paused", + detail: `${view.rollout.updated} of ${view.rollout.total} DSPs updated.${rolloutActive ? " DSPs update one at a time. You can leave this page and return to check progress." : view.rollout.status === "paused" ? " Review rollout progress below before resuming." : " All DSPs in this rollout are on the selected release."}`, + tone: rolloutActive ? "working" : view.rollout.status === "completed" ? "success" : "attention" }; + else if (lastJob?.status === "completed") status = { title: `${actionNames[lastJob.action] || "Update"} complete`, + detail: lastJob.action === "update_dev" ? "Dev passed installation checks. Test the changes in Dev before choosing Rollout Update." : "The worker finished your request.", tone: "success" }; + if (status?.tone === "working" && (query.isError || view && !view.worker.available)) status = { title: "Waiting for update status", + detail: updatingCore ? "Core is restarting. This page will reconnect automatically." : "The latest progress is temporarily unavailable. This page will keep checking; your update may still be running.", tone: "attention" }; return <> + onClick={() => void command("refresh")}>{(pending?.action || activeJob?.action) === "refresh" ? : - {updatingCore && Core is updating. This page will reconnect when it’s ready.} + {status &&
+ {status.tone === "working" ? : status.tone === "success" ? +

{status.title}

{status.detail}

+
} {query.isPending ? : view ?
{!view.enabled && Updates need initial setup. Your current services will continue running.} {view.enabled && !view.worker.available && The update worker is offline. Releases remain available to read.} - {recentFailure && {failureText[recentFailure] || "The update could not finish. Review the current state, then retry or recover."}} - {view.operation && !updatingCore && {view.operation.dspName || "Dev DSP"} is updating. Private data is being preserved.} {view.recoveryRequired && !activeJob &&

Recover the interrupted update before installing another release.

@@ -88,13 +128,18 @@ export function Updates({ hash }: { hash: string }) { const rolling = view.rollout && view.rollout.status !== "completed"; const action = trackName === "core" ? "update_core" : track.tested ? "rollout" : "update_dev"; const label = trackName === "core" ? "Update Core" : track.tested ? "Rollout Update" : "Update Dev"; + const working = pending?.product === trackName && pending.action !== "refresh" || activeJob?.product === trackName && activeJob.action !== "refresh" || view.operation?.product === trackName || trackName === "dsp" && rolloutActive; + const workingLabel = pending?.product === trackName ? pending.id ? "Request received…" : "Sending request…" + : activeJob?.status === "queued" && activeJob.product === trackName ? "Update queued…" + : activeJob?.action === "pause" ? "Pausing rollout…" : activeJob?.action === "resume" ? "Resuming rollout…" : activeJob?.action === "recover" ? "Recovering update…" + : trackName === "core" ? "Updating Core…" : rolloutActive ? "Rolling out…" : activeJob?.action === "rollout" ? "Preparing rollout…" : "Updating Dev…"; return

{trackName === "core" ? "Core" : "DSP"}

{trackName === "core" ? "Installed" : `Installed on ${view.dev.name}`}: {track.installedVersion || "Not registered"}{track.installedLegacy ? " (legacy release)" : ""}

- +

{trackName === "core" ? "Updates the Platform Owner dashboard, shared API and Core services." @@ -117,7 +162,7 @@ export function Updates({ hash }: { hash: string }) {

{view.rollout.updated} of {view.rollout.total} DSPs updated · {view.rollout.status}

{view.rollout.status === "paused" && The rollout is paused. Resolve the affected DSP before resuming this version.} -
    {view.rollout.members.map((member, index) =>
  • {member.name}{member.status}
  • )}
+
    {view.rollout.members.map((member, index) =>
  • {member.name}{member.status === "updating" && }{member.status === "updated" &&
  • )}
}
; })} diff --git a/core/dashboard/playwright.updates.config.cjs b/core/dashboard/playwright.updates.config.cjs index ce5258a..2d0a006 100644 --- a/core/dashboard/playwright.updates.config.cjs +++ b/core/dashboard/playwright.updates.config.cjs @@ -1,6 +1,6 @@ const { defineConfig } = require('@playwright/test'); module.exports = defineConfig({ - testDir: './tests/browser', testMatch: ['independent-updates.spec.cjs', 'updates-workspace.spec.cjs', 'dashboard-rollout.spec.cjs'], + testDir: './tests/browser', testMatch: ['independent-updates.spec.cjs', 'update-progress.spec.cjs', 'updates-workspace.spec.cjs', 'dashboard-rollout.spec.cjs'], workers: 1, timeout: 60000, outputDir: process.env.DISPATCH_UI_ARTIFACTS || '/tmp/dispatch-updates-browser', use: { viewport: { width: 1440, height: 1000 }, reducedMotion: 'reduce', diff --git a/core/dashboard/tests/browser/update-progress.spec.cjs b/core/dashboard/tests/browser/update-progress.spec.cjs new file mode 100644 index 0000000..a692618 --- /dev/null +++ b/core/dashboard/tests/browser/update-progress.spec.cjs @@ -0,0 +1,63 @@ +const {test,expect}=require('@playwright/test'); +const {createPreview}=require('../../examples/independent-updates-preview'); +let app; +test.beforeAll(async()=>{app=await createPreview({automatic:false});}); +test.afterAll(async()=>{await app?.close();}); +test('rollout feedback survives preparation, reload, progress, pause, failure and completion',async({page},info)=>{ + test.setTimeout(90000); + const errors=[];page.on('pageerror',e=>errors.push(e.message));page.on('console',e=>{if(e.type()==='error')errors.push(e.text());}); + await page.goto(`${app.url}/#/updates`); + await page.getByLabel('Email address').fill('platform@example.test'); + await page.getByLabel('Password',{exact:true}).fill('synthetic preview password'); + await page.getByRole('button',{name:'Sign in',exact:true}).click(); + await page.locator('.desktop-sidebar').getByRole('link',{name:'Updates',exact:true}).click(); + await expect(page.getByRole('heading',{name:'DSP',exact:true})).toBeVisible(); + const response=await page.request.get(`${app.url}/api/platform/updates`); + const envelope=await response.json(),view=envelope.data; + view.tracks.dsp.tested=true;view.tracks.dsp.canUpdate=true;view.jobs=[];view.rollout=null;view.operation=null; + let accept,postCount=0; + await page.route('**/api/platform/updates',async route=>{ + if(route.request().method()==='POST'){ + postCount++;await new Promise(resolve=>{accept=resolve;}); + await route.fulfill({json:{...envelope,data:view}}); + }else await route.fulfill({json:{...envelope,data:view}}); + }); + await page.reload(); + await expect(page.getByRole('button',{name:'Rollout Update',exact:true})).toBeEnabled(); + await page.getByRole('button',{name:'Rollout Update',exact:true}).click(); + const status=page.getByRole('status').filter({has:page.getByRole('heading')}); + await expect(status).toContainText('Sending rollout…'); + await expect(page.getByRole('button',{name:'Sending request…',exact:true})).toBeDisabled(); + view.jobs=[{id:'progress-job',action:'rollout',product:'dsp',status:'queued',failure:null}];view.busy=true;view.tracks.dsp.canUpdate=false; + await expect.poll(()=>Boolean(accept)).toBe(true);accept(); + await expect(status).toContainText('Rollout queued'); + view.jobs[0].status='running'; + await expect(status).toContainText('Rollout in progress',{timeout:10000}); + await expect(page.getByRole('button',{name:'Preparing rollout…',exact:true})).toBeDisabled(); + await expect(status.locator('.motion-safe\\:animate-spin')).toHaveCount(1); + await page.screenshot({path:info.outputPath('preparing-desktop.png'),fullPage:true}); + await page.reload();await expect(status).toContainText('Rollout in progress'); + view.worker.available=false; + await expect(status).toContainText('Waiting for update status',{timeout:10000}); + view.worker.available=true; + view.jobs[0].status='completed';view.busy=false; + view.rollout={version:'0.0.2',status:'running',failure:null,updated:0,total:2,members:[{name:'Northline Logistics',status:'updating'},{name:'Cedar Delivery',status:'queued'}]}; + view.operation={product:'dsp',phase:'starting',dspName:'Northline Logistics'}; + await expect(status).toContainText('Updating Northline Logistics',{timeout:10000}); + await expect(status).toContainText('Installing the release and checking service health'); + await expect(page.getByRole('progressbar',{name:'DSPs updated'})).toHaveAttribute('value','0'); + view.operation=null;view.rollout.updated=1;view.rollout.members[0].status='updated';view.rollout.status='paused'; + await expect(status).toContainText('Rollout paused',{timeout:10000}); + await expect(status).toContainText('1 of 2 DSPs updated'); + view.rollout=null;view.jobs[0].status='failed';view.jobs[0].failure='release_runtime_not_ready'; + await expect(status).toContainText('Rollout failed',{timeout:10000}); + await expect(status).toContainText('runtime did not pass'); + view.jobs[0].status='completed';view.jobs[0].failure=null; + view.rollout={version:'0.0.2',status:'completed',failure:null,updated:2,total:2,members:[{name:'Northline Logistics',status:'updated'},{name:'Cedar Delivery',status:'updated'}]}; + await expect(status).toContainText('Rollout complete',{timeout:10000}); + await expect(status).toContainText('2 of 2 DSPs updated'); + await page.setViewportSize({width:390,height:844}); + await expect.poll(()=>page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true); + await page.screenshot({path:info.outputPath('completed-mobile.png'),fullPage:true}); + await expect(page).toHaveTitle('Updates · Dispatch');expect(postCount).toBe(1);expect(errors).toEqual([]); +}); From 3c58a1703903a34b82a3e57028ac5c3dd4e62668 Mon Sep 17 00:00:00 2001 From: Dillon Lillehaug Date: Sun, 13 Sep 2026 14:19:34 +0000 Subject: [PATCH 2/2] Verify progress animation and reduced-motion behavior --- core/dashboard/tests/browser/update-progress.spec.cjs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/core/dashboard/tests/browser/update-progress.spec.cjs b/core/dashboard/tests/browser/update-progress.spec.cjs index a692618..869b658 100644 --- a/core/dashboard/tests/browser/update-progress.spec.cjs +++ b/core/dashboard/tests/browser/update-progress.spec.cjs @@ -34,7 +34,11 @@ test('rollout feedback survives preparation, reload, progress, pause, failure an view.jobs[0].status='running'; await expect(status).toContainText('Rollout in progress',{timeout:10000}); await expect(page.getByRole('button',{name:'Preparing rollout…',exact:true})).toBeDisabled(); - await expect(status.locator('.motion-safe\\:animate-spin')).toHaveCount(1); + const spinner=status.locator('.motion-safe\\:animate-spin'); + await page.emulateMedia({reducedMotion:'no-preference'}); + await expect.poll(()=>spinner.evaluate(node=>getComputedStyle(node).animationName)).not.toBe('none'); + await page.emulateMedia({reducedMotion:'reduce'}); + await expect.poll(()=>spinner.evaluate(node=>getComputedStyle(node).animationName)).toBe('none'); await page.screenshot({path:info.outputPath('preparing-desktop.png'),fullPage:true}); await page.reload();await expect(status).toContainText('Rollout in progress'); view.worker.available=false;