feat(web-ui): SWARM nav group + Hosts/Deckies/AgentEnrollment pages

This commit is contained in:
2026-04-19 04:29:07 -04:00
parent c6f7de30d2
commit 02f07c7962
7 changed files with 631 additions and 4 deletions

View File

@@ -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>

View 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;

View File

@@ -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);

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 } 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;

View 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;
}

View 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;

View 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;