diff --git a/decnet_web/src/App.tsx b/decnet_web/src/App.tsx index 371e0846..3a780e11 100644 --- a/decnet_web/src/App.tsx +++ b/decnet_web/src/App.tsx @@ -11,7 +11,6 @@ import Config from './components/Config'; import Bounty from './components/Bounty'; import RemoteUpdates from './components/RemoteUpdates'; import SwarmHosts from './components/SwarmHosts'; -import AgentEnrollment from './components/AgentEnrollment'; import MazeNET from './components/MazeNET/MazeNET'; import TopologyList from './components/TopologyList/TopologyList'; import CommandPalette from './components/CommandPalette/CommandPalette'; @@ -87,7 +86,7 @@ const AuthedShell: React.FC = ({ onLogout, onSearch, searchQue } /> } /> } /> - } /> + } /> } /> diff --git a/decnet_web/src/components/AgentEnrollment.tsx b/decnet_web/src/components/AgentEnrollment.tsx deleted file mode 100644 index aeff26d5..00000000 --- a/decnet_web/src/components/AgentEnrollment.tsx +++ /dev/null @@ -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(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(''); - 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 ( -
-
-
-

AGENT ENROLLMENT

- - issue a one-shot bootstrap URL for a new swarm worker - -
-
- - {!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/CommandPalette/CommandPalette.tsx b/decnet_web/src/components/CommandPalette/CommandPalette.tsx index 4f81728e..5a03ec97 100644 --- a/decnet_web/src/components/CommandPalette/CommandPalette.tsx +++ b/decnet_web/src/components/CommandPalette/CommandPalette.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { LayoutDashboard, Server, Network, Terminal, Archive, Crosshair, - PlusCircle, Pause, RefreshCw, Download, HardDrive, Package, UserPlus, Settings, + PlusCircle, Pause, RefreshCw, Download, HardDrive, Package, Settings, SearchX, Keyboard, } from 'lucide-react'; 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: '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: '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: 'ACTIONS', label: 'Deploy new decky', icon: PlusCircle, kind: 'action', payload: 'deploy' }, { section: 'ACTIONS', label: 'Pause live stream', icon: Pause, kind: 'action', payload: 'pause-logs' }, diff --git a/decnet_web/src/components/Layout.tsx b/decnet_web/src/components/Layout.tsx index 8fb99895..ea958b05 100644 --- a/decnet_web/src/components/Layout.tsx +++ b/decnet_web/src/components/Layout.tsx @@ -3,7 +3,7 @@ import { NavLink, useLocation } from 'react-router-dom'; import { Menu, X, Search, Activity, LayoutDashboard, Terminal, Settings, LogOut, Server, Archive, Package, Network, ChevronDown, ChevronRight, HardDrive, - UserPlus, ShieldAlert, + ShieldAlert, } from 'lucide-react'; import './Layout.css'; @@ -31,7 +31,6 @@ const ROUTE_LABELS: Record = { '/config': 'CONFIG', '/swarm-updates': 'REMOTE UPDATES', '/swarm/hosts': 'SWARM HOSTS', - '/swarm/enroll': 'AGENT ENROLLMENT', }; function labelForPath(pathname: string): string { @@ -117,7 +116,6 @@ const Layout: React.FC = ({ } open={sidebarOpen}> } label="SWARM Hosts" open={sidebarOpen} indent /> } label="Remote Updates" open={sidebarOpen} indent /> - } label="Agent Enrollment" open={sidebarOpen} indent /> } label="Config" open={sidebarOpen} /> diff --git a/decnet_web/src/components/SwarmHosts.tsx b/decnet_web/src/components/SwarmHosts.tsx index 5f054972..9d61659f 100644 --- a/decnet_web/src/components/SwarmHosts.tsx +++ b/decnet_web/src/components/SwarmHosts.tsx @@ -1,10 +1,14 @@ -import React, { useEffect, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; +import React, { useEffect, useRef, useState } from 'react'; import api from '../utils/api'; import EmptyState from './EmptyState/EmptyState'; +import Modal from './Modal/Modal'; import './Dashboard.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 { uuid: string; @@ -19,15 +23,328 @@ interface SwarmHost { 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) + '…' : '—'); +// ─── Enrollment wizard ──────────────────────────────────────────────────── + +interface EnrollmentWizardProps { + open: boolean; + onClose: () => void; + onEnrolled: () => void; +} + +const EnrollmentWizard: React.FC = ({ 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(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(() => { + 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) => { + 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 ( + + +
+ {step > 0 && !result && ( + + )} + {step < 2 && ( + + )} + {step === 2 && !result && ( + + )} + {result && ( + + )} +
+ + } + > + <> +
+ {['IDENTITY', 'OPTIONS', 'BUNDLE'].map((l, i) => ( +
+ {i + 1}. {l} +
+ ))} +
+ +
+ {step === 0 && ( + <> +
Who is this worker, and how does it reach the master?
+
+ + setMasterHost(e.target.value)} + /> +
+
+ + setAgentName(e.target.value.toLowerCase())} + pattern="^[a-z0-9][a-z0-9-]{0,62}$" + data-autofocus + /> + {agentName && !nameOk && ( + + must match ^[a-z0-9][a-z0-9-]{'{0,62}'}$ + + )} +
+ + )} + + {step === 1 && ( + <> +
Bundle options — tune for the target environment.
+
+ setWithUpdater(e.target.checked)} + style={{ accentColor: 'var(--matrix)', marginTop: 2 }} + /> + +
+
+ setUseIpvlan(e.target.checked)} + style={{ accentColor: 'var(--matrix)', marginTop: 2 }} + /> + +
+
+ + + {servicesIniName && ( +
+ loaded: {servicesIniName} +
+ )} +
+ + )} + + {step === 2 && ( + <> + {!result ? ( + <> +
+ Review and generate a one-shot bootstrap URL valid for 5 minutes. +
+
+ # enrollment bundle preview{'\n'} + master_host{' '}{masterHost}{'\n'} + agent_name {' '}{agentName}{'\n'} + updater {' '}{withUpdater ? 'yes' : 'no'}{'\n'} + ipvlan {' '}{useIpvlan ? 'yes' : 'no'}{'\n'} + services {' '}{servicesIniName ?? '—'} +
+ {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. +
+ )} + + )} + + )} +
+ +
+ ); +}; + +// ─── Swarm hosts page ───────────────────────────────────────────────────── + const SwarmHosts: React.FC = () => { - const navigate = useNavigate(); const [hosts, setHosts] = useState([]); const [loading, setLoading] = useState(true); const [decommissioning, setDecommissioning] = useState>(new Set()); const [tearingDown, setTearingDown] = useState>(new Set()); const [error, setError] = useState(null); + const [showEnroll, setShowEnroll] = useState(false); // Two-click arm/commit replaces window.confirm(). Browsers silently // suppress confirm() after the "prevent additional dialogs" opt-out, // 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`} - +
+ + +
{error &&
{error}
} @@ -116,7 +438,7 @@ const SwarmHosts: React.FC = () => { icon={Server} title="NO SWARM HOSTS ENROLLED" hint="onboard an agent to expand the fleet" - cta={{ label: 'ENROLL HOST', onClick: () => navigate('/swarm/enroll') }} + cta={{ label: 'ENROLL HOST', onClick: () => setShowEnroll(true) }} /> ) : ( @@ -175,6 +497,12 @@ const SwarmHosts: React.FC = () => {
)} + + setShowEnroll(false)} + onEnrolled={fetchHosts} + /> ); }; diff --git a/decnet_web/src/hooks/useGlobalHotkeys.ts b/decnet_web/src/hooks/useGlobalHotkeys.ts index 697e4a57..e284aa7d 100644 --- a/decnet_web/src/hooks/useGlobalHotkeys.ts +++ b/decnet_web/src/hooks/useGlobalHotkeys.ts @@ -18,7 +18,6 @@ const G_NAV: Record = { c: '/config', s: '/swarm/hosts', u: '/swarm-updates', - e: '/swarm/enroll', }; const G_TIMEOUT_MS = 800;