diff --git a/decnet_web/src/components/DeckyFleet/useDeckyFleet.test.ts b/decnet_web/src/components/DeckyFleet/useDeckyFleet.test.ts new file mode 100644 index 00000000..4828dcb1 --- /dev/null +++ b/decnet_web/src/components/DeckyFleet/useDeckyFleet.test.ts @@ -0,0 +1,171 @@ +/** + * @vitest-environment jsdom + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { http, HttpResponse, server, apiUrl } from '../../test/server'; +import { makeDecky } from '../../test/fixtures'; + +beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +import { useDeckyFleet } from './useDeckyFleet'; + +const stockHandlers = (mode: 'unihost' | 'swarm' = 'unihost') => [ + http.get(apiUrl('/system/deployment-mode'), () => + HttpResponse.json({ mode, swarm_host_count: mode === 'swarm' ? 2 : 0 }), + ), + http.get(apiUrl('/config'), () => HttpResponse.json({ role: 'admin' })), + http.get(apiUrl('/topologies/archetypes'), () => + HttpResponse.json({ + archetypes: [ + { slug: 'web-server', display_name: 'Web Server', services: ['http'] }, + ], + }), + ), + http.get(apiUrl('/deckies'), () => HttpResponse.json([makeDecky({ name: 'd1' })])), + http.get(apiUrl('/swarm/deckies'), () => HttpResponse.json([])), +]; + +describe('useDeckyFleet', () => { + it('loads deckies + role + deploy-mode + archetypes on mount', async () => { + server.use(...stockHandlers()); + + const { result } = renderHook(() => useDeckyFleet()); + + expect(result.current.loading).toBe(true); + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.isAdmin).toBe(true); + expect(result.current.deckies).toHaveLength(1); + expect(result.current.deckies[0].name).toBe('d1'); + expect(result.current.deployMode?.mode).toBe('unihost'); + expect(result.current.isSwarm).toBe(false); + expect(result.current.archetypes[0]?.slug).toBe('web-server'); + }); + + it('switches to /swarm/deckies + normalizes the swarm shape when mode=swarm', async () => { + server.use( + ...stockHandlers('swarm').filter( + (h) => typeof h.info.path !== 'string' || h.info.path !== apiUrl('/swarm/deckies'), + ), + http.get(apiUrl('/swarm/deckies'), () => + HttpResponse.json([ + { + decky_name: 'sd1', + decky_ip: '10.1.1.1', + host_uuid: 'h-1', + host_name: 'edge-1', + host_address: 'edge-1.example', + host_status: 'ok', + services: ['ssh'], + state: 'running', + last_error: null, + last_seen: null, + hostname: 'sd1.local', + distro: 'debian-12', + archetype: 'workstation', + service_config: {}, + mutate_interval: null, + last_mutated: 0, + }, + ]), + ), + ); + + const { result } = renderHook(() => useDeckyFleet()); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.isSwarm).toBe(true); + expect(result.current.deckies).toHaveLength(1); + expect(result.current.deckies[0].name).toBe('sd1'); + expect(result.current.deckies[0].swarm?.host_uuid).toBe('h-1'); + }); + + it('mutate(name) resolves ok and triggers a refetch', async () => { + let listCalls = 0; + server.use( + ...stockHandlers().filter( + (h) => typeof h.info.path !== 'string' || h.info.path !== apiUrl('/deckies'), + ), + http.get(apiUrl('/deckies'), () => { + listCalls += 1; + return HttpResponse.json([makeDecky({ name: 'd1' })]); + }), + http.post(apiUrl('/deckies/d1/mutate'), () => HttpResponse.json({})), + ); + + const { result } = renderHook(() => useDeckyFleet()); + await waitFor(() => expect(result.current.loading).toBe(false)); + const initialListCalls = listCalls; + + let mutateResult: Awaited> | undefined; + await act(async () => { + mutateResult = await result.current.mutate('d1'); + }); + expect(mutateResult).toEqual({ ok: true }); + // mutate triggers a refetch; list endpoint should have hit again + expect(listCalls).toBeGreaterThan(initialListCalls); + }); + + it('mutate returns { ok:false, reason:"error" } on a 500', async () => { + server.use( + ...stockHandlers(), + http.post(apiUrl('/deckies/d1/mutate'), () => + HttpResponse.json({ detail: 'boom' }, { status: 500 }), + ), + ); + + const { result } = renderHook(() => useDeckyFleet()); + await waitFor(() => expect(result.current.loading).toBe(false)); + + let mutateResult: Awaited> | undefined; + await act(async () => { + mutateResult = await result.current.mutate('d1'); + }); + expect(mutateResult).toEqual({ ok: false, reason: 'error' }); + }); + + it('teardown returns { ok:true } on success and refetches', async () => { + server.use( + ...stockHandlers('swarm'), + http.get(apiUrl('/swarm/deckies'), () => HttpResponse.json([])), + http.post(apiUrl('/swarm/hosts/h-1/teardown'), () => HttpResponse.json({})), + ); + + const { result } = renderHook(() => useDeckyFleet()); + await waitFor(() => expect(result.current.loading).toBe(false)); + + const swarmDecky = makeDecky({ + name: 'd-td', + swarm: { + host_uuid: 'h-1', + host_name: 'edge-1', + host_address: 'edge-1.example', + host_status: 'ok', + state: 'running', + last_error: null, + last_seen: null, + }, + }); + let tdResult: Awaited> | undefined; + await act(async () => { + tdResult = await result.current.teardown(swarmDecky); + }); + expect(tdResult).toEqual({ ok: true }); + }); + + it('applyServicesChange optimistically rewrites the matching row', async () => { + server.use(...stockHandlers()); + + const { result } = renderHook(() => useDeckyFleet()); + await waitFor(() => expect(result.current.loading).toBe(false)); + + act(() => result.current.applyServicesChange('d1', ['ssh', 'http'])); + expect(result.current.deckies[0].services).toEqual(['ssh', 'http']); + }); +}); diff --git a/decnet_web/src/components/DeckyFleet/useDeckyFleet.ts b/decnet_web/src/components/DeckyFleet/useDeckyFleet.ts new file mode 100644 index 00000000..44d5d63d --- /dev/null +++ b/decnet_web/src/components/DeckyFleet/useDeckyFleet.ts @@ -0,0 +1,234 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import api, { type ApiError } from '../../utils/api'; +import { ARCHETYPES as FALLBACK_ARCHETYPES } from '../MazeNET/data'; +import { archetypeIcon } from './helpers'; +import type { Archetype, Decky, SwarmDeckyRaw } from './types'; + +export interface DeployMode { + mode: string; + swarm_host_count: number; +} + +export type MutateResult = + | { ok: true } + | { ok: false; reason: 'timeout' | 'error' }; + +export type TeardownResult = + | { ok: true } + | { ok: false; reason: string }; + +export interface UseDeckyFleetResult { + deckies: Decky[]; + loading: boolean; + isAdmin: boolean; + deployMode: DeployMode | null; + archetypes: Archetype[]; + isSwarm: boolean; + + /** Name of the decky currently mid-mutate, or null when idle. */ + mutating: string | null; + /** Set of decky names currently mid-teardown. */ + tearingDown: Set; + + /** Re-fetch the decky list under the current deploy mode. */ + refresh: () => Promise; + /** Force-mutate one decky. Resolves to a discriminated result so + * the caller can branch toast tone without seeing axios errors. */ + mutate: (name: string) => Promise; + /** Update or clear a decky's periodic mutate interval. */ + setMutateInterval: (name: string, minutes: number | null) => Promise; + /** Tear down a swarm-pinned decky on its host. */ + teardown: (d: Decky) => Promise; + /** Optimistically apply a server-returned services list to a card + * (used by DeckyCard's add/remove-service flow). */ + applyServicesChange: (name: string, services: string[]) => void; +} + +const POLL_MS = 10_000; + +/** Owns every read- and write-side data flow for the DeckyFleet + * page: the mode-switched fleet fetch, role lookup, archetype + * catalog, mutate / interval / teardown POSTs, and the 10s polling + * loop. UI concerns (toasts, arm-confirm, modal visibility) stay + * in the consuming page. */ +export function useDeckyFleet(): UseDeckyFleetResult { + const [deckies, setDeckies] = useState([]); + const [loading, setLoading] = useState(true); + const [isAdmin, setIsAdmin] = useState(false); + const [deployMode, setDeployMode] = useState(null); + const [archetypes, setArchetypes] = useState(FALLBACK_ARCHETYPES); + const [mutating, setMutating] = useState(null); + const [tearingDown, setTearingDown] = useState>(new Set()); + + const fetchDeckies = useCallback(async (mode?: string) => { + try { + if (mode === 'swarm') { + const res = await api.get('/swarm/deckies'); + const normalized: Decky[] = res.data.map((s) => ({ + name: s.decky_name, + ip: s.decky_ip || '—', + services: s.services || [], + distro: s.distro || 'unknown', + hostname: s.hostname || '—', + archetype: s.archetype, + service_config: s.service_config || {}, + mutate_interval: s.mutate_interval, + last_mutated: s.last_mutated || 0, + swarm: { + host_uuid: s.host_uuid, + host_name: s.host_name, + host_address: s.host_address, + host_status: s.host_status, + state: s.state, + last_error: s.last_error, + last_seen: s.last_seen, + }, + })); + setDeckies(normalized); + } else { + const res = await api.get('/deckies'); + setDeckies(res.data); + } + } catch (err) { + console.error('Failed to fetch decky fleet', err); + } finally { + setLoading(false); + } + }, []); + + const fetchRole = useCallback(async () => { + try { + const res = await api.get('/config'); + setIsAdmin(res.data.role === 'admin'); + } catch { + setIsAdmin(false); + } + }, []); + + const fetchDeployMode = useCallback(async (): Promise => { + try { + const res = await api.get('/system/deployment-mode'); + setDeployMode({ mode: res.data.mode, swarm_host_count: res.data.swarm_host_count }); + return res.data.mode as string; + } catch { + setDeployMode(null); + return undefined; + } + }, []); + + const fetchArchetypes = useCallback(async () => { + try { + const res = await api.get<{ archetypes: { slug: string; display_name: string; services: string[] }[] }>( + '/topologies/archetypes', + ); + const list: Archetype[] = res.data.archetypes.map((a) => ({ + slug: a.slug, + name: a.display_name, + services: a.services, + icon: archetypeIcon(a.slug), + })); + if (list.length) setArchetypes(list); + } catch { + // fall back to bundled list + } + }, []); + + const refresh = useCallback(async () => { + await fetchDeckies(deployMode?.mode); + }, [fetchDeckies, deployMode]); + + const mutate = useCallback(async (name: string): Promise => { + setMutating(name); + try { + await api.post(`/deckies/${name}/mutate`, {}, { timeout: 120000 }); + await fetchDeckies(deployMode?.mode); + return { ok: true }; + } catch (err: unknown) { + console.error('Failed to mutate', err); + const e = err as { code?: string }; + return { + ok: false, + reason: e.code === 'ECONNABORTED' ? 'timeout' : 'error', + }; + } finally { + setMutating(null); + } + }, [fetchDeckies, deployMode]); + + const setMutateInterval = useCallback( + async (name: string, minutes: number | null): Promise => { + try { + await api.put(`/deckies/${name}/mutate-interval`, { mutate_interval: minutes }); + await fetchDeckies(deployMode?.mode); + return true; + } catch (err) { + console.error('Failed to update interval', err); + return false; + } + }, + [fetchDeckies, deployMode], + ); + + const teardown = useCallback(async (d: Decky): Promise => { + if (!d.swarm) return { ok: false, reason: 'not a swarm decky' }; + setTearingDown((prev) => new Set(prev).add(d.name)); + try { + await api.post(`/swarm/hosts/${d.swarm.host_uuid}/teardown`, { decky_id: d.name }); + await fetchDeckies(deployMode?.mode); + return { ok: true }; + } catch (err: unknown) { + const e = err as ApiError; + return { ok: false, reason: e?.response?.data?.detail || d.name }; + } finally { + setTearingDown((prev) => { + const next = new Set(prev); + next.delete(d.name); + return next; + }); + } + }, [fetchDeckies, deployMode]); + + const applyServicesChange = useCallback((name: string, services: string[]) => { + setDeckies((prev) => + prev.map((row) => (row.name === name ? { ...row, services } : row)), + ); + }, []); + + // Initial mount: deploy-mode first (decides which list endpoint to hit), + // then deckies + role + archetypes in parallel. + useEffect(() => { + let cancelled = false; + (async () => { + const mode = await fetchDeployMode(); + if (cancelled) return; + await Promise.all([fetchDeckies(mode), fetchRole(), fetchArchetypes()]); + })(); + const interval = window.setInterval(() => { + fetchDeployMode().then((m) => fetchDeckies(m)); + }, POLL_MS); + return () => { cancelled = true; window.clearInterval(interval); }; + }, [fetchDeckies, fetchDeployMode, fetchRole, fetchArchetypes]); + + return useMemo( + () => ({ + deckies, + loading, + isAdmin, + deployMode, + archetypes, + isSwarm: deployMode?.mode === 'swarm', + mutating, + tearingDown, + refresh, + mutate, + setMutateInterval, + teardown, + applyServicesChange, + }), + [ + deckies, loading, isAdmin, deployMode, archetypes, + mutating, tearingDown, + refresh, mutate, setMutateInterval, teardown, applyServicesChange, + ], + ); +}