feat(web/swarm): fold agent enrollment into a wizard on Swarm Hosts
This commit is contained in:
@@ -11,7 +11,6 @@ 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 SwarmHosts from './components/SwarmHosts';
|
||||||
import AgentEnrollment from './components/AgentEnrollment';
|
|
||||||
import MazeNET from './components/MazeNET/MazeNET';
|
import MazeNET from './components/MazeNET/MazeNET';
|
||||||
import TopologyList from './components/TopologyList/TopologyList';
|
import TopologyList from './components/TopologyList/TopologyList';
|
||||||
import CommandPalette from './components/CommandPalette/CommandPalette';
|
import CommandPalette from './components/CommandPalette/CommandPalette';
|
||||||
@@ -87,7 +86,7 @@ const AuthedShell: React.FC<AuthedShellProps> = ({ onLogout, onSearch, searchQue
|
|||||||
<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/hosts" element={<SwarmHosts />} />
|
||||||
<Route path="/swarm/enroll" element={<AgentEnrollment />} />
|
<Route path="/swarm/enroll" element={<Navigate to="/swarm/hosts" replace />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -1,193 +0,0 @@
|
|||||||
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 [withUpdater, setWithUpdater] = useState(true);
|
|
||||||
const [useIpvlan, setUseIpvlan] = useState(false);
|
|
||||||
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('');
|
|
||||||
setWithUpdater(true);
|
|
||||||
setUseIpvlan(false);
|
|
||||||
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,
|
|
||||||
with_updater: withUpdater,
|
|
||||||
use_ipvlan: useIpvlan,
|
|
||||||
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 swarm-root">
|
|
||||||
<div className="page-header">
|
|
||||||
<div className="page-title-group">
|
|
||||||
<h1><UserPlus size={18} /> AGENT ENROLLMENT</h1>
|
|
||||||
<span className="page-sub">
|
|
||||||
issue a one-shot bootstrap URL for a new swarm worker
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</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 className="form-inline">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={withUpdater}
|
|
||||||
onChange={(e) => setWithUpdater(e.target.checked)}
|
|
||||||
/>
|
|
||||||
<span>Install updater daemon (lets the master push code updates to this agent)</span>
|
|
||||||
</label>
|
|
||||||
<label className="form-inline">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={useIpvlan}
|
|
||||||
onChange={(e) => setUseIpvlan(e.target.checked)}
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
Use IPvlan instead of MACVLAN (required for VirtualBox/VMware guests bridged over Wi-Fi — Wi-Fi APs bind one MAC per station, so MACVLAN rotates the VM's lease)
|
|
||||||
</span>
|
|
||||||
</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;
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
LayoutDashboard, Server, Network, Terminal, Archive, Crosshair,
|
LayoutDashboard, Server, Network, Terminal, Archive, Crosshair,
|
||||||
PlusCircle, Pause, RefreshCw, Download, HardDrive, Package, UserPlus, Settings,
|
PlusCircle, Pause, RefreshCw, Download, HardDrive, Package, Settings,
|
||||||
SearchX, Keyboard,
|
SearchX, Keyboard,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import EmptyState from '../EmptyState/EmptyState';
|
import EmptyState from '../EmptyState/EmptyState';
|
||||||
@@ -27,7 +27,6 @@ const ITEMS: CmdItem[] = [
|
|||||||
{ section: 'GO TO', label: 'Attackers', icon: Crosshair, kbd: 'G A', kind: 'nav', payload: '/attackers' },
|
{ section: 'GO TO', label: 'Attackers', icon: Crosshair, kbd: 'G A', kind: 'nav', payload: '/attackers' },
|
||||||
{ section: 'GO TO', label: 'SWARM Hosts', icon: HardDrive, kbd: 'G S', kind: 'nav', payload: '/swarm/hosts' },
|
{ section: 'GO TO', label: 'SWARM Hosts', icon: HardDrive, kbd: 'G S', kind: 'nav', payload: '/swarm/hosts' },
|
||||||
{ section: 'GO TO', label: 'Remote Updates', icon: Package, kbd: 'G U', kind: 'nav', payload: '/swarm-updates' },
|
{ section: 'GO TO', label: 'Remote Updates', icon: Package, kbd: 'G U', kind: 'nav', payload: '/swarm-updates' },
|
||||||
{ section: 'GO TO', label: 'Agent Enrollment', icon: UserPlus, kbd: 'G E', kind: 'nav', payload: '/swarm/enroll' },
|
|
||||||
{ section: 'GO TO', label: 'Config', icon: Settings, kbd: 'G C', kind: 'nav', payload: '/config' },
|
{ section: 'GO TO', label: 'Config', icon: Settings, kbd: 'G C', kind: 'nav', payload: '/config' },
|
||||||
{ section: 'ACTIONS', label: 'Deploy new decky', icon: PlusCircle, kind: 'action', payload: 'deploy' },
|
{ section: 'ACTIONS', label: 'Deploy new decky', icon: PlusCircle, kind: 'action', payload: 'deploy' },
|
||||||
{ section: 'ACTIONS', label: 'Pause live stream', icon: Pause, kind: 'action', payload: 'pause-logs' },
|
{ section: 'ACTIONS', label: 'Pause live stream', icon: Pause, kind: 'action', payload: 'pause-logs' },
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { NavLink, useLocation } from 'react-router-dom';
|
|||||||
import {
|
import {
|
||||||
Menu, X, Search, Activity, LayoutDashboard, Terminal, Settings, LogOut,
|
Menu, X, Search, Activity, LayoutDashboard, Terminal, Settings, LogOut,
|
||||||
Server, Archive, Package, Network, ChevronDown, ChevronRight, HardDrive,
|
Server, Archive, Package, Network, ChevronDown, ChevronRight, HardDrive,
|
||||||
UserPlus, ShieldAlert,
|
ShieldAlert,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import './Layout.css';
|
import './Layout.css';
|
||||||
|
|
||||||
@@ -31,7 +31,6 @@ const ROUTE_LABELS: Record<string, string> = {
|
|||||||
'/config': 'CONFIG',
|
'/config': 'CONFIG',
|
||||||
'/swarm-updates': 'REMOTE UPDATES',
|
'/swarm-updates': 'REMOTE UPDATES',
|
||||||
'/swarm/hosts': 'SWARM HOSTS',
|
'/swarm/hosts': 'SWARM HOSTS',
|
||||||
'/swarm/enroll': 'AGENT ENROLLMENT',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function labelForPath(pathname: string): string {
|
function labelForPath(pathname: string): string {
|
||||||
@@ -117,7 +116,6 @@ const Layout: React.FC<LayoutProps> = ({
|
|||||||
<NavGroup label="SWARM" icon={<Network size={20} />} 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/hosts" icon={<HardDrive size={18} />} label="SWARM Hosts" open={sidebarOpen} indent />
|
||||||
<NavItem to="/swarm-updates" icon={<Package size={18} />} label="Remote Updates" 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>
|
</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>
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import api from '../utils/api';
|
import api from '../utils/api';
|
||||||
import EmptyState from './EmptyState/EmptyState';
|
import EmptyState from './EmptyState/EmptyState';
|
||||||
|
import Modal from './Modal/Modal';
|
||||||
import './Dashboard.css';
|
import './Dashboard.css';
|
||||||
import './Swarm.css';
|
import './Swarm.css';
|
||||||
import { HardDrive, PowerOff, RefreshCw, Server, Trash2, Wifi, WifiOff } from 'lucide-react';
|
import './DeckyFleet.css';
|
||||||
|
import {
|
||||||
|
AlertTriangle, Check, Copy, HardDrive, PowerOff, RefreshCw, RotateCcw,
|
||||||
|
Server, Trash2, UserPlus, Wifi, WifiOff,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
interface SwarmHost {
|
interface SwarmHost {
|
||||||
uuid: string;
|
uuid: string;
|
||||||
@@ -19,15 +23,328 @@ interface SwarmHost {
|
|||||||
notes: string | null;
|
notes: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface BundleResult {
|
||||||
|
token: string;
|
||||||
|
host_uuid: string;
|
||||||
|
command: string;
|
||||||
|
expires_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
const shortFp = (fp: string): string => (fp ? fp.slice(0, 16) + '…' : '—');
|
const shortFp = (fp: string): string => (fp ? fp.slice(0, 16) + '…' : '—');
|
||||||
|
|
||||||
|
// ─── Enrollment wizard ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface EnrollmentWizardProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onEnrolled: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EnrollmentWizard: React.FC<EnrollmentWizardProps> = ({ open, onClose, onEnrolled }) => {
|
||||||
|
const [step, setStep] = useState(0);
|
||||||
|
const [masterHost, setMasterHost] = useState(window.location.hostname);
|
||||||
|
const [agentName, setAgentName] = useState('');
|
||||||
|
const [withUpdater, setWithUpdater] = useState(true);
|
||||||
|
const [useIpvlan, setUseIpvlan] = useState(false);
|
||||||
|
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(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setStep(0);
|
||||||
|
setMasterHost(window.location.hostname);
|
||||||
|
setAgentName('');
|
||||||
|
setWithUpdater(true);
|
||||||
|
setUseIpvlan(false);
|
||||||
|
setServicesIni(null);
|
||||||
|
setServicesIniName(null);
|
||||||
|
setSubmitting(false);
|
||||||
|
setError(null);
|
||||||
|
setResult(null);
|
||||||
|
setCopied(false);
|
||||||
|
if (fileRef.current) fileRef.current.value = '';
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!result) return;
|
||||||
|
const t = setInterval(() => setNow(Date.now()), 1000);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, [result]);
|
||||||
|
|
||||||
|
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 nameOk = /^[a-z0-9][a-z0-9-]{0,62}$/.test(agentName);
|
||||||
|
|
||||||
|
const generate = async () => {
|
||||||
|
setSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await api.post('/swarm/enroll-bundle', {
|
||||||
|
master_host: masterHost,
|
||||||
|
agent_name: agentName,
|
||||||
|
with_updater: withUpdater,
|
||||||
|
use_ipvlan: useIpvlan,
|
||||||
|
services_ini: servicesIni,
|
||||||
|
});
|
||||||
|
setResult(res.data);
|
||||||
|
onEnrolled();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const e = err as { response?: { data?: { detail?: string } } };
|
||||||
|
setError(e?.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 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');
|
||||||
|
|
||||||
|
const canNext = step === 0 ? (nameOk && !!masterHost) : true;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
title="ENROLL SWARM HOST"
|
||||||
|
icon={UserPlus}
|
||||||
|
accent="violet"
|
||||||
|
width="wide"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<button className="btn ghost" onClick={onClose}>
|
||||||
|
{result ? 'CLOSE' : 'CANCEL'}
|
||||||
|
</button>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
{step > 0 && !result && (
|
||||||
|
<button className="btn ghost" onClick={() => setStep((s) => s - 1)}>← BACK</button>
|
||||||
|
)}
|
||||||
|
{step < 2 && (
|
||||||
|
<button className="btn" disabled={!canNext} onClick={() => setStep((s) => s + 1)}>
|
||||||
|
NEXT →
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{step === 2 && !result && (
|
||||||
|
<button
|
||||||
|
className="btn violet"
|
||||||
|
disabled={submitting || !nameOk || !masterHost}
|
||||||
|
onClick={generate}
|
||||||
|
>
|
||||||
|
{submitting ? 'GENERATING…' : 'GENERATE BUNDLE'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{result && (
|
||||||
|
<button
|
||||||
|
className="btn"
|
||||||
|
onClick={() => {
|
||||||
|
setResult(null);
|
||||||
|
setAgentName('');
|
||||||
|
setStep(0);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RotateCcw size={12} /> NEW BUNDLE
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<>
|
||||||
|
<div className="wizard-steps">
|
||||||
|
{['IDENTITY', 'OPTIONS', 'BUNDLE'].map((l, i) => (
|
||||||
|
<div key={l} className={`wizard-step ${i === step ? 'active' : i < step ? 'done' : ''}`}>
|
||||||
|
{i + 1}. {l}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-body">
|
||||||
|
{step === 0 && (
|
||||||
|
<>
|
||||||
|
<div className="type-label">Who is this worker, and how does it reach the master?</div>
|
||||||
|
<div className="tweak-group">
|
||||||
|
<label>MASTER HOST (IP or DNS this agent can reach)</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="text"
|
||||||
|
value={masterHost}
|
||||||
|
onChange={(e) => setMasterHost(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="tweak-group">
|
||||||
|
<label>AGENT NAME (lowercase, digits, dashes)</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="text"
|
||||||
|
value={agentName}
|
||||||
|
onChange={(e) => setAgentName(e.target.value.toLowerCase())}
|
||||||
|
pattern="^[a-z0-9][a-z0-9-]{0,62}$"
|
||||||
|
data-autofocus
|
||||||
|
/>
|
||||||
|
{agentName && !nameOk && (
|
||||||
|
<small className="field-warn">
|
||||||
|
<AlertTriangle size={12} /> must match ^[a-z0-9][a-z0-9-]{'{0,62}'}$
|
||||||
|
</small>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 1 && (
|
||||||
|
<>
|
||||||
|
<div className="type-label">Bundle options — tune for the target environment.</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex', gap: 10, alignItems: 'flex-start',
|
||||||
|
padding: 14, border: '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="with-updater"
|
||||||
|
type="checkbox"
|
||||||
|
checked={withUpdater}
|
||||||
|
onChange={(e) => setWithUpdater(e.target.checked)}
|
||||||
|
style={{ accentColor: 'var(--matrix)', marginTop: 2 }}
|
||||||
|
/>
|
||||||
|
<label htmlFor="with-updater" style={{ fontSize: '0.8rem', letterSpacing: 1, flex: 1 }}>
|
||||||
|
INSTALL UPDATER DAEMON
|
||||||
|
<div className="dim" style={{ fontSize: '0.65rem', letterSpacing: 1, marginTop: 4 }}>
|
||||||
|
Lets the master push code updates to this agent.
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex', gap: 10, alignItems: 'flex-start',
|
||||||
|
padding: 14, border: '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="use-ipvlan"
|
||||||
|
type="checkbox"
|
||||||
|
checked={useIpvlan}
|
||||||
|
onChange={(e) => setUseIpvlan(e.target.checked)}
|
||||||
|
style={{ accentColor: 'var(--matrix)', marginTop: 2 }}
|
||||||
|
/>
|
||||||
|
<label htmlFor="use-ipvlan" style={{ fontSize: '0.8rem', letterSpacing: 1, flex: 1 }}>
|
||||||
|
USE IPVLAN INSTEAD OF MACVLAN
|
||||||
|
<div className="dim" style={{ fontSize: '0.65rem', letterSpacing: 1, marginTop: 4 }}>
|
||||||
|
Required for VirtualBox/VMware guests bridged over Wi-Fi — Wi-Fi APs bind
|
||||||
|
one MAC per station, so MACVLAN rotates the VM's lease.
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="tweak-group">
|
||||||
|
<label>SERVICES INI (optional)</label>
|
||||||
|
<input ref={fileRef} type="file" accept=".ini,.conf,.txt" onChange={handleFile} />
|
||||||
|
{servicesIniName && (
|
||||||
|
<div className="dim" style={{ fontSize: '0.65rem', letterSpacing: 1 }}>
|
||||||
|
loaded: {servicesIniName}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 2 && (
|
||||||
|
<>
|
||||||
|
{!result ? (
|
||||||
|
<>
|
||||||
|
<div className="type-label">
|
||||||
|
Review and generate a one-shot bootstrap URL valid for 5 minutes.
|
||||||
|
</div>
|
||||||
|
<div className="code-block">
|
||||||
|
<span className="comment"># enrollment bundle preview</span>{'\n'}
|
||||||
|
<span className="key"> master_host</span>{' '}<span className="str">{masterHost}</span>{'\n'}
|
||||||
|
<span className="key"> agent_name </span>{' '}<span className="str">{agentName}</span>{'\n'}
|
||||||
|
<span className="key"> updater </span>{' '}<span className="str">{withUpdater ? 'yes' : 'no'}</span>{'\n'}
|
||||||
|
<span className="key"> ipvlan </span>{' '}<span className="str">{useIpvlan ? 'yes' : 'no'}</span>{'\n'}
|
||||||
|
<span className="key"> services </span>{' '}<span className="str">{servicesIniName ?? '—'}</span>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: '1px solid var(--alert)', color: 'var(--alert)',
|
||||||
|
padding: '8px 12px', fontSize: '0.75rem', letterSpacing: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✖ {error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="type-label">Paste this on the new worker (as root):</div>
|
||||||
|
<div className="code-block" style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
|
||||||
|
{result.command}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<button className="btn" onClick={copyCmd}>
|
||||||
|
{copied ? <><Check size={12} /> COPIED</> : <><Copy size={12} /> COPY</>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="dim" style={{ fontSize: '0.7rem', letterSpacing: 1 }}>
|
||||||
|
Expires in <strong>{mm}:{ss}</strong> — one-shot, single download.
|
||||||
|
Host UUID: <code>{result.host_uuid}</code>
|
||||||
|
</div>
|
||||||
|
{remainingSecs === 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: '1px solid var(--alert)', color: 'var(--alert)',
|
||||||
|
padding: '8px 12px', fontSize: '0.75rem', letterSpacing: 1,
|
||||||
|
display: 'flex', alignItems: 'center', gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertTriangle size={14} /> This bundle has expired. Generate another.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Swarm hosts page ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
const SwarmHosts: React.FC = () => {
|
const SwarmHosts: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
|
||||||
const [hosts, setHosts] = useState<SwarmHost[]>([]);
|
const [hosts, setHosts] = useState<SwarmHost[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [decommissioning, setDecommissioning] = useState<Set<string>>(new Set());
|
const [decommissioning, setDecommissioning] = useState<Set<string>>(new Set());
|
||||||
const [tearingDown, setTearingDown] = useState<Set<string>>(new Set());
|
const [tearingDown, setTearingDown] = useState<Set<string>>(new Set());
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [showEnroll, setShowEnroll] = useState(false);
|
||||||
// Two-click arm/commit replaces window.confirm(). Browsers silently
|
// Two-click arm/commit replaces window.confirm(). Browsers silently
|
||||||
// suppress confirm() after the "prevent additional dialogs" opt-out,
|
// suppress confirm() after the "prevent additional dialogs" opt-out,
|
||||||
// which manifests as a dead button — no network request, no console
|
// which manifests as a dead button — no network request, no console
|
||||||
@@ -101,9 +418,14 @@ const SwarmHosts: React.FC = () => {
|
|||||||
{loading ? 'LOADING…' : `${hosts.length} ENROLLED · ${online} ONLINE`}
|
{loading ? 'LOADING…' : `${hosts.length} ENROLLED · ${online} ONLINE`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={fetchHosts} className="control-btn" disabled={loading}>
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
<RefreshCw size={14} /> REFRESH
|
<button onClick={fetchHosts} className="control-btn" disabled={loading}>
|
||||||
</button>
|
<RefreshCw size={14} /> REFRESH
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setShowEnroll(true)} className="control-btn primary">
|
||||||
|
<UserPlus size={14} /> ENROLL HOST
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <div className="error-box">{error}</div>}
|
{error && <div className="error-box">{error}</div>}
|
||||||
@@ -116,7 +438,7 @@ const SwarmHosts: React.FC = () => {
|
|||||||
icon={Server}
|
icon={Server}
|
||||||
title="NO SWARM HOSTS ENROLLED"
|
title="NO SWARM HOSTS ENROLLED"
|
||||||
hint="onboard an agent to expand the fleet"
|
hint="onboard an agent to expand the fleet"
|
||||||
cta={{ label: 'ENROLL HOST', onClick: () => navigate('/swarm/enroll') }}
|
cta={{ label: 'ENROLL HOST', onClick: () => setShowEnroll(true) }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<table className="data-table">
|
<table className="data-table">
|
||||||
@@ -175,6 +497,12 @@ const SwarmHosts: React.FC = () => {
|
|||||||
</table>
|
</table>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<EnrollmentWizard
|
||||||
|
open={showEnroll}
|
||||||
|
onClose={() => setShowEnroll(false)}
|
||||||
|
onEnrolled={fetchHosts}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ const G_NAV: Record<string, string> = {
|
|||||||
c: '/config',
|
c: '/config',
|
||||||
s: '/swarm/hosts',
|
s: '/swarm/hosts',
|
||||||
u: '/swarm-updates',
|
u: '/swarm-updates',
|
||||||
e: '/swarm/enroll',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const G_TIMEOUT_MS = 800;
|
const G_TIMEOUT_MS = 800;
|
||||||
|
|||||||
Reference in New Issue
Block a user