diff --git a/decnet_web/src/components/SwarmHosts/EnrollmentWizard.test.tsx b/decnet_web/src/components/SwarmHosts/EnrollmentWizard.test.tsx new file mode 100644 index 00000000..cf86b542 --- /dev/null +++ b/decnet_web/src/components/SwarmHosts/EnrollmentWizard.test.tsx @@ -0,0 +1,45 @@ +/** + * @vitest-environment jsdom + */ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import EnrollmentWizard from './EnrollmentWizard'; + +vi.mock('../../hooks/useFocusTrap', () => ({ + default: () => {}, + useFocusTrap: () => {}, +})); + +const stubGen = vi.fn(); + +describe('EnrollmentWizard', () => { + it('keeps NEXT disabled until a valid agent name is entered', () => { + render( + {}} + onEnrolled={() => {}} + generateBundle={stubGen} + />, + ); + const next = screen.getByText(/NEXT/i).closest('button')!; + expect(next).toBeDisabled(); + + const input = screen.getAllByRole('textbox').find( + (i) => (i as HTMLInputElement).value === '', + ) as HTMLInputElement; + fireEvent.change(input, { target: { value: 'agent-1' } }); + expect(next).not.toBeDisabled(); + }); + + it('renders step labels', () => { + render( + {}} onEnrolled={() => {}} generateBundle={stubGen} + />, + ); + expect(screen.getByText(/IDENTITY/)).toBeInTheDocument(); + expect(screen.getByText(/OPTIONS/)).toBeInTheDocument(); + expect(screen.getByText(/BUNDLE/)).toBeInTheDocument(); + }); +}); diff --git a/decnet_web/src/components/SwarmHosts/EnrollmentWizard.tsx b/decnet_web/src/components/SwarmHosts/EnrollmentWizard.tsx new file mode 100644 index 00000000..d9c9df65 --- /dev/null +++ b/decnet_web/src/components/SwarmHosts/EnrollmentWizard.tsx @@ -0,0 +1,316 @@ +import React, { useEffect, useRef, useState } from 'react'; +import Modal from '../Modal/Modal'; +import { + AlertTriangle, Check, Copy, RotateCcw, UserPlus, +} from '../../icons'; +import { + AGENT_NAME_RE, bundleSecondsLeft, extractErrorDetail, formatMmSs, +} from './helpers'; +import type { BundleRequest, BundleResult } from './types'; + +interface Props { + open: boolean; + onClose: () => void; + onEnrolled: () => void; + /** Injected so the page can swap in the {ok, reason} hook + * contract without the wizard knowing about axios. */ + generateBundle: (req: BundleRequest) => Promise<{ ok: true; data: BundleResult } | { ok: false; reason: string }>; +} + +const EnrollmentWizard: React.FC = ({ open, onClose, onEnrolled, generateBundle }) => { + 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 = AGENT_NAME_RE.test(agentName); + + const generate = async () => { + setSubmitting(true); + setError(null); + try { + const r = await generateBundle({ + master_host: masterHost, + agent_name: agentName, + with_updater: withUpdater, + use_ipvlan: useIpvlan, + services_ini: servicesIni, + }); + if (r.ok) { + setResult(r.data); + onEnrolled(); + } else { + setError(r.reason); + } + } catch (err) { + setError(extractErrorDetail(err, '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 ? bundleSecondsLeft(result.expires_at, now) : 0; + const { mm, ss } = formatMmSs(remainingSecs); + + 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. +
+ )} + + )} + + )} +
+ +
+ ); +}; + +export default EnrollmentWizard;