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

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