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:
@@ -11,7 +11,6 @@ import Config from './components/Config';
|
|||||||
import Bounty from './components/Bounty';
|
import Bounty from './components/Bounty';
|
||||||
import RemoteUpdates from './components/RemoteUpdates';
|
import RemoteUpdates from './components/RemoteUpdates';
|
||||||
import SwarmHosts from './components/SwarmHosts';
|
import SwarmHosts from './components/SwarmHosts';
|
||||||
import SwarmDeckies from './components/SwarmDeckies';
|
|
||||||
import AgentEnrollment from './components/AgentEnrollment';
|
import AgentEnrollment from './components/AgentEnrollment';
|
||||||
|
|
||||||
function isTokenValid(token: string): boolean {
|
function isTokenValid(token: string): boolean {
|
||||||
@@ -70,7 +69,6 @@ function App() {
|
|||||||
<Route path="/config" element={<Config />} />
|
<Route path="/config" element={<Config />} />
|
||||||
<Route path="/swarm-updates" element={<RemoteUpdates />} />
|
<Route path="/swarm-updates" element={<RemoteUpdates />} />
|
||||||
<Route path="/swarm/hosts" element={<SwarmHosts />} />
|
<Route path="/swarm/hosts" element={<SwarmHosts />} />
|
||||||
<Route path="/swarm/deckies" element={<SwarmDeckies />} />
|
|
||||||
<Route path="/swarm/enroll" element={<AgentEnrollment />} />
|
<Route path="/swarm/enroll" element={<AgentEnrollment />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import api from '../utils/api';
|
import api from '../utils/api';
|
||||||
import './Dashboard.css'; // Re-use common dashboard styles
|
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 {
|
interface Decky {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -13,8 +23,43 @@ interface Decky {
|
|||||||
service_config: Record<string, Record<string, any>>;
|
service_config: Record<string, Record<string, any>>;
|
||||||
mutate_interval: number | null;
|
mutate_interval: number | null;
|
||||||
last_mutated: number;
|
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 DeckyFleet: React.FC = () => {
|
||||||
const [deckies, setDeckies] = useState<Decky[]>([]);
|
const [deckies, setDeckies] = useState<Decky[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -24,11 +69,47 @@ const DeckyFleet: React.FC = () => {
|
|||||||
const [deploying, setDeploying] = useState(false);
|
const [deploying, setDeploying] = useState(false);
|
||||||
const [isAdmin, setIsAdmin] = useState(false);
|
const [isAdmin, setIsAdmin] = useState(false);
|
||||||
const [deployMode, setDeployMode] = useState<{ mode: string; swarm_host_count: number } | null>(null);
|
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 {
|
try {
|
||||||
const _res = await api.get('/deckies');
|
if (mode === 'swarm') {
|
||||||
setDeckies(_res.data);
|
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) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch decky fleet', err);
|
console.error('Failed to fetch decky fleet', err);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -49,7 +130,7 @@ const DeckyFleet: React.FC = () => {
|
|||||||
setMutating(name);
|
setMutating(name);
|
||||||
try {
|
try {
|
||||||
await api.post(`/deckies/${name}/mutate`, {}, { timeout: 120000 });
|
await api.post(`/deckies/${name}/mutate`, {}, { timeout: 120000 });
|
||||||
await fetchDeckies();
|
await fetchDeckies(deployMode?.mode);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Failed to mutate', err);
|
console.error('Failed to mutate', err);
|
||||||
if (err.code === 'ECONNABORTED') {
|
if (err.code === 'ECONNABORTED') {
|
||||||
@@ -68,13 +149,33 @@ const DeckyFleet: React.FC = () => {
|
|||||||
const mutate_interval = _val.trim() === '' ? null : parseInt(_val);
|
const mutate_interval = _val.trim() === '' ? null : parseInt(_val);
|
||||||
try {
|
try {
|
||||||
await api.put(`/deckies/${name}/mutate-interval`, { mutate_interval });
|
await api.put(`/deckies/${name}/mutate-interval`, { mutate_interval });
|
||||||
fetchDeckies();
|
fetchDeckies(deployMode?.mode);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to update interval', err);
|
console.error('Failed to update interval', err);
|
||||||
alert('Update failed');
|
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 () => {
|
const handleDeploy = async () => {
|
||||||
if (!iniContent.trim()) return;
|
if (!iniContent.trim()) return;
|
||||||
setDeploying(true);
|
setDeploying(true);
|
||||||
@@ -82,7 +183,7 @@ const DeckyFleet: React.FC = () => {
|
|||||||
await api.post('/deckies/deploy', { ini_content: iniContent }, { timeout: 120000 });
|
await api.post('/deckies/deploy', { ini_content: iniContent }, { timeout: 120000 });
|
||||||
setIniContent('');
|
setIniContent('');
|
||||||
setShowDeploy(false);
|
setShowDeploy(false);
|
||||||
fetchDeckies();
|
fetchDeckies(deployMode?.mode);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Deploy failed', err);
|
console.error('Deploy failed', err);
|
||||||
alert(`Deploy failed: ${err.response?.data?.detail || err.message}`);
|
alert(`Deploy failed: ${err.response?.data?.detail || err.message}`);
|
||||||
@@ -106,28 +207,47 @@ const DeckyFleet: React.FC = () => {
|
|||||||
const fetchDeployMode = async () => {
|
const fetchDeployMode = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await api.get('/system/deployment-mode');
|
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 {
|
} catch {
|
||||||
setDeployMode(null);
|
setDeployMode(null);
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchDeckies();
|
let cancelled = false;
|
||||||
fetchRole();
|
(async () => {
|
||||||
fetchDeployMode();
|
const mode = await fetchDeployMode();
|
||||||
const _interval = setInterval(fetchDeckies, 10000); // Fleet state updates less frequently than logs
|
if (cancelled) return;
|
||||||
return () => clearInterval(_interval);
|
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>;
|
if (loading) return <div className="loader">SCANNING NETWORK FOR DECOYS...</div>;
|
||||||
|
|
||||||
|
const isSwarm = deployMode?.mode === 'swarm';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="dashboard">
|
<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 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' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||||
<Server size={20} />
|
<Server size={20} />
|
||||||
<h2 style={{ margin: 0 }}>DECOY FLEET ASSET INVENTORY</h2>
|
<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>
|
</div>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<button
|
<button
|
||||||
@@ -194,113 +314,174 @@ const DeckyFleet: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="deckies-grid" style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(350px, 1fr))', gap: '24px' }}>
|
<div className="deckies-grid" style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(350px, 1fr))', gap: '24px' }}>
|
||||||
{deckies.length > 0 ? deckies.map(decky => (
|
{deckies.length > 0 ? deckies.map(decky => {
|
||||||
<div key={decky.name} className="stat-card" style={{ flexDirection: 'column', alignItems: 'flex-start', gap: '16px', padding: '24px' }}>
|
const tdKey = decky.swarm ? `td:${decky.swarm.host_uuid}:${decky.name}` : '';
|
||||||
<div style={{ width: '100%', display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderBottom: '1px solid var(--border-color)', paddingBottom: '12px' }}>
|
const tdBusy = tearingDown.has(decky.name) || decky.swarm?.state === 'tearing_down';
|
||||||
<span className="matrix-text" style={{ fontSize: '1.2rem', fontWeight: 'bold' }}>{decky.name}</span>
|
return (
|
||||||
<span className="dim" style={{ fontSize: '0.8rem', backgroundColor: 'rgba(0, 255, 65, 0.1)', padding: '2px 8px', borderRadius: '4px' }}>{decky.ip}</span>
|
<div key={decky.name} className="stat-card" style={{ flexDirection: 'column', alignItems: 'flex-start', gap: '16px', padding: '24px' }}>
|
||||||
</div>
|
<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%' }}>
|
{decky.swarm && (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.85rem' }}>
|
<div style={{ width: '100%', display: 'flex', alignItems: 'center', gap: '10px', flexWrap: 'wrap', fontSize: '0.8rem' }}>
|
||||||
<Cpu size={14} className="dim" />
|
<span style={{ display: 'flex', alignItems: 'center', gap: '6px', border: '1px solid var(--border-color)', padding: '2px 8px', borderRadius: '2px' }}>
|
||||||
<span className="dim">HOSTNAME:</span> {decky.hostname}
|
<Network size={12} className="dim" />
|
||||||
</div>
|
<span className="dim">{decky.swarm.host_name}</span>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.85rem' }}>
|
<span style={{ color: 'var(--dim-color)' }}>@ {decky.swarm.host_address || '—'}</span>
|
||||||
<Globe size={14} className="dim" />
|
</span>
|
||||||
<span className="dim">DISTRO:</span> {decky.distro}
|
<span style={{
|
||||||
</div>
|
padding: '2px 8px', borderRadius: '2px',
|
||||||
{decky.archetype && (
|
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' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.85rem' }}>
|
||||||
<Database size={14} className="dim" />
|
<Cpu size={14} className="dim" />
|
||||||
<span className="dim">ARCHETYPE:</span> <span style={{ color: 'var(--highlight-color)' }}>{decky.archetype}</span>
|
<span className="dim">HOSTNAME:</span> {decky.hostname}
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.85rem' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.85rem', marginTop: '8px' }}>
|
<Globe size={14} className="dim" />
|
||||||
<Clock size={14} className="dim" />
|
<span className="dim">DISTRO:</span> {decky.distro}
|
||||||
<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>
|
||||||
)}
|
{decky.archetype && (
|
||||||
</div>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.85rem' }}>
|
||||||
|
<Database size={14} className="dim" />
|
||||||
<div style={{ width: '100%' }}>
|
<span className="dim">ARCHETYPE:</span> <span style={{ color: 'var(--highlight-color)' }}>{decky.archetype}</span>
|
||||||
<div className="dim" style={{ fontSize: '0.7rem', marginBottom: '8px', letterSpacing: '1px' }}>EXPOSED SERVICES:</div>
|
</div>
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
)}
|
||||||
{decky.services.map(svc => {
|
{/* Mutate controls are unihost-only for v1 — swarm-side mutation
|
||||||
const _config = decky.service_config[svc];
|
belongs in a separate ticket (the worker /mutate endpoint
|
||||||
return (
|
still returns 501). */}
|
||||||
<div key={svc} className="service-tag-container" style={{ position: 'relative' }}>
|
{!decky.swarm && (
|
||||||
<span className="service-tag" style={{
|
<>
|
||||||
display: 'inline-block',
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.85rem', marginTop: '8px' }}>
|
||||||
padding: '4px 10px',
|
<Clock size={14} className="dim" />
|
||||||
fontSize: '0.75rem',
|
<span className="dim">MUTATION:</span>
|
||||||
backgroundColor: 'var(--bg-color)',
|
{isAdmin ? (
|
||||||
border: '1px solid var(--accent-color)',
|
<span
|
||||||
color: 'var(--accent-color)',
|
style={{ color: 'var(--accent-color)', cursor: 'pointer', textDecoration: 'underline' }}
|
||||||
borderRadius: '2px',
|
onClick={() => handleIntervalChange(decky.name, decky.mutate_interval)}
|
||||||
cursor: 'help'
|
>
|
||||||
}}>
|
{decky.mutate_interval ? `EVERY ${decky.mutate_interval}m` : 'DISABLED'}
|
||||||
{svc}
|
</span>
|
||||||
</span>
|
) : (
|
||||||
{_config && Object.keys(_config).length > 0 && (
|
<span style={{ color: 'var(--accent-color)' }}>
|
||||||
<div className="service-config-tooltip" style={{
|
{decky.mutate_interval ? `EVERY ${decky.mutate_interval}m` : 'DISABLED'}
|
||||||
display: 'none',
|
</span>
|
||||||
position: 'absolute',
|
)}
|
||||||
bottom: '100%',
|
{isAdmin && (
|
||||||
left: '0',
|
<button
|
||||||
backgroundColor: 'rgba(10, 10, 10, 0.95)',
|
onClick={() => handleMutate(decky.name)}
|
||||||
border: '1px solid var(--accent-color)',
|
disabled={!!mutating}
|
||||||
padding: '12px',
|
style={{
|
||||||
zIndex: 100,
|
background: 'transparent', border: '1px solid var(--accent-color)',
|
||||||
minWidth: '200px',
|
color: 'var(--accent-color)', padding: '2px 8px', fontSize: '0.7rem',
|
||||||
boxShadow: '0 0 15px rgba(0, 255, 65, 0.2)',
|
cursor: mutating ? 'not-allowed' : 'pointer', display: 'flex', alignItems: 'center', gap: '4px', marginLeft: 'auto',
|
||||||
marginBottom: '8px'
|
opacity: mutating ? 0.5 : 1
|
||||||
}}>
|
}}
|
||||||
{Object.entries(_config).map(([k, v]) => (
|
>
|
||||||
<div key={k} style={{ fontSize: '0.7rem', marginBottom: '4px' }}>
|
<RefreshCw size={10} className={mutating === decky.name ? "spin" : ""} /> {mutating === decky.name ? 'MUTATING...' : 'FORCE'}
|
||||||
<span style={{ color: 'var(--highlight-color)', fontWeight: 'bold' }}>{k}:</span>
|
</button>
|
||||||
<span style={{ marginLeft: '6px', opacity: 0.9 }}>{String(v)}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
|
<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>
|
);
|
||||||
)) : (
|
}) : (
|
||||||
<div className="stat-card" style={{ gridColumn: '1 / -1', justifyContent: 'center', padding: '60px' }}>
|
<div className="stat-card" style={{ gridColumn: '1 / -1', justifyContent: 'center', padding: '60px' }}>
|
||||||
<span className="dim">NO DECOYS CURRENTLY DEPLOYED IN THIS SECTOR</span>
|
<span className="dim">NO DECOYS CURRENTLY DEPLOYED IN THIS SECTOR</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { NavLink } from 'react-router-dom';
|
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';
|
import './Layout.css';
|
||||||
|
|
||||||
interface LayoutProps {
|
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} />
|
<NavItem to="/attackers" icon={<Activity size={20} />} label="Attackers" open={sidebarOpen} />
|
||||||
<NavGroup label="SWARM" icon={<Network size={20} />} 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/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-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 />
|
<NavItem to="/swarm/enroll" icon={<UserPlus size={18} />} label="Agent Enrollment" open={sidebarOpen} indent />
|
||||||
</NavGroup>
|
</NavGroup>
|
||||||
|
|||||||
@@ -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;
|
|
||||||
Reference in New Issue
Block a user