import React, { useEffect, useMemo, useRef, useState } from 'react'; import { Cpu, Database, Globe, Monitor, Network, PlusCircle, PowerOff, RefreshCw, Server, Shield, Terminal, Plus, X, } from '../icons'; import api from '../utils/api'; import { ARCHETYPES as FALLBACK_ARCHETYPES, DEFAULT_SERVICES } from './MazeNET/data'; import { useToast } from './Toasts/useToast'; import Modal from './Modal/Modal'; import { useServiceRegistry } from '../hooks/useServiceRegistry'; import ServiceConfigForm from './ServiceConfigForm'; import AddServiceConfigModal from './AddServiceConfigModal'; import ServiceConfigFields, { type FormState as SvcFormState, type ServiceConfigFieldDTO as SvcFieldDTO, compactPayload as svcCompactPayload, } from './ServiceConfigFields'; import './DeckyFleet.css'; // ─── Types ──────────────────────────────────────────────────────────────── interface SwarmMeta { host_uuid: string; host_name: string; host_address: string; host_status: string; state: string; last_error: string | null; last_seen: string | null; } interface Decky { name: string; ip: string; services: string[]; distro: string; hostname: string; archetype: string | null; service_config: Record>; mutate_interval: number | null; last_mutated: number; swarm?: SwarmMeta; } interface SwarmDeckyRaw { decky_name: string; decky_ip: string | null; host_uuid: string; host_name: string; host_address: string; host_status: string; services: string[]; state: string; last_error: string | null; last_seen: string | null; hostname: string | null; distro: string | null; archetype: string | null; service_config: Record>; mutate_interval: number | null; last_mutated: number; } interface Archetype { slug: string; name: string; services: string[]; icon: string; } type FilterKey = 'all' | 'active' | 'hot' | 'idle'; type DeckyStatus = 'active' | 'hot' | 'idle'; // ─── Helpers ────────────────────────────────────────────────────────────── const _archetypeIcon = (slug: string): string => { const s = slug.toLowerCase(); if (s.includes('windows') || s.includes('workstation')) return 'monitor'; if (s.includes('domain')) return 'shield'; if (s.includes('database') || s.includes('sql')) return 'database'; if (s.includes('iot') || s.includes('ot')) return 'cpu'; if (s.includes('web')) return 'globe'; return 'server'; }; // Compact icon resolver for lucide names we use in the wizard. const PickIcon: React.FC<{ name: string; size?: number; className?: string }> = ( { name, size = 16, className }, ) => { const map: Record> = { server: Server, monitor: Monitor, shield: Shield, database: Database, cpu: Cpu, globe: Globe, terminal: Terminal, }; const Cmp = map[name] ?? Server; return ; }; // Map swarm state -> visual dot status. "active" with no recent hit is idle; // we don't have per-decky hit counts here, so treat running = active. const _dotFor = (d: Decky): DeckyStatus => { if (!d.swarm) return 'active'; switch (d.swarm.state) { case 'running': return 'active'; case 'failed': case 'teardown_failed': return 'hot'; case 'pending': case 'tearing_down': case 'degraded': return 'idle'; default: return 'idle'; } }; // Hits placeholder — backend doesn't expose per-decky 24h hit count yet. const _hitsFor = (_d: Decky): number => 0; const _stateColor = (state: string): string => { switch (state) { case 'running': return 'var(--matrix)'; case 'degraded': case 'tearing_down': case 'pending': return 'var(--violet)'; case 'failed': case 'teardown_failed': return 'var(--alert)'; default: return 'var(--border)'; } }; // ─── Decky card ─────────────────────────────────────────────────────────── interface DeckyCardProps { decky: Decky; mutating: boolean; isAdmin: boolean; armed: string | null; tdBusy: boolean; onForce: (name: string) => void; onTeardown: (d: Decky) => void; onIntervalChange: (name: string, current: number | null) => void; onInspect: (d: Decky) => void; innerRef?: React.Ref; /** Per-decky-eligible service slugs from useServiceRegistry. */ availableServices: string[]; /** Called after a successful live add/remove so the parent can * optimistically apply the response's services list. */ onServicesChanged: (deckyName: string, services: string[]) => void; } const DeckyCard: React.FC = ({ decky, mutating, isAdmin, armed, tdBusy, onForce, onTeardown, onIntervalChange, onInspect, innerRef, availableServices, onServicesChanged, }) => { const dot = _dotFor(decky); const hits = _hitsFor(decky); const hot = dot === 'hot'; const dotClass = mutating ? 'mutating' : dot; const tdKey = decky.swarm ? `td:${decky.swarm.host_uuid}:${decky.name}` : ''; // Live service mutation is local-only (admin, non-swarm). Swarm // deckies live on a remote agent — the W3 path runs docker compose // locally and won't reach the agent's containers (same gap as the // canary planter has for agent-pinned topologies; out of scope here). const liveServicesEnabled = isAdmin && !decky.swarm; const [addOpen, setAddOpen] = useState(false); const [addSlug, setAddSlug] = useState(''); const [busy, setBusy] = useState(null); const [opError, setOpError] = useState(null); const [openCfgSvc, setOpenCfgSvc] = useState(null); // Pending add — when non-null, AddServiceConfigModal is mounted and // will either auto-fire onConfirm (no schema fields) or show the form. const [pendingAdd, setPendingAdd] = useState<{ deckyName: string; slug: string } | null>(null); const removeService = async (slug: string) => { setOpError(null); setBusy(slug); try { const { data } = await api.delete<{ services: string[] }>( `/deckies/${encodeURIComponent(decky.name)}/services/${encodeURIComponent(slug)}`, ); onServicesChanged(decky.name, data.services); } catch (err) { const msg = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail ?? 'Remove failed.'; setOpError(msg); } finally { setBusy(null); } }; const beginAdd = () => { if (!addSlug) return; setOpError(null); setPendingAdd({ deckyName: decky.name, slug: addSlug }); }; const confirmAdd = async (deckyName: string, slug: string, cfg: Record) => { setBusy(slug); try { const { data } = await api.post<{ services: string[] }>( `/deckies/${encodeURIComponent(deckyName)}/services`, { name: slug, config: cfg }, ); onServicesChanged(deckyName, data.services); setPendingAdd(null); setAddOpen(false); setAddSlug(''); } catch (err) { // Re-raise so the modal can surface the error in its own status row. // Also mirror onto opError for the inline picker case. const msg = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail ?? 'Add failed.'; setOpError(msg); throw err; } finally { setBusy(null); } }; return (
{ if ((e.target as HTMLElement).closest('button, a, input')) return; onInspect(decky); }} style={{ cursor: 'pointer' }} >
{decky.name}
{decky.ip}
{decky.swarm && (
{decky.swarm.host_name} @ {decky.swarm.host_address || '—'} {decky.swarm.state.toUpperCase()} {decky.swarm.last_error && ( ⚠ {decky.swarm.last_error.slice(0, 48)} {decky.swarm.last_error.length > 48 ? '…' : ''} )}
)}
HOST{decky.hostname}
DISTRO{decky.distro}
ARCHETYPE {decky.archetype || '—'}
MUTATE {!decky.swarm && isAdmin ? ( onIntervalChange(decky.name, decky.mutate_interval)} > {decky.mutate_interval ? `EVERY ${decky.mutate_interval}m` : 'DISABLED'} ) : ( {decky.mutate_interval ? `EVERY ${decky.mutate_interval}m` : 'DISABLED'} )}
EXPOSED
{decky.services.map((s) => ( {liveServicesEnabled ? ( ) : ( {s} )} {liveServicesEnabled && ( )} ))} {liveServicesEnabled && !addOpen && ( )}
{liveServicesEnabled && addOpen && (
e.stopPropagation()} style={{ display: 'flex', gap: 6, marginTop: 6, alignItems: 'center' }} >
)} {opError && (
{opError}
)} {liveServicesEnabled && openCfgSvc && decky.services.includes(openCfgSvc) && (
e.stopPropagation()}>
)}
HITS 24h: {hits}
{!decky.swarm && isAdmin && ( )} {decky.swarm && isAdmin && ( )}
setPendingAdd(null)} onConfirm={confirmAdd} />
); }; // ─── Deploy wizard ──────────────────────────────────────────────────────── interface DeployWizardProps { open: boolean; onClose: () => void; onComplete: (count: number) => void; archetypes: Archetype[]; fleetSize: number; } type PickMode = 'archetype' | 'services'; const PLACEHOLDER_LINES = ( archetypeName: string, services: string[], count: number, fleetSize: number, ): string[] => [ '[INIT] allocating MAC addresses...', '[NET] binding macvlan interfaces...', `[FP] spoofing OS fingerprint → ${archetypeName}`, `[SVC] starting services: ${services.join(', ') || '—'}`, '[TLS] provisioning self-signed certs...', '[SENSE] attaching syslog sinks to logging stack...', `[OK] ${count} deckies online — fleet size now ${fleetSize + count}`, ]; // UTF-8-safe base64 encode (btoa alone breaks on non-ASCII). const _b64encodeUtf8 = (s: string): string => { const bytes = new TextEncoder().encode(s); let bin = ''; for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]); return btoa(bin); }; const _buildIni = ( prefix: string, count: number, fleetSize: number, mode: PickMode, archetype: Archetype | null, services: string[], mutate: boolean, mutateEvery: number, serviceConfigs: Record>, serviceSchemas: Record, ): string => { const lines: string[] = []; for (let i = 0; i < count; i++) { const name = `${prefix}-${String(fleetSize + i + 1).padStart(2, '0')}`; lines.push(`[${name}]`); if (mode === 'archetype' && archetype) { lines.push(`archetype=${archetype.slug}`); } else if (mode === 'services' && services.length) { lines.push(`services=${services.join(',')}`); } if (mutate) lines.push(`mutate_interval=${mutateEvery}`); lines.push(''); } // Per-service overrides emitted as [.] group subsections. // The INI loader (decnet/ini_loader.py) prefix-matches these onto every // ``${prefix}-NN`` decky in the batch, so one block covers all clones. for (const svc of services) { const cfg = serviceConfigs[svc]; if (!cfg || Object.keys(cfg).length === 0) continue; const fieldTypes: Record = {}; for (const f of serviceSchemas[svc] ?? []) fieldTypes[f.key] = f.type; lines.push(`[${prefix}.${svc}]`); for (const [k, v] of Object.entries(cfg)) { // textarea values may contain newlines that ConfigParser can't carry // on a single line; wrap them in `b64:` so validate_cfg decodes back // to the original UTF-8 string. Other types are emitted raw. let serialised: string; if (fieldTypes[k] === 'textarea' && typeof v === 'string') { serialised = `b64:${_b64encodeUtf8(v)}`; } else { serialised = typeof v === 'string' ? v : String(v); } lines.push(`${k}=${serialised}`); } lines.push(''); } return lines.join('\n'); }; const DeployWizard: React.FC = ({ open, onClose, onComplete, archetypes, fleetSize, }) => { const [step, setStep] = useState(0); const [pickMode, setPickMode] = useState('archetype'); const [archetype, setArchetype] = useState(null); const [selectedServices, setSelectedServices] = useState([]); const [prefix, setPrefix] = useState('decky'); const [count, setCount] = useState(3); const [mutate, setMutate] = useState(true); const [mutateEvery, setMutateEvery] = useState(30); const [deploying, setDeploying] = useState(false); const [log, setLog] = useState([]); const [deployErr, setDeployErr] = useState(null); // Per-service config dicts keyed by service slug. Edits flow into // the INI as [.] subsections at deploy time so the // initial container build picks them up — no follow-up apply needed. const [serviceConfigs, setServiceConfigs] = useState>({}); const [serviceSchemas, setServiceSchemas] = useState>({}); const [openSvcCfg, setOpenSvcCfg] = useState(null); useEffect(() => { if (!open) return; setStep(0); setPickMode('archetype'); setArchetype(null); setSelectedServices([]); setPrefix('decky'); setCount(3); setMutate(true); setMutateEvery(30); setDeploying(false); setLog([]); setDeployErr(null); setServiceConfigs({}); setServiceSchemas({}); setOpenSvcCfg(null); }, [open]); const effectiveArchetypeName = archetype?.name ?? (pickMode === 'services' && selectedServices.length ? 'custom services' : 'linux-server'); const effectiveServices = pickMode === 'archetype' ? (archetype?.services ?? []) : selectedServices; // Drop config for services no longer in the selection so the INI // doesn't carry orphaned subsections, and auto-collapse the open // panel if its service got removed. useEffect(() => { setServiceConfigs((prev) => { const allowed = new Set(effectiveServices); const next: Record = {}; for (const [k, v] of Object.entries(prev)) if (allowed.has(k)) next[k] = v; return next; }); setOpenSvcCfg((cur) => (cur && effectiveServices.includes(cur) ? cur : null)); // eslint-disable-next-line react-hooks/exhaustive-deps }, [effectiveServices.join('|')]); // Preview lines, count-aware (shows up to 6, with "…and N more" footer). const previewLines = useMemo(() => { const out: string[] = []; const cap = Math.min(count, 6); for (let i = 0; i < cap; i++) { const name = `${prefix}-${String(fleetSize + i + 1).padStart(2, '0')}`; out.push(`${name} → ${effectiveArchetypeName} [${effectiveServices.join(', ') || '—'}]`); } if (count > 6) out.push(`...and ${count - 6} more`); return out; }, [count, prefix, fleetSize, effectiveArchetypeName, effectiveServices]); const [deployOk, setDeployOk] = useState(false); const [deployFailures, setDeployFailures] = useState([]); // Fake log stream during "deploying" (runs as visual backdrop; real API // lines are spliced in by startDeploy once the HTTP call resolves). useEffect(() => { if (step !== 3 || !deploying) return; const msgs = PLACEHOLDER_LINES(effectiveArchetypeName, effectiveServices, count, fleetSize); let i = 0; const t = window.setInterval(() => { setLog((prev) => [...prev, msgs[i]]); i++; if (i >= msgs.length) { window.clearInterval(t); // Only auto-close if the server accepted. if (deployOk) { window.setTimeout(() => onComplete(count), 500); } } }, 420); return () => window.clearInterval(t); }, [step, deploying, effectiveArchetypeName, effectiveServices, count, fleetSize, onComplete, deployOk]); const canNext = step === 0 ? (pickMode === 'archetype' ? !!archetype : selectedServices.length > 0) : true; const startDeploy = async () => { setDeployErr(null); setLog([]); setDeployOk(false); setDeployFailures([]); setDeploying(true); // Roll the per-service forms into the compact payload the server // expects — empty values dropped, types coerced where the schema // already pulled in primitives. const rolled: Record> = {}; for (const svc of effectiveServices) { const fields = serviceSchemas[svc]; const state = serviceConfigs[svc]; if (!fields || !state) continue; const compact = svcCompactPayload(fields, state); if (Object.keys(compact).length > 0) rolled[svc] = compact; } const servicesForIni = pickMode === 'archetype' ? (archetype?.services ?? []) : selectedServices; const ini = _buildIni( prefix, count, fleetSize, pickMode, archetype, servicesForIni, mutate, mutateEvery, rolled, serviceSchemas, ); try { const res = await api.post<{ failures?: { name: string; reason: string }[] }>( '/deckies/deploy', { ini_content: ini }, { timeout: 180000 }, ); const failures = res.data?.failures ?? []; setDeployFailures(failures.map(f => `[FAIL] ${f.name}: ${f.reason}`)); if (failures.length > 0) { setLog(prev => [...prev, `[OK] server accepted ${count - failures.length}/${count}`, ...failures.map(f => `[FAIL] ${f.name}: ${f.reason}`)]); } else { setLog(prev => [...prev, `[OK] server accepted ${count} deckies`]); } setDeployOk(true); } catch (e: unknown) { const err = e as { response?: { data?: { detail?: string } }; message?: string }; setDeployErr(err?.response?.data?.detail || err?.message || 'Deploy failed'); setDeploying(false); } }; const toggleService = (slug: string) => { setSelectedServices((prev) => prev.includes(slug) ? prev.filter((s) => s !== slug) : [...prev, slug]); }; return (
{step > 0 && !deploying && ( )} {step < 3 && ( )} {step === 3 && !deploying && ( )} {step === 3 && deploying && !deployOk && ( )} {step === 3 && deployOk && deployFailures.length > 0 && ( )}
} > <>
{['ARCHETYPE', 'CONFIGURATION', 'MUTATION', 'DEPLOY'].map((l, i) => (
{i + 1}. {l}
))}
{step === 0 && ( <>
{pickMode === 'archetype' ? ( <>
Pick the archetype the deckies should masquerade as.
{archetypes.map((a) => ( ))}
) : ( <>
Pick individual services. Every selected decky will expose the same set.
{DEFAULT_SERVICES.map((s) => { const on = selectedServices.includes(s.slug); return ( ); })}
)} )} {step === 1 && ( <>
How many, and what to call them.
setPrefix(e.target.value.replace(/\s+/g, '-'))} />
setCount(parseInt(e.target.value, 10))} />
{effectiveServices.length > 0 && (
Click a service to set passwords, banners, response codes, TLS material — applied to every decky in this batch via INI subsections.
{effectiveServices.map((svc) => { const open = openSvcCfg === svc; const overrideCount = Object.values(serviceConfigs[svc] ?? {}) .filter((v) => v !== '' && v !== undefined && v !== null && v !== false) .length; return (
{open && (
setServiceConfigs((s) => ({ ...s, [svc]: next }))} onSchema={(sch) => setServiceSchemas((s) => ({ ...s, [svc]: sch.fields }))} />
)}
); })}
)}
# preview: deckies that will come online {'\n'} {previewLines.join('\n')}
)} {step === 2 && ( <>
Mutation rotates MAC / IP / hostname so attackers can't re-target.
setMutate(e.target.checked)} style={{ accentColor: 'var(--matrix)' }} />
{mutate && (
setMutateEvery(parseInt(e.target.value, 10))} />
Next mutation will occur {mutateEvery}m after deploy.
)} )} {step === 3 && ( <>
{!deploying ? 'Ready to deploy. This will write to the fleet and start the listener.' : 'Deploying...'}
{log.length === 0 && !deploying && ( <> # decnet deploy \{'\n'} {pickMode === 'archetype' && archetype && ( <> --archetype{' '} {archetype.slug}{' \\'}{'\n'} )} --count{' '} {count}{' \\'}{'\n'} --prefix{' '} {prefix}{' \\'}{'\n'} --mutate{' '} {mutate ? `${mutateEvery}m` : 'off'} {pickMode === 'services' && selectedServices.length > 0 && ( <> {' \\'}{'\n'} --services{' '} {selectedServices.join(',')} )} )} {log.map((l, i) =>
{l}
)} {deploying && log.length < 7 && }
{deployErr && (
✖ {deployErr}
)} )}
); }; // ─── Interval editor modal ─────────────────────────────────────────────── interface IntervalEditorProps { open: boolean; deckyName: string; current: number | null; onClose: () => void; onSave: (minutes: number | null) => void; } const IntervalEditor: React.FC = ({ open, deckyName, current, onClose, onSave }) => { const [enabled, setEnabled] = useState(current !== null); const [minutes, setMinutes] = useState(current ?? 30); return ( } >
setEnabled(e.target.checked)} style={{ accentColor: 'var(--matrix)' }} />
{enabled && (
setMinutes(parseInt(e.target.value, 10))} />
Applied on the next mutation cycle.
)}
); }; // ─── Fleet page ────────────────────────────────────────────────────────── interface FleetProps { searchQuery?: string; } const DeckyFleet: React.FC = ({ searchQuery = '' }) => { const { push } = useToast(); const serviceRegistry = useServiceRegistry(); const [deckies, setDeckies] = useState([]); const [loading, setLoading] = useState(true); const [mutating, setMutating] = useState(null); const [isAdmin, setIsAdmin] = useState(false); const [deployMode, setDeployMode] = useState<{ mode: string; swarm_host_count: number } | null>(null); const [filter, setFilter] = useState('all'); const [showDeploy, setShowDeploy] = useState(false); const [armed, setArmed] = useState(null); const [tearingDown, setTearingDown] = useState>(new Set()); const [archetypes, setArchetypes] = useState(FALLBACK_ARCHETYPES); const [localSearch, setLocalSearch] = useState(''); const [intervalEditor, setIntervalEditor] = useState<{ name: string; current: number | null } | null>(null); const cardRefs = useRef>(new Map()); const lastSearchPropRef = useRef(searchQuery); if (lastSearchPropRef.current !== searchQuery) { lastSearchPropRef.current = searchQuery; // Mirror the topbar search into local state; filter-decky events can // override it in-session. if (localSearch !== searchQuery) setLocalSearch(searchQuery); } const arm = (key: string) => { setArmed(key); window.setTimeout(() => setArmed((p) => (p === key ? null : p)), 4000); }; const fetchDeckies = 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 = async () => { try { const res = await api.get('/config'); setIsAdmin(res.data.role === 'admin'); } catch { setIsAdmin(false); } }; const fetchDeployMode = async () => { 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 = 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 handleMutate = async (name: string): Promise => { setMutating(name); try { await api.post(`/deckies/${name}/mutate`, {}, { timeout: 120000 }); await fetchDeckies(deployMode?.mode); push({ text: `MUTATED · ${name.toUpperCase()}`, tone: 'matrix', icon: 'refresh-cw' }); return true; } catch (err: unknown) { console.error('Failed to mutate', err); const e = err as { code?: string }; const msg = e.code === 'ECONNABORTED' ? `MUTATION TIMED OUT · ${name.toUpperCase()}` : `MUTATION FAILED · ${name.toUpperCase()}`; push({ text: msg, tone: 'alert', icon: 'alert-triangle' }); return false; } finally { setMutating(null); } }; const handleMutateAll = async () => { if (!isAdmin) { push({ text: 'ADMIN REQUIRED', tone: 'alert', icon: 'alert-triangle' }); return; } const targets = deckies.filter(d => !d.swarm || d.swarm.state === 'running'); if (targets.length === 0) { push({ text: 'NO DECKIES TO MUTATE', tone: 'violet', icon: 'info' }); return; } push({ text: `MUTATING FLEET · ${targets.length} DECKIES`, tone: 'violet', icon: 'refresh-cw' }); let failed = 0; for (const d of targets) { const ok = await handleMutate(d.name); if (!ok) failed++; } if (failed === 0) { push({ text: 'FLEET MUTATED', tone: 'matrix', icon: 'check-circle' }); } else { push({ text: `FLEET MUTATED · ${failed} FAILED`, tone: 'alert', icon: 'alert-triangle' }); } }; const handleIntervalChange = (name: string, current: number | null) => { setIntervalEditor({ name, current }); }; const handleIntervalSave = async (minutes: number | null) => { if (!intervalEditor) return; const { name } = intervalEditor; try { await api.put(`/deckies/${name}/mutate-interval`, { mutate_interval: minutes }); setIntervalEditor(null); fetchDeckies(deployMode?.mode); push({ text: minutes === null ? `INTERVAL · ${name.toUpperCase()} · DISABLED` : `INTERVAL · ${name.toUpperCase()} · ${minutes}m`, tone: 'matrix', icon: 'refresh-cw', }); } catch (err) { console.error('Failed to update interval', err); push({ text: `INTERVAL UPDATE FAILED · ${name.toUpperCase()}`, tone: 'alert', icon: 'alert-triangle' }); } }; const handleTeardown = async (d: Decky) => { if (!d.swarm) return; const key = `td:${d.swarm.host_uuid}:${d.name}`; if (armed !== key) { arm(key); return; } setArmed(null); 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); push({ text: `TORN DOWN · ${d.name.toUpperCase()}`, tone: 'matrix', icon: 'check-circle' }); } catch (err: unknown) { const e = err as { response?: { data?: { detail?: string } } }; push({ text: `TEARDOWN FAILED · ${e?.response?.data?.detail || d.name}`, tone: 'alert', icon: 'alert-triangle', }); } finally { setTearingDown((prev) => { const next = new Set(prev); next.delete(d.name); return next; }); } }; const handleInspect = (d: Decky) => { window.dispatchEvent(new CustomEvent('decnet:cmd', { detail: { id: 'filter-decky', payload: d.name }, })); }; 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)); }, 10000); return () => { cancelled = true; window.clearInterval(interval); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Phase-2 decnet:cmd bus: deploy, mutate-all, filter-decky useEffect(() => { const onCmd = (e: Event) => { const detail = (e as CustomEvent).detail as { id?: string; payload?: string }; if (!detail?.id) return; if (detail.id === 'deploy') { setShowDeploy(true); return; } if (detail.id === 'mutate-all') { void handleMutateAll(); return; } if (detail.id === 'filter-decky' && typeof detail.payload === 'string') { const name = detail.payload; setLocalSearch(name); push({ text: `FILTERING · ${name.toUpperCase()}`, tone: 'violet', icon: 'crosshair' }); // Defer so React renders filtered grid first. window.setTimeout(() => { const el = cardRefs.current.get(name); if (el) el.scrollIntoView({ block: 'center', behavior: 'smooth' }); }, 80); } }; window.addEventListener('decnet:cmd', onCmd); return () => window.removeEventListener('decnet:cmd', onCmd); // eslint-disable-next-line react-hooks/exhaustive-deps }, [deckies, isAdmin]); const counts = useMemo(() => { const c = { all: deckies.length, active: 0, hot: 0, idle: 0 } as Record; for (const d of deckies) { const s = _dotFor(d); c[s] += 1; } return c; }, [deckies]); const visible = useMemo(() => { const base = filter === 'all' ? deckies : deckies.filter((d) => _dotFor(d) === filter); const q = localSearch.trim().toLowerCase(); if (!q) return base; return base.filter((d) => d.name.toLowerCase().includes(q) || (d.ip || '').toLowerCase().includes(q) || (d.hostname || '').toLowerCase().includes(q), ); }, [deckies, filter, localSearch]); const isSwarm = deployMode?.mode === 'swarm'; if (loading) { return (
SCANNING NETWORK FOR DECOYS...
); } return (

DECOY FLEET

{deckies.length} DECKIES DEPLOYED · {counts.active + counts.hot} ACTIVE · {counts.hot} UNDER SIEGE {deployMode && ( <> · [{isSwarm ? `SWARM × ${deployMode.swarm_host_count}` : 'UNIHOST'}] )}
{([['all', 'ALL'], ['active', 'ACTIVE'], ['hot', 'HOT'], ['idle', 'IDLE']] as [FilterKey, string][]).map( ([v, l]) => ( ), )}
{isAdmin && ( )}
{visible.length === 0 ? (
{deckies.length === 0 ? 'NO DECOYS DEPLOYED IN THIS SECTOR' : 'NO DECOYS MATCH CURRENT FILTER'} {isAdmin && deckies.length === 0 && ( )}
) : ( visible.map((d) => ( { void handleMutate(name); }} onTeardown={handleTeardown} onIntervalChange={handleIntervalChange} onInspect={handleInspect} innerRef={(el: HTMLDivElement | null) => { if (el) cardRefs.current.set(d.name, el); else cardRefs.current.delete(d.name); }} availableServices={serviceRegistry.perDecky} onServicesChanged={(name, services) => { setDeckies((prev) => prev.map((row) => row.name === name ? { ...row, services } : row, )); }} /> )) )}
setShowDeploy(false)} onComplete={(count) => { setShowDeploy(false); fetchDeckies(deployMode?.mode); push({ text: `DEPLOYED · ${count} DECK${count === 1 ? 'Y' : 'IES'}`, tone: 'matrix', icon: 'check-circle', }); }} /> setIntervalEditor(null)} onSave={handleIntervalSave} />
); }; export default DeckyFleet;