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.
This commit is contained in:
2026-05-22 17:50:26 -04:00
parent eacac9aa60
commit 5b13a01ab6
4 changed files with 289 additions and 29 deletions

View File

@@ -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>): 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);
});
});

View File

@@ -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<LifecycleStatus>(['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<LifecycleRow[]>([]);
const [error, setError] = useState<string | null>(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 };
}