feat(web-ui): SWARM nav group + Hosts/Deckies/AgentEnrollment pages
This commit is contained in:
@@ -10,6 +10,9 @@ import AttackerDetail from './components/AttackerDetail';
|
||||
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 {
|
||||
try {
|
||||
@@ -66,6 +69,9 @@ function App() {
|
||||
<Route path="/attackers/:id" element={<AttackerDetail />} />
|
||||
<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>
|
||||
</Layout>
|
||||
|
||||
164
decnet_web/src/components/AgentEnrollment.tsx
Normal file
164
decnet_web/src/components/AgentEnrollment.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import api from '../utils/api';
|
||||
import './Dashboard.css';
|
||||
import './Swarm.css';
|
||||
import { UserPlus, Copy, RotateCcw, Check, AlertTriangle } from 'lucide-react';
|
||||
|
||||
interface BundleResult {
|
||||
token: string;
|
||||
host_uuid: string;
|
||||
command: string;
|
||||
expires_at: string;
|
||||
}
|
||||
|
||||
const AgentEnrollment: React.FC = () => {
|
||||
const [masterHost, setMasterHost] = useState(window.location.hostname);
|
||||
const [agentName, setAgentName] = useState('');
|
||||
const [servicesIni, setServicesIni] = useState<string | null>(null);
|
||||
const [servicesIniName, setServicesIniName] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<BundleResult | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [now, setNow] = useState<number>(Date.now());
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const t = setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => clearInterval(t);
|
||||
}, []);
|
||||
|
||||
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (!f) {
|
||||
setServicesIni(null);
|
||||
setServicesIniName(null);
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
setServicesIni(String(reader.result));
|
||||
setServicesIniName(f.name);
|
||||
};
|
||||
reader.readAsText(f);
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setResult(null);
|
||||
setError(null);
|
||||
setAgentName('');
|
||||
setServicesIni(null);
|
||||
setServicesIniName(null);
|
||||
setCopied(false);
|
||||
if (fileRef.current) fileRef.current.value = '';
|
||||
};
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await api.post('/swarm/enroll-bundle', {
|
||||
master_host: masterHost,
|
||||
agent_name: agentName,
|
||||
services_ini: servicesIni,
|
||||
});
|
||||
setResult(res.data);
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.detail || 'Enrollment bundle creation failed');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyCmd = async () => {
|
||||
if (!result) return;
|
||||
await navigator.clipboard.writeText(result.command);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const nameOk = /^[a-z0-9][a-z0-9-]{0,62}$/.test(agentName);
|
||||
|
||||
const remainingSecs = result ? Math.max(0, Math.floor((new Date(result.expires_at).getTime() - now) / 1000)) : 0;
|
||||
const mm = Math.floor(remainingSecs / 60).toString().padStart(2, '0');
|
||||
const ss = (remainingSecs % 60).toString().padStart(2, '0');
|
||||
|
||||
return (
|
||||
<div className="dashboard">
|
||||
<div className="dashboard-header">
|
||||
<h1><UserPlus size={28} /> Agent Enrollment</h1>
|
||||
</div>
|
||||
|
||||
{!result ? (
|
||||
<div className="panel">
|
||||
<p>
|
||||
Generates a one-shot bootstrap URL valid for 5 minutes. Paste the command into a
|
||||
root shell on the target worker VM — no manual cert shuffling required.
|
||||
</p>
|
||||
<form onSubmit={submit} className="form-stack">
|
||||
<label>
|
||||
Master host (IP or DNS this agent can reach)
|
||||
<input
|
||||
type="text"
|
||||
value={masterHost}
|
||||
onChange={(e) => setMasterHost(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Agent name (lowercase, digits, dashes)
|
||||
<input
|
||||
type="text"
|
||||
value={agentName}
|
||||
onChange={(e) => setAgentName(e.target.value.toLowerCase())}
|
||||
pattern="^[a-z0-9][a-z0-9-]{0,62}$"
|
||||
required
|
||||
/>
|
||||
{agentName && !nameOk && (
|
||||
<small className="field-warn"><AlertTriangle size={12} /> must match ^[a-z0-9][a-z0-9-]{`{0,62}`}$</small>
|
||||
)}
|
||||
</label>
|
||||
<label>
|
||||
Services INI (optional)
|
||||
<input ref={fileRef} type="file" accept=".ini,.conf,.txt" onChange={handleFile} />
|
||||
{servicesIniName && <small>loaded: {servicesIniName}</small>}
|
||||
</label>
|
||||
{error && <div className="error-box">{error}</div>}
|
||||
<button
|
||||
type="submit"
|
||||
className="control-btn primary"
|
||||
disabled={submitting || !nameOk || !masterHost}
|
||||
>
|
||||
{submitting ? 'Generating…' : 'Generate enrollment bundle'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
) : (
|
||||
<div className="panel">
|
||||
<h3>Paste this on the new worker (as root):</h3>
|
||||
<pre className="code-block">{result.command}</pre>
|
||||
<div className="button-row">
|
||||
<button className="control-btn" onClick={copyCmd}>
|
||||
{copied ? <><Check size={14} /> Copied</> : <><Copy size={14} /> Copy</>}
|
||||
</button>
|
||||
<button className="control-btn" onClick={reset}>
|
||||
<RotateCcw size={14} /> Generate another
|
||||
</button>
|
||||
</div>
|
||||
<p>
|
||||
Expires in <strong>{mm}:{ss}</strong> — one-shot, single download. Host UUID:{' '}
|
||||
<code>{result.host_uuid}</code>
|
||||
</p>
|
||||
{remainingSecs === 0 && (
|
||||
<div className="error-box">
|
||||
<AlertTriangle size={14} /> This bundle has expired. Generate another.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AgentEnrollment;
|
||||
@@ -83,6 +83,44 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nav-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.nav-group-toggle {
|
||||
background: transparent;
|
||||
border: none;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.nav-group-toggle:hover {
|
||||
box-shadow: none;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.nav-group-chevron {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nav-group-children {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.nav-subitem {
|
||||
padding-left: 40px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 20px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
|
||||
@@ -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 } from 'lucide-react';
|
||||
import { Menu, X, Search, Activity, LayoutDashboard, Terminal, Settings, LogOut, Server, Archive, Package, Network, ChevronDown, ChevronRight, HardDrive, Boxes, UserPlus } from 'lucide-react';
|
||||
import './Layout.css';
|
||||
|
||||
interface LayoutProps {
|
||||
@@ -46,7 +46,12 @@ const Layout: React.FC<LayoutProps> = ({ children, onLogout, onSearch }) => {
|
||||
<NavItem to="/live-logs" icon={<Terminal size={20} />} label="Live Logs" open={sidebarOpen} />
|
||||
<NavItem to="/bounty" icon={<Archive size={20} />} label="Bounty" open={sidebarOpen} />
|
||||
<NavItem to="/attackers" icon={<Activity size={20} />} label="Attackers" open={sidebarOpen} />
|
||||
<NavItem to="/swarm-updates" icon={<Package size={20} />} label="Remote Updates" 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>
|
||||
<NavItem to="/config" icon={<Settings size={20} />} label="Config" open={sidebarOpen} />
|
||||
</nav>
|
||||
|
||||
@@ -92,13 +97,49 @@ interface NavItemProps {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
open: boolean;
|
||||
indent?: boolean;
|
||||
}
|
||||
|
||||
const NavItem: React.FC<NavItemProps> = ({ to, icon, label, open }) => (
|
||||
<NavLink to={to} className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`} end={to === '/'}>
|
||||
const NavItem: React.FC<NavItemProps> = ({ to, icon, label, open, indent }) => (
|
||||
<NavLink
|
||||
to={to}
|
||||
className={({ isActive }) => `nav-item ${isActive ? 'active' : ''} ${indent ? 'nav-subitem' : ''}`}
|
||||
end={to === '/'}
|
||||
>
|
||||
{icon}
|
||||
{open && <span className="nav-label">{label}</span>}
|
||||
</NavLink>
|
||||
);
|
||||
|
||||
interface NavGroupProps {
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
open: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const NavGroup: React.FC<NavGroupProps> = ({ label, icon, open, children }) => {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
return (
|
||||
<div className="nav-group">
|
||||
<button
|
||||
type="button"
|
||||
className="nav-item nav-group-toggle"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
>
|
||||
{icon}
|
||||
{open && (
|
||||
<>
|
||||
<span className="nav-label">{label}</span>
|
||||
<span className="nav-group-chevron">
|
||||
{expanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{expanded && <div className="nav-group-children">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
|
||||
159
decnet_web/src/components/Swarm.css
Normal file
159
decnet_web/src/components/Swarm.css
Normal file
@@ -0,0 +1,159 @@
|
||||
.dashboard-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.dashboard-header h1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 1.4rem;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background-color: var(--secondary-color);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.panel h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.panel h3 small {
|
||||
color: var(--accent-color);
|
||||
font-weight: normal;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.data-table th,
|
||||
.data-table td {
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
text-align: left;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
color: var(--accent-color);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.data-table code {
|
||||
font-family: monospace;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-color);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.control-btn:hover {
|
||||
border-color: var(--text-color);
|
||||
box-shadow: var(--matrix-green-glow);
|
||||
}
|
||||
|
||||
.control-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.control-btn.primary {
|
||||
border-color: var(--text-color);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.control-btn.danger {
|
||||
border-color: #ff4d4d;
|
||||
color: #ff4d4d;
|
||||
}
|
||||
|
||||
.control-btn.danger:hover {
|
||||
background-color: rgba(255, 77, 77, 0.1);
|
||||
box-shadow: 0 0 8px rgba(255, 77, 77, 0.4);
|
||||
}
|
||||
|
||||
.error-box {
|
||||
background-color: rgba(255, 77, 77, 0.08);
|
||||
border: 1px solid #ff4d4d;
|
||||
color: #ff4d4d;
|
||||
padding: 10px 14px;
|
||||
margin: 10px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.form-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
max-width: 520px;
|
||||
}
|
||||
|
||||
.form-stack label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 0.88rem;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.form-stack input[type="text"],
|
||||
.form-stack input[type="file"] {
|
||||
background: var(--background-color);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-color);
|
||||
padding: 8px 10px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.form-stack small {
|
||||
opacity: 0.7;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.field-warn {
|
||||
color: #ffb347;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
background: var(--background-color);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 12px;
|
||||
overflow-x: auto;
|
||||
font-family: monospace;
|
||||
font-size: 0.85rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.button-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
101
decnet_web/src/components/SwarmDeckies.tsx
Normal file
101
decnet_web/src/components/SwarmDeckies.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import api from '../utils/api';
|
||||
import './Dashboard.css';
|
||||
import './Swarm.css';
|
||||
import { Boxes, RefreshCw } from 'lucide-react';
|
||||
|
||||
interface DeckyShard {
|
||||
decky_name: string;
|
||||
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 [error, setError] = useState<string | null>(null);
|
||||
|
||||
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 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>State</th>
|
||||
<th>Services</th>
|
||||
<th>Compose</th>
|
||||
<th>Updated</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{h.shards.map((s) => (
|
||||
<tr key={`${uuid}-${s.decky_name}`}>
|
||||
<td>{s.decky_name}</td>
|
||||
<td>{s.state}{s.last_error ? ` — ${s.last_error}` : ''}</td>
|
||||
<td>{s.services.join(', ')}</td>
|
||||
<td><code>{s.compose_hash ? s.compose_hash.slice(0, 8) : '—'}</code></td>
|
||||
<td>{new Date(s.updated_at).toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SwarmDeckies;
|
||||
118
decnet_web/src/components/SwarmHosts.tsx
Normal file
118
decnet_web/src/components/SwarmHosts.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import api from '../utils/api';
|
||||
import './Dashboard.css';
|
||||
import './Swarm.css';
|
||||
import { HardDrive, RefreshCw, Trash2, Wifi, WifiOff } from 'lucide-react';
|
||||
|
||||
interface SwarmHost {
|
||||
uuid: string;
|
||||
name: string;
|
||||
address: string;
|
||||
agent_port: number;
|
||||
status: string;
|
||||
last_heartbeat: string | null;
|
||||
client_cert_fingerprint: string;
|
||||
updater_cert_fingerprint: string | null;
|
||||
enrolled_at: string;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
const shortFp = (fp: string): string => (fp ? fp.slice(0, 16) + '…' : '—');
|
||||
|
||||
const SwarmHosts: React.FC = () => {
|
||||
const [hosts, setHosts] = useState<SwarmHost[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [decommissioning, setDecommissioning] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchHosts = async () => {
|
||||
try {
|
||||
const res = await api.get('/swarm/hosts');
|
||||
setHosts(res.data);
|
||||
setError(null);
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.detail || 'Failed to fetch swarm hosts');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchHosts();
|
||||
const t = setInterval(fetchHosts, 10000);
|
||||
return () => clearInterval(t);
|
||||
}, []);
|
||||
|
||||
const handleDecommission = async (host: SwarmHost) => {
|
||||
if (!window.confirm(`Decommission ${host.name} (${host.address})? This removes certs and decky mappings.`)) return;
|
||||
setDecommissioning(host.uuid);
|
||||
try {
|
||||
await api.delete(`/swarm/hosts/${host.uuid}`);
|
||||
await fetchHosts();
|
||||
} catch (err: any) {
|
||||
alert(err?.response?.data?.detail || 'Decommission failed');
|
||||
} finally {
|
||||
setDecommissioning(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="dashboard">
|
||||
<div className="dashboard-header">
|
||||
<h1><HardDrive size={28} /> SWARM Hosts</h1>
|
||||
<button onClick={fetchHosts} className="control-btn" disabled={loading}>
|
||||
<RefreshCw size={16} /> Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="error-box">{error}</div>}
|
||||
|
||||
<div className="panel">
|
||||
{loading ? (
|
||||
<p>Loading hosts…</p>
|
||||
) : hosts.length === 0 ? (
|
||||
<p>No swarm hosts enrolled yet. Head to <strong>SWARM → Agent Enrollment</strong> to onboard one.</p>
|
||||
) : (
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Status</th>
|
||||
<th>Name</th>
|
||||
<th>Address</th>
|
||||
<th>Last heartbeat</th>
|
||||
<th>Client cert</th>
|
||||
<th>Enrolled</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{hosts.map((h) => (
|
||||
<tr key={h.uuid}>
|
||||
<td>
|
||||
{h.status === 'active' ? <Wifi size={16} /> : <WifiOff size={16} />} {h.status}
|
||||
</td>
|
||||
<td>{h.name}</td>
|
||||
<td>{h.address}:{h.agent_port}</td>
|
||||
<td>{h.last_heartbeat ? new Date(h.last_heartbeat).toLocaleString() : '—'}</td>
|
||||
<td title={h.client_cert_fingerprint}><code>{shortFp(h.client_cert_fingerprint)}</code></td>
|
||||
<td>{new Date(h.enrolled_at).toLocaleString()}</td>
|
||||
<td>
|
||||
<button
|
||||
className="control-btn danger"
|
||||
disabled={decommissioning === h.uuid}
|
||||
onClick={() => handleDecommission(h)}
|
||||
>
|
||||
<Trash2 size={14} /> {decommissioning === h.uuid ? 'Decommissioning…' : 'Decommission'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SwarmHosts;
|
||||
Reference in New Issue
Block a user