diff --git a/decnet_web/src/App.tsx b/decnet_web/src/App.tsx index 5e856e0..50bdefc 100644 --- a/decnet_web/src/App.tsx +++ b/decnet_web/src/App.tsx @@ -11,7 +11,6 @@ import Config from './components/Config'; import Bounty from './components/Bounty'; import RemoteUpdates from './components/RemoteUpdates'; import SwarmHosts from './components/SwarmHosts'; -import SwarmDeckies from './components/SwarmDeckies'; import AgentEnrollment from './components/AgentEnrollment'; function isTokenValid(token: string): boolean { @@ -70,7 +69,6 @@ function App() { } /> } /> } /> - } /> } /> } /> diff --git a/decnet_web/src/components/DeckyFleet.tsx b/decnet_web/src/components/DeckyFleet.tsx index 68d76bd..41521a0 100644 --- a/decnet_web/src/components/DeckyFleet.tsx +++ b/decnet_web/src/components/DeckyFleet.tsx @@ -1,7 +1,17 @@ import React, { useEffect, useState } from 'react'; import api from '../utils/api'; import './Dashboard.css'; // Re-use common dashboard styles -import { Server, Cpu, Globe, Database, Clock, RefreshCw, Upload } from 'lucide-react'; +import { Server, Cpu, Globe, Database, Clock, RefreshCw, Upload, Network, PowerOff } from 'lucide-react'; + +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; @@ -13,8 +23,43 @@ interface Decky { service_config: Record>; mutate_interval: number | null; last_mutated: number; + swarm?: SwarmMeta; } +// Raw shape returned by /swarm/deckies (DeckyShardView on the backend). +// Pre-heartbeat rows have nullable metadata fields; we coerce to the +// shared Decky interface so the card grid renders uniformly either way. +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; +} + +const _stateColor = (state: string): string => { + switch (state) { + case 'running': return 'var(--accent-color)'; + case 'degraded': return '#f39c12'; + case 'tearing_down': return '#f39c12'; + case 'pending': return 'var(--dim-color)'; + case 'failed': + case 'teardown_failed': return '#e74c3c'; + default: return 'var(--dim-color)'; + } +}; + const DeckyFleet: React.FC = () => { const [deckies, setDeckies] = useState([]); const [loading, setLoading] = useState(true); @@ -24,11 +69,47 @@ const DeckyFleet: React.FC = () => { const [deploying, setDeploying] = useState(false); const [isAdmin, setIsAdmin] = useState(false); const [deployMode, setDeployMode] = useState<{ mode: string; swarm_host_count: number } | null>(null); + // Two-click arm/commit for teardown — lifted from the old SwarmDeckies + // component. browsers silently suppress window.confirm() after the user + // opts out of further dialogs, so we gate destructive actions with a + // 4-second "click again" window instead. + const [armed, setArmed] = useState(null); + const [tearingDown, setTearingDown] = useState>(new Set()); - const fetchDeckies = async () => { + const arm = (key: string) => { + setArmed(key); + setTimeout(() => setArmed((prev) => (prev === key ? null : prev)), 4000); + }; + + const fetchDeckies = async (mode?: string) => { try { - const _res = await api.get('/deckies'); - setDeckies(_res.data); + 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 { @@ -49,7 +130,7 @@ const DeckyFleet: React.FC = () => { setMutating(name); try { await api.post(`/deckies/${name}/mutate`, {}, { timeout: 120000 }); - await fetchDeckies(); + await fetchDeckies(deployMode?.mode); } catch (err: any) { console.error('Failed to mutate', err); if (err.code === 'ECONNABORTED') { @@ -68,13 +149,33 @@ const DeckyFleet: React.FC = () => { const mutate_interval = _val.trim() === '' ? null : parseInt(_val); try { await api.put(`/deckies/${name}/mutate-interval`, { mutate_interval }); - fetchDeckies(); + fetchDeckies(deployMode?.mode); } catch (err) { console.error('Failed to update interval', err); alert('Update failed'); } }; + 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); + } catch (err: any) { + alert(err?.response?.data?.detail || 'Teardown failed'); + } finally { + setTearingDown((prev) => { + const next = new Set(prev); + next.delete(d.name); + return next; + }); + } + }; + const handleDeploy = async () => { if (!iniContent.trim()) return; setDeploying(true); @@ -82,7 +183,7 @@ const DeckyFleet: React.FC = () => { await api.post('/deckies/deploy', { ini_content: iniContent }, { timeout: 120000 }); setIniContent(''); setShowDeploy(false); - fetchDeckies(); + fetchDeckies(deployMode?.mode); } catch (err: any) { console.error('Deploy failed', err); alert(`Deploy failed: ${err.response?.data?.detail || err.message}`); @@ -106,28 +207,47 @@ const DeckyFleet: React.FC = () => { 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 }); + const mode = res.data.mode; + setDeployMode({ mode, swarm_host_count: res.data.swarm_host_count }); + return mode; } catch { setDeployMode(null); + return undefined; } }; useEffect(() => { - fetchDeckies(); - fetchRole(); - fetchDeployMode(); - const _interval = setInterval(fetchDeckies, 10000); // Fleet state updates less frequently than logs - return () => clearInterval(_interval); + let cancelled = false; + (async () => { + const mode = await fetchDeployMode(); + if (cancelled) return; + await fetchDeckies(mode); + await fetchRole(); + })(); + // Keep the poll mode-aware by reading from the deployMode ref at tick time. + const _interval = setInterval(() => { + // Deployment mode itself can change (first host enrolls → swarm), so + // re-check it alongside the fleet. + fetchDeployMode().then((m) => fetchDeckies(m)); + }, 10000); + return () => { cancelled = true; clearInterval(_interval); }; }, []); if (loading) return
SCANNING NETWORK FOR DECOYS...
; + const isSwarm = deployMode?.mode === 'swarm'; + return (

DECOY FLEET ASSET INVENTORY

+ {deployMode && ( + + [{isSwarm ? `SWARM × ${deployMode.swarm_host_count}` : 'UNIHOST'}] + + )}
{isAdmin && (
-