From 780d395a46faebb35f82bba6f3b4b8b29d87bd22 Mon Sep 17 00:00:00 2001 From: anti Date: Sat, 9 May 2026 06:07:38 -0400 Subject: [PATCH] refactor(decnet_web/SwarmHosts): add useSwarmHosts polled data hook --- .../SwarmHosts/useSwarmHosts.test.ts | 123 ++++++++++++++++++ .../components/SwarmHosts/useSwarmHosts.ts | 93 +++++++++++++ 2 files changed, 216 insertions(+) create mode 100644 decnet_web/src/components/SwarmHosts/useSwarmHosts.test.ts create mode 100644 decnet_web/src/components/SwarmHosts/useSwarmHosts.ts diff --git a/decnet_web/src/components/SwarmHosts/useSwarmHosts.test.ts b/decnet_web/src/components/SwarmHosts/useSwarmHosts.test.ts new file mode 100644 index 00000000..87aada84 --- /dev/null +++ b/decnet_web/src/components/SwarmHosts/useSwarmHosts.test.ts @@ -0,0 +1,123 @@ +/** + * @vitest-environment jsdom + */ +import { describe, it, expect, vi } from 'vitest'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { http, HttpResponse, server, apiUrl } from '../../test/server'; +import { useSwarmHosts } from './useSwarmHosts'; +import type { SwarmHost } from './types'; + +const host = (over: Partial = {}): SwarmHost => ({ + uuid: 'h-1', + name: 'agent-1', + address: '10.0.0.10', + agent_port: 8443, + status: 'active', + last_heartbeat: '2026-05-01T00:00:00Z', + client_cert_fingerprint: 'a'.repeat(64), + updater_cert_fingerprint: null, + enrolled_at: '2026-05-01T00:00:00Z', + notes: null, + ...over, +}); + +describe('useSwarmHosts', () => { + it('loads /swarm/hosts on mount', async () => { + server.use( + http.get(apiUrl('/swarm/hosts'), () => HttpResponse.json([host()])), + ); + const { result } = renderHook(() => useSwarmHosts()); + expect(result.current.loading).toBe(true); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.hosts).toHaveLength(1); + }); + + it('surfaces error on load failure', async () => { + server.use( + http.get(apiUrl('/swarm/hosts'), () => + HttpResponse.json({ detail: 'forbidden' }, { status: 403 }), + ), + ); + const { result } = renderHook(() => useSwarmHosts()); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.error).toBe('forbidden'); + }); + + it('teardownHost reports ok and reloads', async () => { + let calls = 0; + server.use( + http.get(apiUrl('/swarm/hosts'), () => { calls += 1; return HttpResponse.json([host()]); }), + http.post(apiUrl('/swarm/hosts/h-1/teardown'), () => + new HttpResponse(null, { status: 202 }), + ), + ); + const { result } = renderHook(() => useSwarmHosts()); + await waitFor(() => expect(result.current.loading).toBe(false)); + const before = calls; + + let r: Awaited> | undefined; + await act(async () => { r = await result.current.teardownHost('h-1'); }); + expect(r).toEqual({ ok: true }); + expect(calls).toBeGreaterThan(before); + }); + + it('decommissionHost surfaces server reason on failure', async () => { + server.use( + http.get(apiUrl('/swarm/hosts'), () => HttpResponse.json([host()])), + http.delete(apiUrl('/swarm/hosts/h-1'), () => + HttpResponse.json({ detail: 'cannot remove last host' }, { status: 400 }), + ), + ); + const { result } = renderHook(() => useSwarmHosts()); + await waitFor(() => expect(result.current.loading).toBe(false)); + + let r: Awaited> | undefined; + await act(async () => { r = await result.current.decommissionHost('h-1'); }); + expect(r).toEqual({ ok: false, reason: 'cannot remove last host' }); + }); + + it('generateBundle returns the bundle on success', async () => { + server.use( + http.get(apiUrl('/swarm/hosts'), () => HttpResponse.json([])), + http.post(apiUrl('/swarm/enroll-bundle'), () => + HttpResponse.json({ + token: 'abc', host_uuid: 'h-9', + command: 'curl … | bash', expires_at: '2026-05-09T08:05:00Z', + }), + ), + ); + const { result } = renderHook(() => useSwarmHosts()); + await waitFor(() => expect(result.current.loading).toBe(false)); + + let r: Awaited> | undefined; + await act(async () => { + r = await result.current.generateBundle({ + master_host: 'master.local', + agent_name: 'a-1', + with_updater: true, + use_ipvlan: false, + services_ini: null, + }); + }); + expect(r?.ok).toBe(true); + expect(r?.data?.host_uuid).toBe('h-9'); + }); + + it('polls /swarm/hosts every 10s', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + try { + let calls = 0; + server.use( + http.get(apiUrl('/swarm/hosts'), () => { calls += 1; return HttpResponse.json([]); }), + ); + const { result } = renderHook(() => useSwarmHosts()); + await waitFor(() => expect(result.current.loading).toBe(false)); + const initial = calls; + + await act(async () => { vi.advanceTimersByTime(10_500); }); + await waitFor(() => expect(calls).toBeGreaterThan(initial)); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/decnet_web/src/components/SwarmHosts/useSwarmHosts.ts b/decnet_web/src/components/SwarmHosts/useSwarmHosts.ts new file mode 100644 index 00000000..2fdf993a --- /dev/null +++ b/decnet_web/src/components/SwarmHosts/useSwarmHosts.ts @@ -0,0 +1,93 @@ +import { useCallback, useEffect, useState } from 'react'; +import api from '../../utils/api'; +import { extractErrorDetail } from './helpers'; +import type { BundleRequest, BundleResult, SwarmHost } from './types'; + +export interface MutationResult { + ok: boolean; + reason?: string; + data?: T; +} + +export interface UseSwarmHosts { + hosts: SwarmHost[]; + loading: boolean; + error: string | null; + reload: () => Promise; + teardownHost: (uuid: string) => Promise; + decommissionHost: (uuid: string) => Promise; + generateBundle: (req: BundleRequest) => Promise>; +} + +const POLL_INTERVAL_MS = 10_000; + +/** Owns the swarm-host list with a 10s heartbeat poll plus the + * teardown / decommission / enroll-bundle round-trips. UI concerns + * (arm-then-confirm, busy spinners, modal toggling) stay in the page; + * the hook returns `{ ok, reason }` so callers can decide whether to + * toast, alert, or set local error UI. */ +export function useSwarmHosts(): UseSwarmHosts { + const [hosts, setHosts] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const reload = useCallback(async () => { + try { + const res = await api.get('/swarm/hosts'); + setHosts(res.data); + setError(null); + } catch (err) { + setError(extractErrorDetail(err, 'Failed to fetch swarm hosts')); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void reload(); + const t = setInterval(reload, POLL_INTERVAL_MS); + return () => clearInterval(t); + }, [reload]); + + const teardownHost = useCallback( + async (uuid: string): Promise => { + try { + // 202 Accepted — teardown runs async on the backend. + await api.post(`/swarm/hosts/${uuid}/teardown`, {}); + await reload(); + return { ok: true }; + } catch (err) { + return { ok: false, reason: extractErrorDetail(err, 'Teardown failed') }; + } + }, + [reload], + ); + + const decommissionHost = useCallback( + async (uuid: string): Promise => { + try { + await api.delete(`/swarm/hosts/${uuid}`); + await reload(); + return { ok: true }; + } catch (err) { + return { ok: false, reason: extractErrorDetail(err, 'Decommission failed') }; + } + }, + [reload], + ); + + const generateBundle = useCallback( + async (req: BundleRequest): Promise> => { + try { + const res = await api.post('/swarm/enroll-bundle', req); + await reload(); + return { ok: true, data: res.data }; + } catch (err) { + return { ok: false, reason: extractErrorDetail(err, 'Enrollment bundle creation failed') }; + } + }, + [reload], + ); + + return { hosts, loading, error, reload, teardownHost, decommissionHost, generateBundle }; +}