refactor(decnet_web/SwarmHosts): extract EnrollmentWizard
This commit is contained in:
@@ -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(
|
||||
<EnrollmentWizard
|
||||
open
|
||||
onClose={() => {}}
|
||||
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(
|
||||
<EnrollmentWizard
|
||||
open onClose={() => {}} onEnrolled={() => {}} generateBundle={stubGen}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/IDENTITY/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/OPTIONS/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/BUNDLE/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
316
decnet_web/src/components/SwarmHosts/EnrollmentWizard.tsx
Normal file
316
decnet_web/src/components/SwarmHosts/EnrollmentWizard.tsx
Normal file
@@ -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<Props> = ({ 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<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 = 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 (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
export default EnrollmentWizard;
|
||||
Reference in New Issue
Block a user