119 lines
3.8 KiB
TypeScript
119 lines
3.8 KiB
TypeScript
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;
|