refactor(decnet_web/DeckyFleet): extract useDeckyFleet data hook
Lift every read- and write-side data flow off the page shell:
GET /system/deployment-mode (decides which list endpoint to hit)
GET /deckies | /swarm/deckies (mode-switched + shape-normalized)
GET /config (role -> isAdmin)
GET /topologies/archetypes (live catalog with bundled fallback)
POST /deckies/:name/mutate
PUT /deckies/:name/mutate-interval
POST /swarm/hosts/:uuid/teardown
10s polling loop refreshing mode + list
Operations return discriminated results ({ok:true} | {ok:false,
reason:...}) so the page can branch toast tone without seeing the
axios error type. Toasts, arm-confirm, and modal visibility stay
in the consuming page — the hook is pure data.
- New DeckyFleet/useDeckyFleet.ts
- useDeckyFleet.test.ts MSW-covers initial load, swarm-mode shape
normalization, mutate ok/error paths, teardown ok path, and
applyServicesChange optimistic write.
- DeckyFleet.tsx wiring lands in the next commit so the diff stays
reviewable.
This commit is contained in:
171
decnet_web/src/components/DeckyFleet/useDeckyFleet.test.ts
Normal file
171
decnet_web/src/components/DeckyFleet/useDeckyFleet.test.ts
Normal file
@@ -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<ReturnType<typeof result.current.mutate>> | 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<ReturnType<typeof result.current.mutate>> | 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<ReturnType<typeof result.current.teardown>> | 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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
234
decnet_web/src/components/DeckyFleet/useDeckyFleet.ts
Normal file
234
decnet_web/src/components/DeckyFleet/useDeckyFleet.ts
Normal file
@@ -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<string>;
|
||||||
|
|
||||||
|
/** Re-fetch the decky list under the current deploy mode. */
|
||||||
|
refresh: () => Promise<void>;
|
||||||
|
/** Force-mutate one decky. Resolves to a discriminated result so
|
||||||
|
* the caller can branch toast tone without seeing axios errors. */
|
||||||
|
mutate: (name: string) => Promise<MutateResult>;
|
||||||
|
/** Update or clear a decky's periodic mutate interval. */
|
||||||
|
setMutateInterval: (name: string, minutes: number | null) => Promise<boolean>;
|
||||||
|
/** Tear down a swarm-pinned decky on its host. */
|
||||||
|
teardown: (d: Decky) => Promise<TeardownResult>;
|
||||||
|
/** 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<Decky[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [isAdmin, setIsAdmin] = useState(false);
|
||||||
|
const [deployMode, setDeployMode] = useState<DeployMode | null>(null);
|
||||||
|
const [archetypes, setArchetypes] = useState<Archetype[]>(FALLBACK_ARCHETYPES);
|
||||||
|
const [mutating, setMutating] = useState<string | null>(null);
|
||||||
|
const [tearingDown, setTearingDown] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const fetchDeckies = useCallback(async (mode?: string) => {
|
||||||
|
try {
|
||||||
|
if (mode === 'swarm') {
|
||||||
|
const res = await api.get<SwarmDeckyRaw[]>('/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<Decky[]>('/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<string | undefined> => {
|
||||||
|
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<MutateResult> => {
|
||||||
|
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<boolean> => {
|
||||||
|
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<TeardownResult> => {
|
||||||
|
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,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user