From 02f07c7962cf5650174bd426e1396501d4bcbb3a Mon Sep 17 00:00:00 2001 From: anti Date: Sun, 19 Apr 2026 04:29:07 -0400 Subject: [PATCH] feat(web-ui): SWARM nav group + Hosts/Deckies/AgentEnrollment pages --- decnet_web/src/App.tsx | 6 + decnet_web/src/components/AgentEnrollment.tsx | 164 ++++++++++++++++++ decnet_web/src/components/Layout.css | 38 ++++ decnet_web/src/components/Layout.tsx | 49 +++++- decnet_web/src/components/Swarm.css | 159 +++++++++++++++++ decnet_web/src/components/SwarmDeckies.tsx | 101 +++++++++++ decnet_web/src/components/SwarmHosts.tsx | 118 +++++++++++++ 7 files changed, 631 insertions(+), 4 deletions(-) create mode 100644 decnet_web/src/components/AgentEnrollment.tsx create mode 100644 decnet_web/src/components/Swarm.css create mode 100644 decnet_web/src/components/SwarmDeckies.tsx create mode 100644 decnet_web/src/components/SwarmHosts.tsx diff --git a/decnet_web/src/App.tsx b/decnet_web/src/App.tsx index 54a21e3..5e856e0 100644 --- a/decnet_web/src/App.tsx +++ b/decnet_web/src/App.tsx @@ -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() { } /> } /> } /> + } /> + } /> + } /> } /> diff --git a/decnet_web/src/components/AgentEnrollment.tsx b/decnet_web/src/components/AgentEnrollment.tsx new file mode 100644 index 0000000..05e2a20 --- /dev/null +++ b/decnet_web/src/components/AgentEnrollment.tsx @@ -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(null); + const [servicesIniName, setServicesIniName] = useState(null); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [result, setResult] = useState(null); + const [copied, setCopied] = useState(false); + const [now, setNow] = useState(Date.now()); + const fileRef = useRef(null); + + useEffect(() => { + const t = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(t); + }, []); + + const handleFile = (e: React.ChangeEvent) => { + 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 ( +
+
+

Agent Enrollment

+
+ + {!result ? ( +
+

+ 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. +

+
+ + + + {error &&
{error}
} + +
+
+ ) : ( +
+

Paste this on the new worker (as root):

+
{result.command}
+
+ + +
+

+ Expires in {mm}:{ss} — one-shot, single download. Host UUID:{' '} + {result.host_uuid} +

+ {remainingSecs === 0 && ( +
+ This bundle has expired. Generate another. +
+ )} +
+ )} +
+ ); +}; + +export default AgentEnrollment; diff --git a/decnet_web/src/components/Layout.css b/decnet_web/src/components/Layout.css index 3f47644..82f8eed 100644 --- a/decnet_web/src/components/Layout.css +++ b/decnet_web/src/components/Layout.css @@ -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); diff --git a/decnet_web/src/components/Layout.tsx b/decnet_web/src/components/Layout.tsx index 2df1451..11c4c42 100644 --- a/decnet_web/src/components/Layout.tsx +++ b/decnet_web/src/components/Layout.tsx @@ -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 = ({ children, onLogout, onSearch }) => { } label="Live Logs" open={sidebarOpen} /> } label="Bounty" open={sidebarOpen} /> } label="Attackers" open={sidebarOpen} /> - } label="Remote Updates" open={sidebarOpen} /> + } open={sidebarOpen}> + } label="SWARM Hosts" open={sidebarOpen} indent /> + } label="SWARM Deckies" open={sidebarOpen} indent /> + } label="Remote Updates" open={sidebarOpen} indent /> + } label="Agent Enrollment" open={sidebarOpen} indent /> + } label="Config" open={sidebarOpen} /> @@ -92,13 +97,49 @@ interface NavItemProps { icon: React.ReactNode; label: string; open: boolean; + indent?: boolean; } -const NavItem: React.FC = ({ to, icon, label, open }) => ( - `nav-item ${isActive ? 'active' : ''}`} end={to === '/'}> +const NavItem: React.FC = ({ to, icon, label, open, indent }) => ( + `nav-item ${isActive ? 'active' : ''} ${indent ? 'nav-subitem' : ''}`} + end={to === '/'} + > {icon} {open && {label}} ); +interface NavGroupProps { + label: string; + icon: React.ReactNode; + open: boolean; + children: React.ReactNode; +} + +const NavGroup: React.FC = ({ label, icon, open, children }) => { + const [expanded, setExpanded] = useState(true); + return ( +
+ + {expanded &&
{children}
} +
+ ); +}; + export default Layout; diff --git a/decnet_web/src/components/Swarm.css b/decnet_web/src/components/Swarm.css new file mode 100644 index 0000000..e36d48a --- /dev/null +++ b/decnet_web/src/components/Swarm.css @@ -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; +} diff --git a/decnet_web/src/components/SwarmDeckies.tsx b/decnet_web/src/components/SwarmDeckies.tsx new file mode 100644 index 0000000..b33429a --- /dev/null +++ b/decnet_web/src/components/SwarmDeckies.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 = {}; + 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 ( +
+
+

SWARM Deckies

+ +
+ + {error &&
{error}
} + + {loading ? ( +

Loading deckies…

+ ) : shards.length === 0 ? ( +
+

No deckies deployed to swarm workers yet.

+
+ ) : ( + Object.entries(byHost).map(([uuid, h]) => ( +
+

{h.name} ({h.address}) — {h.status}

+ + + + + + + + + + + + {h.shards.map((s) => ( + + + + + + + + ))} + +
DeckyStateServicesComposeUpdated
{s.decky_name}{s.state}{s.last_error ? ` — ${s.last_error}` : ''}{s.services.join(', ')}{s.compose_hash ? s.compose_hash.slice(0, 8) : '—'}{new Date(s.updated_at).toLocaleString()}
+
+ )) + )} +
+ ); +}; + +export default SwarmDeckies; diff --git a/decnet_web/src/components/SwarmHosts.tsx b/decnet_web/src/components/SwarmHosts.tsx new file mode 100644 index 0000000..7b84cca --- /dev/null +++ b/decnet_web/src/components/SwarmHosts.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [decommissioning, setDecommissioning] = useState(null); + const [error, setError] = useState(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 ( +
+
+

SWARM Hosts

+ +
+ + {error &&
{error}
} + +
+ {loading ? ( +

Loading hosts…

+ ) : hosts.length === 0 ? ( +

No swarm hosts enrolled yet. Head to SWARM → Agent Enrollment to onboard one.

+ ) : ( + + + + + + + + + + + + + + {hosts.map((h) => ( + + + + + + + + + + ))} + +
StatusNameAddressLast heartbeatClient certEnrolled
+ {h.status === 'active' ? : } {h.status} + {h.name}{h.address}:{h.agent_port}{h.last_heartbeat ? new Date(h.last_heartbeat).toLocaleString() : '—'}{shortFp(h.client_cert_fingerprint)}{new Date(h.enrolled_at).toLocaleString()} + +
+ )} +
+
+ ); +}; + +export default SwarmHosts;