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:
71
decnet_web/src/hooks/useLifecyclePolling.test.ts
Normal file
71
decnet_web/src/hooks/useLifecyclePolling.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
84
decnet_web/src/hooks/useLifecyclePolling.ts
Normal file
84
decnet_web/src/hooks/useLifecyclePolling.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user