refactor(decnet_web/SwarmHosts): add useSwarmHosts polled data hook
This commit is contained in:
123
decnet_web/src/components/SwarmHosts/useSwarmHosts.test.ts
Normal file
123
decnet_web/src/components/SwarmHosts/useSwarmHosts.test.ts
Normal file
@@ -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> = {}): 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<ReturnType<typeof result.current.teardownHost>> | 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<ReturnType<typeof result.current.decommissionHost>> | 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<ReturnType<typeof result.current.generateBundle>> | 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
93
decnet_web/src/components/SwarmHosts/useSwarmHosts.ts
Normal file
93
decnet_web/src/components/SwarmHosts/useSwarmHosts.ts
Normal file
@@ -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<T = void> {
|
||||
ok: boolean;
|
||||
reason?: string;
|
||||
data?: T;
|
||||
}
|
||||
|
||||
export interface UseSwarmHosts {
|
||||
hosts: SwarmHost[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
reload: () => Promise<void>;
|
||||
teardownHost: (uuid: string) => Promise<MutationResult>;
|
||||
decommissionHost: (uuid: string) => Promise<MutationResult>;
|
||||
generateBundle: (req: BundleRequest) => Promise<MutationResult<BundleResult>>;
|
||||
}
|
||||
|
||||
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<SwarmHost[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
try {
|
||||
const res = await api.get<SwarmHost[]>('/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<MutationResult> => {
|
||||
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<MutationResult> => {
|
||||
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<MutationResult<BundleResult>> => {
|
||||
try {
|
||||
const res = await api.post<BundleResult>('/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 };
|
||||
}
|
||||
Reference in New Issue
Block a user