import React, { useEffect, useMemo, useRef, useState } from 'react'; import { Cpu, Database, Globe, Monitor, Network, PlusCircle, PowerOff, RefreshCw, Server, Shield, Terminal, } from 'lucide-react'; 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 './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; } const DeckyCard: React.FC = ({ decky, mutating, isAdmin, armed, tdBusy, onForce, onTeardown, onIntervalChange, onInspect, innerRef, }) => { 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}` : ''; 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) => {s})}
HITS 24h: {hits}
{!decky.swarm && isAdmin && ( )} {decky.swarm && isAdmin && ( )}
); }; // ─── 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}`, ]; const _buildIni = ( prefix: string, count: number, fleetSize: number, mode: PickMode, archetype: Archetype | null, services: string[], mutate: boolean, mutateEvery: number, ): 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(''); } 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); useEffect(() => { if (!open) return; setStep(0); setPickMode('archetype'); setArchetype(null); setSelectedServices([]); setPrefix('decky'); setCount(3); setMutate(true); setMutateEvery(30); setDeploying(false); setLog([]); setDeployErr(null); }, [open]); const effectiveArchetypeName = archetype?.name ?? (pickMode === 'services' && selectedServices.length ? 'custom services' : 'linux-server'); const effectiveServices = pickMode === 'archetype' ? (archetype?.services ?? []) : selectedServices; // 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); const ini = _buildIni( prefix, count, fleetSize, pickMode, archetype, selectedServices, mutate, mutateEvery, ); 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))} />