feat(web-ui): unify SwarmDeckies into DeckyFleet with swarm card mode

DeckyFleet now branches on /system/deployment-mode: in swarm mode it
pulls /swarm/deckies and normalises DeckyShardView into the shared
Decky shape so the same card grid renders either way. Swarm cards gain
a host badge (host_name @ address), a state pill (running/degraded/
tearing_down/failed/teardown_failed with matching colors), an inline
last_error snippet, and a two-click arm/commit Teardown button lifted
from the old SwarmDeckies component. Mutate + interval controls are
hidden in swarm mode since the worker /mutate endpoint still 501s —
swarm-side rotation is a separate ticket.

Drops the standalone /swarm/deckies route + nav entry; SwarmDeckies.tsx
is deleted. The SWARM nav group keeps SwarmHosts, Remote Updates, and
Agent Enrollment.
This commit is contained in:
2026-04-19 21:53:26 -04:00
parent bf01804736
commit 33d954a61c
4 changed files with 311 additions and 282 deletions

View File

@@ -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() {
<Route path="/config" element={<Config />} />
<Route path="/swarm-updates" element={<RemoteUpdates />} />
<Route path="/swarm/hosts" element={<SwarmHosts />} />
<Route path="/swarm/deckies" element={<SwarmDeckies />} />
<Route path="/swarm/enroll" element={<AgentEnrollment />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>

View File

@@ -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<string, Record<string, any>>;
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<string, Record<string, any>>;
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<Decky[]>([]);
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<string | null>(null);
const [tearingDown, setTearingDown] = useState<Set<string>>(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<SwarmDeckyRaw[]>('/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 <div className="loader">SCANNING NETWORK FOR DECOYS...</div>;
const isSwarm = deployMode?.mode === 'swarm';
return (
<div className="dashboard">
<div className="section-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', border: '1px solid var(--border-color)', backgroundColor: 'var(--secondary-color)', marginBottom: '24px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<Server size={20} />
<h2 style={{ margin: 0 }}>DECOY FLEET ASSET INVENTORY</h2>
{deployMode && (
<span className="dim" style={{ fontSize: '0.75rem', marginLeft: 8 }}>
[{isSwarm ? `SWARM × ${deployMode.swarm_host_count}` : 'UNIHOST'}]
</span>
)}
</div>
{isAdmin && (
<button
@@ -153,21 +273,21 @@ const DeckyFleet: React.FC = () => {
)}
</h3>
<div>
<input
type="file"
id="ini-upload"
accept=".ini"
onChange={handleFileUpload}
style={{ display: 'none' }}
<input
type="file"
id="ini-upload"
accept=".ini"
onChange={handleFileUpload}
style={{ display: 'none' }}
/>
<label
htmlFor="ini-upload"
style={{
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '8px',
fontSize: '0.8rem',
<label
htmlFor="ini-upload"
style={{
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '8px',
fontSize: '0.8rem',
color: 'var(--accent-color)',
border: '1px solid var(--accent-color)',
padding: '4px 12px'
@@ -177,7 +297,7 @@ const DeckyFleet: React.FC = () => {
</label>
</div>
</div>
<textarea
<textarea
value={iniContent}
onChange={(e) => setIniContent(e.target.value)}
placeholder="[decky-01]&#10;archetype=linux-server&#10;services=ssh,http"
@@ -194,113 +314,174 @@ const DeckyFleet: React.FC = () => {
)}
<div className="deckies-grid" style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(350px, 1fr))', gap: '24px' }}>
{deckies.length > 0 ? deckies.map(decky => (
<div key={decky.name} className="stat-card" style={{ flexDirection: 'column', alignItems: 'flex-start', gap: '16px', padding: '24px' }}>
<div style={{ width: '100%', display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderBottom: '1px solid var(--border-color)', paddingBottom: '12px' }}>
<span className="matrix-text" style={{ fontSize: '1.2rem', fontWeight: 'bold' }}>{decky.name}</span>
<span className="dim" style={{ fontSize: '0.8rem', backgroundColor: 'rgba(0, 255, 65, 0.1)', padding: '2px 8px', borderRadius: '4px' }}>{decky.ip}</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', width: '100%' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.85rem' }}>
<Cpu size={14} className="dim" />
<span className="dim">HOSTNAME:</span> {decky.hostname}
{deckies.length > 0 ? deckies.map(decky => {
const tdKey = decky.swarm ? `td:${decky.swarm.host_uuid}:${decky.name}` : '';
const tdBusy = tearingDown.has(decky.name) || decky.swarm?.state === 'tearing_down';
return (
<div key={decky.name} className="stat-card" style={{ flexDirection: 'column', alignItems: 'flex-start', gap: '16px', padding: '24px' }}>
<div style={{ width: '100%', display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderBottom: '1px solid var(--border-color)', paddingBottom: '12px' }}>
<span className="matrix-text" style={{ fontSize: '1.2rem', fontWeight: 'bold' }}>{decky.name}</span>
<span className="dim" style={{ fontSize: '0.8rem', backgroundColor: 'rgba(0, 255, 65, 0.1)', padding: '2px 8px', borderRadius: '4px' }}>{decky.ip}</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.85rem' }}>
<Globe size={14} className="dim" />
<span className="dim">DISTRO:</span> {decky.distro}
</div>
{decky.archetype && (
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.85rem' }}>
<Database size={14} className="dim" />
<span className="dim">ARCHETYPE:</span> <span style={{ color: 'var(--highlight-color)' }}>{decky.archetype}</span>
</div>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.85rem', marginTop: '8px' }}>
<Clock size={14} className="dim" />
<span className="dim">MUTATION:</span>
{isAdmin ? (
<span
style={{ color: 'var(--accent-color)', cursor: 'pointer', textDecoration: 'underline' }}
onClick={() => handleIntervalChange(decky.name, decky.mutate_interval)}
>
{decky.mutate_interval ? `EVERY ${decky.mutate_interval}m` : 'DISABLED'}
</span>
) : (
<span style={{ color: 'var(--accent-color)' }}>
{decky.mutate_interval ? `EVERY ${decky.mutate_interval}m` : 'DISABLED'}
</span>
)}
{isAdmin && (
<button
onClick={() => handleMutate(decky.name)}
disabled={!!mutating}
style={{
background: 'transparent', border: '1px solid var(--accent-color)',
color: 'var(--accent-color)', padding: '2px 8px', fontSize: '0.7rem',
cursor: mutating ? 'not-allowed' : 'pointer', display: 'flex', alignItems: 'center', gap: '4px', marginLeft: 'auto',
opacity: mutating ? 0.5 : 1
}}
>
<RefreshCw size={10} className={mutating === decky.name ? "spin" : ""} /> {mutating === decky.name ? 'MUTATING...' : 'FORCE'}
</button>
)}
</div>
{decky.last_mutated > 0 && (
<div style={{ fontSize: '0.7rem', color: 'var(--dim-color)', fontStyle: 'italic', marginTop: '4px' }}>
Last mutated: {new Date(decky.last_mutated * 1000).toLocaleString()}
</div>
)}
</div>
<div style={{ width: '100%' }}>
<div className="dim" style={{ fontSize: '0.7rem', marginBottom: '8px', letterSpacing: '1px' }}>EXPOSED SERVICES:</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
{decky.services.map(svc => {
const _config = decky.service_config[svc];
return (
<div key={svc} className="service-tag-container" style={{ position: 'relative' }}>
<span className="service-tag" style={{
display: 'inline-block',
padding: '4px 10px',
fontSize: '0.75rem',
backgroundColor: 'var(--bg-color)',
border: '1px solid var(--accent-color)',
color: 'var(--accent-color)',
borderRadius: '2px',
cursor: 'help'
}}>
{svc}
</span>
{_config && Object.keys(_config).length > 0 && (
<div className="service-config-tooltip" style={{
display: 'none',
position: 'absolute',
bottom: '100%',
left: '0',
backgroundColor: 'rgba(10, 10, 10, 0.95)',
border: '1px solid var(--accent-color)',
padding: '12px',
zIndex: 100,
minWidth: '200px',
boxShadow: '0 0 15px rgba(0, 255, 65, 0.2)',
marginBottom: '8px'
}}>
{Object.entries(_config).map(([k, v]) => (
<div key={k} style={{ fontSize: '0.7rem', marginBottom: '4px' }}>
<span style={{ color: 'var(--highlight-color)', fontWeight: 'bold' }}>{k}:</span>
<span style={{ marginLeft: '6px', opacity: 0.9 }}>{String(v)}</span>
</div>
))}
</div>
{decky.swarm && (
<div style={{ width: '100%', display: 'flex', alignItems: 'center', gap: '10px', flexWrap: 'wrap', fontSize: '0.8rem' }}>
<span style={{ display: 'flex', alignItems: 'center', gap: '6px', border: '1px solid var(--border-color)', padding: '2px 8px', borderRadius: '2px' }}>
<Network size={12} className="dim" />
<span className="dim">{decky.swarm.host_name}</span>
<span style={{ color: 'var(--dim-color)' }}>@ {decky.swarm.host_address || '—'}</span>
</span>
<span style={{
padding: '2px 8px', borderRadius: '2px',
border: `1px solid ${_stateColor(decky.swarm.state)}`,
color: _stateColor(decky.swarm.state),
fontSize: '0.7rem', letterSpacing: '1px',
}}>
{decky.swarm.state.toUpperCase()}
</span>
{decky.swarm.last_error && (
<span style={{ color: '#e74c3c', fontSize: '0.7rem' }} title={decky.swarm.last_error}>
{decky.swarm.last_error.slice(0, 60)}{decky.swarm.last_error.length > 60 ? '…' : ''}
</span>
)}
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', width: '100%' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.85rem' }}>
<Cpu size={14} className="dim" />
<span className="dim">HOSTNAME:</span> {decky.hostname}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.85rem' }}>
<Globe size={14} className="dim" />
<span className="dim">DISTRO:</span> {decky.distro}
</div>
{decky.archetype && (
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.85rem' }}>
<Database size={14} className="dim" />
<span className="dim">ARCHETYPE:</span> <span style={{ color: 'var(--highlight-color)' }}>{decky.archetype}</span>
</div>
)}
{/* Mutate controls are unihost-only for v1 — swarm-side mutation
belongs in a separate ticket (the worker /mutate endpoint
still returns 501). */}
{!decky.swarm && (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.85rem', marginTop: '8px' }}>
<Clock size={14} className="dim" />
<span className="dim">MUTATION:</span>
{isAdmin ? (
<span
style={{ color: 'var(--accent-color)', cursor: 'pointer', textDecoration: 'underline' }}
onClick={() => handleIntervalChange(decky.name, decky.mutate_interval)}
>
{decky.mutate_interval ? `EVERY ${decky.mutate_interval}m` : 'DISABLED'}
</span>
) : (
<span style={{ color: 'var(--accent-color)' }}>
{decky.mutate_interval ? `EVERY ${decky.mutate_interval}m` : 'DISABLED'}
</span>
)}
{isAdmin && (
<button
onClick={() => handleMutate(decky.name)}
disabled={!!mutating}
style={{
background: 'transparent', border: '1px solid var(--accent-color)',
color: 'var(--accent-color)', padding: '2px 8px', fontSize: '0.7rem',
cursor: mutating ? 'not-allowed' : 'pointer', display: 'flex', alignItems: 'center', gap: '4px', marginLeft: 'auto',
opacity: mutating ? 0.5 : 1
}}
>
<RefreshCw size={10} className={mutating === decky.name ? "spin" : ""} /> {mutating === decky.name ? 'MUTATING...' : 'FORCE'}
</button>
)}
</div>
);
})}
{decky.last_mutated > 0 && (
<div style={{ fontSize: '0.7rem', color: 'var(--dim-color)', fontStyle: 'italic', marginTop: '4px' }}>
Last mutated: {new Date(decky.last_mutated * 1000).toLocaleString()}
</div>
)}
</>
)}
</div>
<div style={{ width: '100%' }}>
<div className="dim" style={{ fontSize: '0.7rem', marginBottom: '8px', letterSpacing: '1px' }}>EXPOSED SERVICES:</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
{decky.services.map(svc => {
const _config = decky.service_config[svc];
return (
<div key={svc} className="service-tag-container" style={{ position: 'relative' }}>
<span className="service-tag" style={{
display: 'inline-block',
padding: '4px 10px',
fontSize: '0.75rem',
backgroundColor: 'var(--bg-color)',
border: '1px solid var(--accent-color)',
color: 'var(--accent-color)',
borderRadius: '2px',
cursor: 'help'
}}>
{svc}
</span>
{_config && Object.keys(_config).length > 0 && (
<div className="service-config-tooltip" style={{
display: 'none',
position: 'absolute',
bottom: '100%',
left: '0',
backgroundColor: 'rgba(10, 10, 10, 0.95)',
border: '1px solid var(--accent-color)',
padding: '12px',
zIndex: 100,
minWidth: '200px',
boxShadow: '0 0 15px rgba(0, 255, 65, 0.2)',
marginBottom: '8px'
}}>
{Object.entries(_config).map(([k, v]) => (
<div key={k} style={{ fontSize: '0.7rem', marginBottom: '4px' }}>
<span style={{ color: 'var(--highlight-color)', fontWeight: 'bold' }}>{k}:</span>
<span style={{ marginLeft: '6px', opacity: 0.9 }}>{String(v)}</span>
</div>
))}
</div>
)}
</div>
);
})}
</div>
</div>
{decky.swarm && isAdmin && (
<div style={{ width: '100%', display: 'flex', justifyContent: 'flex-end', borderTop: '1px solid var(--border-color)', paddingTop: '12px' }}>
<button
onClick={() => handleTeardown(decky)}
disabled={tdBusy}
style={{
background: 'transparent',
border: '1px solid #e74c3c',
color: '#e74c3c',
padding: '4px 12px',
fontSize: '0.75rem',
display: 'flex', alignItems: 'center', gap: '6px',
cursor: tdBusy ? 'not-allowed' : 'pointer',
opacity: tdBusy ? 0.5 : 1,
}}
title="Stop this decky on its host"
>
<PowerOff size={12} />
{tdBusy
? 'TEARING DOWN…'
: armed === tdKey
? 'CLICK AGAIN TO CONFIRM'
: 'TEARDOWN'}
</button>
</div>
)}
</div>
</div>
)) : (
);
}) : (
<div className="stat-card" style={{ gridColumn: '1 / -1', justifyContent: 'center', padding: '60px' }}>
<span className="dim">NO DECOYS CURRENTLY DEPLOYED IN THIS SECTOR</span>
</div>

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react';
import { NavLink } from 'react-router-dom';
import { Menu, X, Search, Activity, LayoutDashboard, Terminal, Settings, LogOut, Server, Archive, Package, Network, ChevronDown, ChevronRight, HardDrive, Boxes, UserPlus } from 'lucide-react';
import { Menu, X, Search, Activity, LayoutDashboard, Terminal, Settings, LogOut, Server, Archive, Package, Network, ChevronDown, ChevronRight, HardDrive, UserPlus } from 'lucide-react';
import './Layout.css';
interface LayoutProps {
@@ -48,7 +48,6 @@ const Layout: React.FC<LayoutProps> = ({ children, onLogout, onSearch }) => {
<NavItem to="/attackers" icon={<Activity size={20} />} label="Attackers" open={sidebarOpen} />
<NavGroup label="SWARM" icon={<Network size={20} />} open={sidebarOpen}>
<NavItem to="/swarm/hosts" icon={<HardDrive size={18} />} label="SWARM Hosts" open={sidebarOpen} indent />
<NavItem to="/swarm/deckies" icon={<Boxes size={18} />} label="SWARM Deckies" open={sidebarOpen} indent />
<NavItem to="/swarm-updates" icon={<Package size={18} />} label="Remote Updates" open={sidebarOpen} indent />
<NavItem to="/swarm/enroll" icon={<UserPlus size={18} />} label="Agent Enrollment" open={sidebarOpen} indent />
</NavGroup>

View File

@@ -1,149 +0,0 @@
import React, { useEffect, useState } from 'react';
import api from '../utils/api';
import './Dashboard.css';
import './Swarm.css';
import { Boxes, PowerOff, RefreshCw } from 'lucide-react';
interface DeckyShard {
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;
compose_hash: string | null;
updated_at: string;
}
const SwarmDeckies: React.FC = () => {
const [shards, setShards] = useState<DeckyShard[]>([]);
const [loading, setLoading] = useState(true);
const [tearingDown, setTearingDown] = useState<Set<string>>(new Set());
const [error, setError] = useState<string | null>(null);
// Two-click arm/commit replaces window.confirm() — browsers silently
// suppress confirm() after the "prevent additional dialogs" opt-out.
const [armed, setArmed] = useState<string | null>(null);
const arm = (key: string) => {
setArmed(key);
setTimeout(() => setArmed((prev) => (prev === key ? null : prev)), 4000);
};
const fetch = async () => {
try {
const res = await api.get('/swarm/deckies');
setShards(res.data);
setError(null);
} catch (err: any) {
setError(err?.response?.data?.detail || 'Failed to fetch swarm deckies');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetch();
const t = setInterval(fetch, 10000);
return () => clearInterval(t);
}, []);
const handleTeardown = async (s: DeckyShard) => {
const key = `td:${s.host_uuid}:${s.decky_name}`;
if (armed !== key) { arm(key); return; }
setArmed(null);
setTearingDown((prev) => new Set(prev).add(s.decky_name));
try {
// Endpoint returns 202 immediately; the actual teardown runs in the
// background on the backend. Shard state flips to 'tearing_down' and
// the 10s poll picks up the final state (gone on success, or
// 'teardown_failed' with an error).
await api.post(`/swarm/hosts/${s.host_uuid}/teardown`, { decky_id: s.decky_name });
await fetch();
} catch (err: any) {
alert(err?.response?.data?.detail || 'Teardown failed');
} finally {
setTearingDown((prev) => {
const next = new Set(prev);
next.delete(s.decky_name);
return next;
});
}
};
const byHost: Record<string, { name: string; address: string; status: string; shards: DeckyShard[] }> = {};
for (const s of shards) {
if (!byHost[s.host_uuid]) {
byHost[s.host_uuid] = { name: s.host_name, address: s.host_address, status: s.host_status, shards: [] };
}
byHost[s.host_uuid].shards.push(s);
}
return (
<div className="dashboard">
<div className="dashboard-header">
<h1><Boxes size={28} /> SWARM Deckies</h1>
<button onClick={fetch} className="control-btn" disabled={loading}>
<RefreshCw size={16} /> Refresh
</button>
</div>
{error && <div className="error-box">{error}</div>}
{loading ? (
<p>Loading deckies</p>
) : shards.length === 0 ? (
<div className="panel">
<p>No deckies deployed to swarm workers yet.</p>
</div>
) : (
Object.entries(byHost).map(([uuid, h]) => (
<div key={uuid} className="panel">
<h3>{h.name} <small>({h.address}) {h.status}</small></h3>
<table className="data-table">
<thead>
<tr>
<th>Decky</th>
<th>IP</th>
<th>State</th>
<th>Services</th>
<th>Updated</th>
<th></th>
</tr>
</thead>
<tbody>
{h.shards.map((s) => (
<tr key={`${uuid}-${s.decky_name}`}>
<td>{s.decky_name}</td>
<td><code>{s.decky_ip || '—'}</code></td>
<td>{s.state}{s.last_error ? `${s.last_error}` : ''}</td>
<td>{s.services.join(', ')}</td>
<td>{new Date(s.updated_at).toLocaleString()}</td>
<td>
<button
className="control-btn danger"
disabled={tearingDown.has(s.decky_name) || s.state === 'tearing_down'}
onClick={() => handleTeardown(s)}
title="Stop this decky on its host"
>
<PowerOff size={14} />{' '}
{tearingDown.has(s.decky_name) || s.state === 'tearing_down'
? 'Tearing down…'
: armed === `td:${s.host_uuid}:${s.decky_name}`
? 'Click again to confirm'
: 'Teardown'}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
))
)}
</div>
);
};
export default SwarmDeckies;