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 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 SwarmDeckies from './components/SwarmDeckies';
|
||||||
|
import AgentEnrollment from './components/AgentEnrollment';
|
||||||
|
|
||||||
function isTokenValid(token: string): boolean {
|
function isTokenValid(token: string): boolean {
|
||||||
try {
|
try {
|
||||||
@@ -66,6 +69,9 @@ function App() {
|
|||||||
<Route path="/attackers/:id" element={<AttackerDetail />} />
|
<Route path="/attackers/:id" element={<AttackerDetail />} />
|
||||||
<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/deckies" element={<SwarmDeckies />} />
|
||||||
|
<Route path="/swarm/enroll" element={<AgentEnrollment />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Layout>
|
</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;
|
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 {
|
.sidebar-footer {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
border-top: 1px solid var(--border-color);
|
border-top: 1px solid var(--border-color);
|
||||||
|
|||||||
@@ -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 } 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';
|
import './Layout.css';
|
||||||
|
|
||||||
interface LayoutProps {
|
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="/live-logs" icon={<Terminal size={20} />} label="Live Logs" open={sidebarOpen} />
|
||||||
<NavItem to="/bounty" icon={<Archive size={20} />} label="Bounty" 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="/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} />
|
<NavItem to="/config" icon={<Settings size={20} />} label="Config" open={sidebarOpen} />
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@@ -92,13 +97,49 @@ interface NavItemProps {
|
|||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
label: string;
|
label: string;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
indent?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NavItem: React.FC<NavItemProps> = ({ to, icon, label, open }) => (
|
const NavItem: React.FC<NavItemProps> = ({ to, icon, label, open, indent }) => (
|
||||||
<NavLink to={to} className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`} end={to === '/'}>
|
<NavLink
|
||||||
|
to={to}
|
||||||
|
className={({ isActive }) => `nav-item ${isActive ? 'active' : ''} ${indent ? 'nav-subitem' : ''}`}
|
||||||
|
end={to === '/'}
|
||||||
|
>
|
||||||
{icon}
|
{icon}
|
||||||
{open && <span className="nav-label">{label}</span>}
|
{open && <span className="nav-label">{label}</span>}
|
||||||
</NavLink>
|
</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;
|
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