import React, { useEffect, useMemo, useRef, useState } from 'react'; import { Server } from '../icons'; import { useToast } from './Toasts/useToast'; import { useServiceRegistry } from '../hooks/useServiceRegistry'; import './DeckyFleet.css'; import type { Decky, FilterKey } from './DeckyFleet/types'; import { dotFor } from './DeckyFleet/helpers'; import { useDeckyFleet } from './DeckyFleet/useDeckyFleet'; import { DeckyInspectPanel } from './DeckyFleet/DeckyInspectPanel'; import { DeckyCard } from './DeckyFleet/DeckyCard'; import { DeployWizard } from './DeckyFleet/DeployWizard'; import { IntervalEditor } from './DeckyFleet/IntervalEditor'; import { DeckyFilters } from './DeckyFleet/DeckyFilters'; import { DeckyGridEmpty } from './DeckyFleet/DeckyGridEmpty'; interface FleetProps { searchQuery?: string; } const DeckyFleet: React.FC = ({ searchQuery = '' }) => { const { push } = useToast(); const serviceRegistry = useServiceRegistry(); const fleet = useDeckyFleet(); const { deckies, loading, isAdmin, deployMode, archetypes, isSwarm, mutating, tearingDown, mutate, setMutateInterval, teardown, applyServicesChange, refresh, } = fleet; // Pure UI state (no data lifecycle). const [filter, setFilter] = useState('all'); const [showDeploy, setShowDeploy] = useState(false); const [armed, setArmed] = useState(null); const [localSearch, setLocalSearch] = useState(''); const [intervalEditor, setIntervalEditor] = useState<{ name: string; current: number | null } | null>(null); const [selectedDecky, setSelectedDecky] = useState(null); const cardRefs = useRef>(new Map()); // Mirror the topbar search prop into local state; filter-decky events // can override it in-session. const lastSearchPropRef = useRef(searchQuery); if (lastSearchPropRef.current !== searchQuery) { lastSearchPropRef.current = searchQuery; if (localSearch !== searchQuery) setLocalSearch(searchQuery); } const arm = (key: string) => { setArmed(key); window.setTimeout(() => setArmed((p) => (p === key ? null : p)), 4000); }; // Toast-wrapping handlers — the hook returns discriminated results, // and the page decides how to surface them in the toast lane. const handleMutate = async (name: string): Promise => { const r = await mutate(name); if (r.ok) { push({ text: `MUTATED · ${name.toUpperCase()}`, tone: 'matrix', icon: 'refresh-cw' }); return true; } const msg = r.reason === 'timeout' ? `MUTATION TIMED OUT · ${name.toUpperCase()}` : `MUTATION FAILED · ${name.toUpperCase()}`; push({ text: msg, tone: 'alert', icon: 'alert-triangle' }); return false; }; 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; const ok = await setMutateInterval(name, minutes); if (ok) { setIntervalEditor(null); push({ text: minutes === null ? `INTERVAL · ${name.toUpperCase()} · DISABLED` : `INTERVAL · ${name.toUpperCase()} · ${minutes}m`, tone: 'matrix', icon: 'refresh-cw', }); } else { push({ text: `INTERVAL UPDATE FAILED · ${name.toUpperCase()}`, tone: 'alert', icon: 'alert-triangle' }); } }; // Two-step teardown: first click arms the button, second click within // 4s actually fires the POST. Keeps swarm hosts safe from misclicks. 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); const r = await teardown(d); if (r.ok) { push({ text: `TORN DOWN · ${d.name.toUpperCase()}`, tone: 'matrix', icon: 'check-circle' }); } else { push({ text: `TEARDOWN FAILED · ${r.reason}`, tone: 'alert', icon: 'alert-triangle' }); } }; // decnet:cmd bus: deploy + mutate-all are wired here because they // dispatch UI state and a toast-wrapped operation respectively. 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; } }; 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]); 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'}] )}
setShowDeploy(true)} />
{visible.length === 0 ? ( setShowDeploy(true)} /> ) : ( visible.map((d) => ( { void handleMutate(name); }} onTeardown={handleTeardown} onIntervalChange={handleIntervalChange} onInspect={(decky) => setSelectedDecky(decky)} innerRef={(el: HTMLDivElement | null) => { if (el) cardRefs.current.set(d.name, el); else cardRefs.current.delete(d.name); }} availableServices={serviceRegistry.perDecky} onServicesChanged={applyServicesChange} onTarpitResult={(_name, ok, message) => { push({ text: message, tone: ok ? 'matrix' : 'alert', icon: ok ? 'shield' : 'alert-triangle', }); }} /> )) )}
setShowDeploy(false)} onComplete={(count) => { setShowDeploy(false); void refresh(); push({ text: `DEPLOYED · ${count} DECK${count === 1 ? 'Y' : 'IES'}`, tone: 'matrix', icon: 'check-circle', }); }} /> setIntervalEditor(null)} onSave={handleIntervalSave} /> {selectedDecky && ( setSelectedDecky(null)} /> )}
); }; export default DeckyFleet;