From 2c1ccec8fa2561500e0a45657d21898feff5fa90 Mon Sep 17 00:00:00 2001 From: anti Date: Sat, 9 May 2026 06:08:48 -0400 Subject: [PATCH] refactor(decnet_web/SwarmHosts): wire shell + bump coverage floor SwarmHosts.tsx: 513 -> 161 LOC. Page now composes EnrollmentWizard + useSwarmHosts hook; only the arm/confirm UI affordance and the busy-set tracking remain in the shell. --- decnet_web/src/components/SwarmHosts.tsx | 394 ++--------------------- decnet_web/vite.config.ts | 14 +- 2 files changed, 28 insertions(+), 380 deletions(-) diff --git a/decnet_web/src/components/SwarmHosts.tsx b/decnet_web/src/components/SwarmHosts.tsx index e9d9b2b0..199603e9 100644 --- a/decnet_web/src/components/SwarmHosts.tsx +++ b/decnet_web/src/components/SwarmHosts.tsx @@ -1,349 +1,25 @@ -import React, { useEffect, useRef, useState } from 'react'; -import api, { type ApiError } from '../utils/api'; +import React, { useState } from 'react'; import EmptyState from './EmptyState/EmptyState'; -import Modal from './Modal/Modal'; +import EnrollmentWizard from './SwarmHosts/EnrollmentWizard'; +import { useSwarmHosts } from './SwarmHosts/useSwarmHosts'; +import { shortFp } from './SwarmHosts/helpers'; +import type { SwarmHost } from './SwarmHosts/types'; import './Dashboard.css'; import './Swarm.css'; import './DeckyFleet.css'; import { - AlertTriangle, Check, Copy, HardDrive, PowerOff, RefreshCw, RotateCcw, - Server, Trash2, UserPlus, Wifi, WifiOff, + HardDrive, PowerOff, RefreshCw, Server, + Trash2, UserPlus, Wifi, WifiOff, } from '../icons'; -interface SwarmHost { - uuid: string; - name: string; - address: string; - agent_port: number; - status: string; - last_heartbeat: string | null; - client_cert_fingerprint: string; - updater_cert_fingerprint: string | null; - enrolled_at: string; - notes: string | null; -} - -interface BundleResult { - token: string; - host_uuid: string; - command: string; - expires_at: string; -} - -const shortFp = (fp: string): string => (fp ? fp.slice(0, 16) + '…' : '—'); - -// ─── Enrollment wizard ──────────────────────────────────────────────────── - -interface EnrollmentWizardProps { - open: boolean; - onClose: () => void; - onEnrolled: () => void; -} - -const EnrollmentWizard: React.FC = ({ open, onClose, onEnrolled }) => { - const [step, setStep] = useState(0); - const [masterHost, setMasterHost] = useState(window.location.hostname); - const [agentName, setAgentName] = useState(''); - const [withUpdater, setWithUpdater] = useState(true); - const [useIpvlan, setUseIpvlan] = useState(false); - const [servicesIni, setServicesIni] = useState(null); - const [servicesIniName, setServicesIniName] = useState(null); - const [submitting, setSubmitting] = useState(false); - const [error, setError] = useState(null); - const [result, setResult] = useState(null); - const [copied, setCopied] = useState(false); - const [now, setNow] = useState(Date.now()); - const fileRef = useRef(null); - - useEffect(() => { - if (!open) return; - setStep(0); - setMasterHost(window.location.hostname); - setAgentName(''); - setWithUpdater(true); - setUseIpvlan(false); - setServicesIni(null); - setServicesIniName(null); - setSubmitting(false); - setError(null); - setResult(null); - setCopied(false); - if (fileRef.current) fileRef.current.value = ''; - }, [open]); - - useEffect(() => { - if (!result) return; - const t = setInterval(() => setNow(Date.now()), 1000); - return () => clearInterval(t); - }, [result]); - - const handleFile = (e: React.ChangeEvent) => { - const f = e.target.files?.[0]; - if (!f) { - setServicesIni(null); - setServicesIniName(null); - return; - } - const reader = new FileReader(); - reader.onload = () => { - setServicesIni(String(reader.result)); - setServicesIniName(f.name); - }; - reader.readAsText(f); - }; - - const nameOk = /^[a-z0-9][a-z0-9-]{0,62}$/.test(agentName); - - const generate = async () => { - setSubmitting(true); - setError(null); - try { - const res = await api.post('/swarm/enroll-bundle', { - master_host: masterHost, - agent_name: agentName, - with_updater: withUpdater, - use_ipvlan: useIpvlan, - services_ini: servicesIni, - }); - setResult(res.data); - onEnrolled(); - } catch (err: unknown) { - const e = err as ApiError; - setError(e?.response?.data?.detail || 'Enrollment bundle creation failed'); - } finally { - setSubmitting(false); - } - }; - - const copyCmd = async () => { - if (!result) return; - await navigator.clipboard.writeText(result.command); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }; - - const remainingSecs = result - ? Math.max(0, Math.floor((new Date(result.expires_at).getTime() - now) / 1000)) - : 0; - const mm = Math.floor(remainingSecs / 60).toString().padStart(2, '0'); - const ss = (remainingSecs % 60).toString().padStart(2, '0'); - - const canNext = step === 0 ? (nameOk && !!masterHost) : true; - - return ( - - -
- {step > 0 && !result && ( - - )} - {step < 2 && ( - - )} - {step === 2 && !result && ( - - )} - {result && ( - - )} -
- - } - > - <> -
- {['IDENTITY', 'OPTIONS', 'BUNDLE'].map((l, i) => ( -
- {i + 1}. {l} -
- ))} -
- -
- {step === 0 && ( - <> -
Who is this worker, and how does it reach the master?
-
- - setMasterHost(e.target.value)} - /> -
-
- - setAgentName(e.target.value.toLowerCase())} - pattern="^[a-z0-9][a-z0-9-]{0,62}$" - data-autofocus - /> - {agentName && !nameOk && ( - - must match ^[a-z0-9][a-z0-9-]{'{0,62}'}$ - - )} -
- - )} - - {step === 1 && ( - <> -
Bundle options — tune for the target environment.
-
- setWithUpdater(e.target.checked)} - style={{ accentColor: 'var(--matrix)', marginTop: 2 }} - /> - -
-
- setUseIpvlan(e.target.checked)} - style={{ accentColor: 'var(--matrix)', marginTop: 2 }} - /> - -
-
- - - {servicesIniName && ( -
- loaded: {servicesIniName} -
- )} -
- - )} - - {step === 2 && ( - <> - {!result ? ( - <> -
- Review and generate a one-shot bootstrap URL valid for 5 minutes. -
-
- # enrollment bundle preview{'\n'} - master_host{' '}{masterHost}{'\n'} - agent_name {' '}{agentName}{'\n'} - updater {' '}{withUpdater ? 'yes' : 'no'}{'\n'} - ipvlan {' '}{useIpvlan ? 'yes' : 'no'}{'\n'} - services {' '}{servicesIniName ?? '—'} -
- {error && ( -
- ✖ {error} -
- )} - - ) : ( - <> -
Paste this on the new worker (as root):
-
- {result.command} -
-
- -
-
- Expires in {mm}:{ss} — one-shot, single download. - Host UUID: {result.host_uuid} -
- {remainingSecs === 0 && ( -
- This bundle has expired. Generate another. -
- )} - - )} - - )} -
- -
- ); -}; - -// ─── Swarm hosts page ───────────────────────────────────────────────────── - const SwarmHosts: React.FC = () => { - const [hosts, setHosts] = useState([]); - const [loading, setLoading] = useState(true); + const { + hosts, loading, error, reload, + teardownHost, decommissionHost, generateBundle, + } = useSwarmHosts(); + const [decommissioning, setDecommissioning] = useState>(new Set()); const [tearingDown, setTearingDown] = useState>(new Set()); - const [error, setError] = useState(null); const [showEnroll, setShowEnroll] = useState(false); // Two-click arm/commit replaces window.confirm(). Browsers silently // suppress confirm() after the "prevent additional dialogs" opt-out, @@ -355,24 +31,6 @@ const SwarmHosts: React.FC = () => { setTimeout(() => setArmed((prev) => (prev === key ? null : prev)), 4000); }; - const fetchHosts = async () => { - try { - const res = await api.get('/swarm/hosts'); - setHosts(res.data); - setError(null); - } catch (err: any) { - setError(err?.response?.data?.detail || 'Failed to fetch swarm hosts'); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - fetchHosts(); - const t = setInterval(fetchHosts, 10000); - return () => clearInterval(t); - }, []); - const addTo = (set: Set, id: string) => { const n = new Set(set); n.add(id); return n; }; const removeFrom = (set: Set, id: string) => { const n = new Set(set); n.delete(id); return n; }; @@ -381,15 +39,9 @@ const SwarmHosts: React.FC = () => { if (armed !== key) { arm(key); return; } setArmed(null); setTearingDown((s) => addTo(s, host.uuid)); - try { - // 202 Accepted — teardown runs async on the backend. - await api.post(`/swarm/hosts/${host.uuid}/teardown`, {}); - await fetchHosts(); - } catch (err: any) { - alert(err?.response?.data?.detail || 'Teardown failed'); - } finally { - setTearingDown((s) => removeFrom(s, host.uuid)); - } + const r = await teardownHost(host.uuid); + if (!r.ok) alert(r.reason ?? 'Teardown failed'); + setTearingDown((s) => removeFrom(s, host.uuid)); }; const handleDecommission = async (host: SwarmHost) => { @@ -397,14 +49,9 @@ const SwarmHosts: React.FC = () => { if (armed !== key) { arm(key); return; } setArmed(null); setDecommissioning((s) => addTo(s, host.uuid)); - try { - await api.delete(`/swarm/hosts/${host.uuid}`); - await fetchHosts(); - } catch (err: any) { - alert(err?.response?.data?.detail || 'Decommission failed'); - } finally { - setDecommissioning((s) => removeFrom(s, host.uuid)); - } + const r = await decommissionHost(host.uuid); + if (!r.ok) alert(r.reason ?? 'Decommission failed'); + setDecommissioning((s) => removeFrom(s, host.uuid)); }; const online = hosts.filter((h) => h.status === 'online').length; @@ -422,7 +69,7 @@ const SwarmHosts: React.FC = () => {
-
); diff --git a/decnet_web/vite.config.ts b/decnet_web/vite.config.ts index ac450225..0063bb3b 100644 --- a/decnet_web/vite.config.ts +++ b/decnet_web/vite.config.ts @@ -15,15 +15,15 @@ export default defineConfig({ include: ['src/**/*.{ts,tsx}'], exclude: ['src/**/*.d.ts', 'src/test/**', 'src/main.tsx'], // Baseline floors. Each refactor PR raises these; never lower. - // Phase 7 (Webhooks trim): page shell down from 642 to 387 LOC. - // Lifted helpers, FormRow, SecretModal, and a useWebhooks data - // hook (CRUD + test endpoint). 17 new tests. Suite: 43 files, - // 207 tests, 21.69% lines / 16.51% branches. + // Phase 8 (SwarmHosts trim): page shell down from 513 to 161 LOC. + // Lifted helpers, EnrollmentWizard, and a useSwarmHosts polled + // data hook (CRUD + bundle generation). 16 new tests. Suite: + // 46 files, 223 tests, 22.9% lines / 16.97% branches. thresholds: { - lines: 21, - functions: 18, + lines: 22, + functions: 19, branches: 16, - statements: 20, + statements: 21, }, }, },