From 5b13a01ab626c793beccd98b9e7c76185504bd17 Mon Sep 17 00:00:00 2001 From: anti Date: Fri, 22 May 2026 17:50:26 -0400 Subject: [PATCH] feat(web): deploy wizard polls async lifecycle instead of holding HTTP - New useLifecyclePolling(ids, intervalMs) hook: polls GET /deckies/lifecycle?ids=... every 2s until every row is terminal, surfaces transient HTTP failures without giving up. - DeployWizard: drops the 180s axios timeout and the fake-log-driven deployOk flag. After POST 202, sets lifecycle_ids -> the hook drives the per-decky pill grid (PENDING / RUNNING / SUCCEEDED / FAILED). Real terminal lines stream into the log as rows resolve. Auto-close on all-success after 700ms. - DeckyFleet.css: .lifecycle-grid + .lifecycle-pill in the existing fleet vocabulary; running pill pulses, failed pill borders alert. - Existing 4 wizard render tests still pass; 4 new hook tests cover empty ids / single-success / polling-until-terminal / HTTP error. --- decnet_web/src/components/DeckyFleet.css | 45 +++++++ .../components/DeckyFleet/DeployWizard.tsx | 118 +++++++++++++----- .../src/hooks/useLifecyclePolling.test.ts | 71 +++++++++++ decnet_web/src/hooks/useLifecyclePolling.ts | 84 +++++++++++++ 4 files changed, 289 insertions(+), 29 deletions(-) create mode 100644 decnet_web/src/hooks/useLifecyclePolling.test.ts create mode 100644 decnet_web/src/hooks/useLifecyclePolling.ts diff --git a/decnet_web/src/components/DeckyFleet.css b/decnet_web/src/components/DeckyFleet.css index 89f688e9..912b6d6e 100644 --- a/decnet_web/src/components/DeckyFleet.css +++ b/decnet_web/src/components/DeckyFleet.css @@ -462,6 +462,51 @@ } .fleet-empty .dim { opacity: 0.5; } +/* Per-decky lifecycle pills (deploy wizard, /deckies/{name}/mutate) */ +.lifecycle-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 6px; + margin-bottom: 10px; +} +.lifecycle-pill { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + padding: 6px 10px; + font-size: 0.7rem; + letter-spacing: 1px; + border: 1px solid var(--border); + background: var(--panel); +} +.lifecycle-pill .lifecycle-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; +} +.lifecycle-pill .lifecycle-status { + font-weight: 600; + font-size: 0.62rem; +} +.lifecycle-pill.lifecycle-pending { + opacity: 0.6; +} +.lifecycle-pill.lifecycle-running { + border-color: var(--matrix); + color: var(--matrix); + animation: dfleet-pulse 1.2s ease-in-out infinite alternate; +} +.lifecycle-pill.lifecycle-succeeded { + border-color: var(--matrix); + color: var(--matrix); +} +.lifecycle-pill.lifecycle-failed { + border-color: var(--alert); + color: var(--alert); +} + /* Animations */ @keyframes dfleet-pulse { from { opacity: 0.5; } to { opacity: 1; } } @keyframes dfleet-blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } } diff --git a/decnet_web/src/components/DeckyFleet/DeployWizard.tsx b/decnet_web/src/components/DeckyFleet/DeployWizard.tsx index d722ed50..6ec8ded6 100644 --- a/decnet_web/src/components/DeckyFleet/DeployWizard.tsx +++ b/decnet_web/src/components/DeckyFleet/DeployWizard.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useMemo, useState } from 'react'; import { PlusCircle } from '../../icons'; +import { useLifecyclePolling } from '../../hooks/useLifecyclePolling'; import api from '../../utils/api'; import Modal from '../Modal/Modal'; import { DEFAULT_SERVICES } from '../MazeNET/data'; @@ -129,6 +130,8 @@ export const DeployWizard: React.FC = ({ setServiceConfigs({}); setServiceSchemas({}); setOpenSvcCfg(null); + setLifecycleIds([]); + setLoggedTerminals(new Set()); }, [open]); const effectiveArchetypeName = archetype?.name @@ -163,28 +166,45 @@ export const DeployWizard: React.FC = ({ return out; }, [count, prefix, fleetSize, effectiveArchetypeName, effectiveServices]); - const [deployOk, setDeployOk] = useState(false); - const [deployFailures, setDeployFailures] = useState([]); + // 202 returns the per-decky lifecycle row ids; the polling hook flips + // them through pending -> running -> succeeded|failed. Empty array + // disables the hook (idle / not yet POSTed). + const [lifecycleIds, setLifecycleIds] = useState([]); + const { rows: lifecycleRows, done: lifecycleDone, error: lifecycleErr } = + useLifecyclePolling(lifecycleIds); - // Fake log stream during "deploying" (runs as visual backdrop; real API - // lines are spliced in by startDeploy once the HTTP call resolves). + // Atmospheric backdrop (one-shot, decoupled from real progress now + // that the lifecycle rows carry truth). Runs once when DEPLOYING + // begins so the operator sees activity before the first poll lands. useEffect(() => { - if (step !== 3 || !deploying) return; + if (step !== 3 || !deploying || lifecycleIds.length === 0) return; const msgs = PLACEHOLDER_LINES(effectiveArchetypeName, effectiveServices, count, fleetSize); let i = 0; const t = window.setInterval(() => { setLog((prev) => [...prev, msgs[i]]); i++; - if (i >= msgs.length) { - window.clearInterval(t); - // Only auto-close if the server accepted. - if (deployOk) { - window.setTimeout(() => onComplete(count), 500); - } - } + if (i >= msgs.length) window.clearInterval(t); }, 420); return () => window.clearInterval(t); - }, [step, deploying, effectiveArchetypeName, effectiveServices, count, fleetSize, onComplete, deployOk]); + }, [step, deploying, lifecycleIds.length, effectiveArchetypeName, effectiveServices, count, fleetSize]); + + const deployFailures = useMemo(() => + lifecycleRows + .filter((r) => r.status === 'failed') + .map((r) => `[FAIL] ${r.decky_name}: ${r.error ?? 'unknown error'}`), + [lifecycleRows], + ); + const deployOk = lifecycleDone && deployFailures.length === 0; + + // When every row reaches terminal status, auto-close on full success + // (or stay open so the operator can read failures). + useEffect(() => { + if (!lifecycleDone) return; + if (deployFailures.length === 0) { + const t = window.setTimeout(() => onComplete(count), 700); + return () => window.clearTimeout(t); + } + }, [lifecycleDone, deployFailures.length, count, onComplete]); const canNext = step === 0 ? (pickMode === 'archetype' ? !!archetype : selectedServices.length > 0) @@ -193,8 +213,7 @@ export const DeployWizard: React.FC = ({ const startDeploy = async () => { setDeployErr(null); setLog([]); - setDeployOk(false); - setDeployFailures([]); + setLifecycleIds([]); setDeploying(true); // Roll the per-service forms into the compact payload the server // expects — empty values dropped, types coerced where the schema @@ -215,20 +234,13 @@ export const DeployWizard: React.FC = ({ mutate, mutateEvery, rolled, serviceSchemas, ); try { - const res = await api.post<{ failures?: { name: string; reason: string }[] }>( + const res = await api.post<{ lifecycle_ids?: string[]; message?: string; mode?: string }>( '/deckies/deploy', { ini_content: ini }, - { timeout: 180000 }, ); - const failures = res.data?.failures ?? []; - setDeployFailures(failures.map(f => `[FAIL] ${f.name}: ${f.reason}`)); - if (failures.length > 0) { - setLog(prev => [...prev, `[OK] server accepted ${count - failures.length}/${count}`, - ...failures.map(f => `[FAIL] ${f.name}: ${f.reason}`)]); - } else { - setLog(prev => [...prev, `[OK] server accepted ${count} deckies`]); - } - setDeployOk(true); + const ids = res.data?.lifecycle_ids ?? []; + setLifecycleIds(ids); + setLog((prev) => [...prev, `[ACK] server accepted ${ids.length} decky/ies — tracking...`]); } catch (e: unknown) { const err = e as { response?: { data?: { detail?: string } }; message?: string }; setDeployErr(err?.response?.data?.detail || err?.message || 'Deploy failed'); @@ -236,6 +248,33 @@ export const DeployWizard: React.FC = ({ } }; + // Append lifecycle terminal lines to the log as rows resolve, so the + // operator gets a running transcript instead of a flicker-replaced + // table. De-dupe by id so re-polls don't double-log. + const [, setLoggedTerminals] = useState>(new Set()); + useEffect(() => { + setLoggedTerminals((prev) => { + let next = prev; + const additions: string[] = []; + for (const r of lifecycleRows) { + if (prev.has(r.id)) continue; + if (r.status === 'succeeded') { + additions.push(`[OK] ${r.decky_name} deployed`); + } else if (r.status === 'failed') { + additions.push(`[FAIL] ${r.decky_name}: ${r.error ?? 'unknown error'}`); + } else { + continue; + } + if (next === prev) next = new Set(prev); + next.add(r.id); + } + if (additions.length > 0) { + setLog((l) => [...l, ...additions]); + } + return next; + }); + }, [lifecycleRows]); + const toggleService = (slug: string) => { setSelectedServices((prev) => prev.includes(slug) ? prev.filter((s) => s !== slug) : [...prev, slug]); @@ -270,10 +309,10 @@ export const DeployWizard: React.FC = ({ {step === 3 && !deploying && ( )} - {step === 3 && deploying && !deployOk && ( + {step === 3 && deploying && !lifecycleDone && ( )} - {step === 3 && deployOk && deployFailures.length > 0 && ( + {step === 3 && lifecycleDone && deployFailures.length > 0 && ( )} @@ -485,8 +524,29 @@ export const DeployWizard: React.FC = ({
{!deploying ? 'Ready to deploy. This will write to the fleet and start the listener.' - : 'Deploying...'} + : lifecycleDone + ? (deployFailures.length === 0 ? 'Deployed.' : 'Deploy finished with errors.') + : 'Deploying — polling lifecycle...'}
+ {lifecycleIds.length > 0 && ( +
+ {lifecycleRows.map((r) => ( +
+ {r.decky_name} + {r.status.toUpperCase()} +
+ ))} +
+ )} + {lifecycleErr && ( +
+ Polling: {lifecycleErr} — retrying... +
+ )}
{log.length === 0 && !deploying && ( <> diff --git a/decnet_web/src/hooks/useLifecyclePolling.test.ts b/decnet_web/src/hooks/useLifecyclePolling.test.ts new file mode 100644 index 00000000..e3dfb600 --- /dev/null +++ b/decnet_web/src/hooks/useLifecyclePolling.test.ts @@ -0,0 +1,71 @@ +/** + * @vitest-environment jsdom + */ +import { describe, it, expect } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { http, HttpResponse, server, apiUrl } from '../test/server'; +import { useLifecyclePolling, type LifecycleRow } from './useLifecyclePolling'; + +const row = (over: Partial): LifecycleRow => ({ + id: 'lid-1', + decky_name: 'decky-01', + host_uuid: null, + operation: 'deploy', + status: 'pending', + error: null, + started_at: '2026-05-22T00:00:00Z', + updated_at: '2026-05-22T00:00:00Z', + completed_at: null, + ...over, +}); + +describe('useLifecyclePolling', () => { + it('returns no rows and done=false for empty ids', () => { + const { result } = renderHook(() => useLifecyclePolling([])); + expect(result.current.rows).toEqual([]); + expect(result.current.done).toBe(false); + }); + + it('fetches once and marks done when all rows are terminal', async () => { + server.use( + http.get(apiUrl('/deckies/lifecycle'), () => + HttpResponse.json({ + rows: [row({ id: 'lid-1', status: 'succeeded', completed_at: 'ts' })], + }), + ), + ); + const { result } = renderHook(() => useLifecyclePolling(['lid-1'], 20)); + await waitFor(() => expect(result.current.done).toBe(true)); + expect(result.current.rows).toHaveLength(1); + expect(result.current.rows[0].status).toBe('succeeded'); + expect(result.current.error).toBeNull(); + }); + + it('keeps polling while at least one row is non-terminal', async () => { + let hits = 0; + server.use( + http.get(apiUrl('/deckies/lifecycle'), () => { + hits++; + return HttpResponse.json({ + rows: hits < 2 + ? [row({ id: 'lid-1', status: 'running' })] + : [row({ id: 'lid-1', status: 'succeeded', completed_at: 'ts' })], + }); + }), + ); + const { result } = renderHook(() => useLifecyclePolling(['lid-1'], 20)); + await waitFor(() => expect(result.current.done).toBe(true)); + expect(hits).toBeGreaterThanOrEqual(2); + }); + + it('surfaces error and keeps retrying on HTTP failure', async () => { + server.use( + http.get(apiUrl('/deckies/lifecycle'), () => + HttpResponse.json({ detail: 'server error' }, { status: 500 }), + ), + ); + const { result } = renderHook(() => useLifecyclePolling(['lid-1'], 20)); + await waitFor(() => expect(result.current.error).not.toBeNull()); + expect(result.current.done).toBe(false); + }); +}); diff --git a/decnet_web/src/hooks/useLifecyclePolling.ts b/decnet_web/src/hooks/useLifecyclePolling.ts new file mode 100644 index 00000000..999858eb --- /dev/null +++ b/decnet_web/src/hooks/useLifecyclePolling.ts @@ -0,0 +1,84 @@ +import { useEffect, useRef, useState } from 'react'; +import api from '../utils/api'; + +export type LifecycleStatus = 'pending' | 'running' | 'succeeded' | 'failed'; + +export interface LifecycleRow { + id: string; + decky_name: string; + host_uuid: string | null; + operation: 'deploy' | 'mutate'; + status: LifecycleStatus; + error: string | null; + started_at: string; + updated_at: string; + completed_at: string | null; +} + +const TERMINAL = new Set(['succeeded', 'failed']); + +/** + * Poll ``GET /deckies/lifecycle?ids=…`` every ``intervalMs`` until every + * row reaches a terminal status. Returns the latest rows, a derived + * ``done`` flag, and any HTTP failure that surfaced on the last tick. + * + * Polling stops automatically once all rows are terminal (or when + * ``ids`` becomes empty / unmounts). Pass an empty array to disable. + */ +export function useLifecyclePolling( + ids: string[], + intervalMs: number = 2000, +): { rows: LifecycleRow[]; done: boolean; error: string | null } { + const [rows, setRows] = useState([]); + const [error, setError] = useState(null); + const cancelled = useRef(false); + + useEffect(() => { + cancelled.current = false; + if (ids.length === 0) { + setRows([]); + return () => { cancelled.current = true; }; + } + + let timer: number | undefined; + + const tick = async () => { + try { + const { data } = await api.get<{ rows: LifecycleRow[] }>( + '/deckies/lifecycle', + // axios encodes array params as repeated ?ids=… when + // paramsSerializer isn't overridden — matches FastAPI's expected + // shape for List[str] Query params. + { params: { ids }, paramsSerializer: { indexes: null } }, + ); + if (cancelled.current) return; + const next = data?.rows ?? []; + setRows(next); + setError(null); + const allDone = next.length === ids.length + && next.every((r) => TERMINAL.has(r.status)); + if (!allDone) { + timer = window.setTimeout(tick, intervalMs); + } + } catch (e: unknown) { + if (cancelled.current) return; + const err = e as { message?: string }; + setError(err?.message || 'Lifecycle poll failed'); + timer = window.setTimeout(tick, intervalMs); + } + }; + + tick(); + return () => { + cancelled.current = true; + if (timer !== undefined) window.clearTimeout(timer); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ids.join('|'), intervalMs]); + + const done = ids.length > 0 + && rows.length === ids.length + && rows.every((r) => TERMINAL.has(r.status)); + + return { rows, done, error }; +}