refactor(decnet_web/DeckyFleet): move DeployWizard out
Lift the multi-step deploy wizard (~520 LOC) plus its private INI-builder helpers (PLACEHOLDER_LINES, b64encodeUtf8, buildIni, PickMode type) into their own file. Verbatim move; the underscore-prefixed helpers drop the leading underscore now that they're file-local rather than competing with hoisted parent constants. - New DeckyFleet/DeployWizard.tsx - DeployWizard.test.tsx covers the closed render guard, the open-at-step-0 archetype list, NEXT-disabled-until-archetype, and CANCEL -> onClose. ServiceConfigFields is vi.mock'd to a stub since it pulls schemas via api.get() that are out of scope for these tests. - DeckyFleet.tsx loses the wizard plus the now-unused imports (DEFAULT_SERVICES, Modal, PickIcon, ServiceConfigFields and its type aliases).
This commit is contained in:
@@ -1,15 +1,9 @@
|
|||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { PlusCircle, Server } from '../icons';
|
import { PlusCircle, Server } from '../icons';
|
||||||
import api, { type ApiError } from '../utils/api';
|
import api, { type ApiError } from '../utils/api';
|
||||||
import { ARCHETYPES as FALLBACK_ARCHETYPES, DEFAULT_SERVICES } from './MazeNET/data';
|
import { ARCHETYPES as FALLBACK_ARCHETYPES } from './MazeNET/data';
|
||||||
import { useToast } from './Toasts/useToast';
|
import { useToast } from './Toasts/useToast';
|
||||||
import Modal from './Modal/Modal';
|
|
||||||
import { useServiceRegistry } from '../hooks/useServiceRegistry';
|
import { useServiceRegistry } from '../hooks/useServiceRegistry';
|
||||||
import ServiceConfigFields, {
|
|
||||||
type FormState as SvcFormState,
|
|
||||||
type ServiceConfigFieldDTO as SvcFieldDTO,
|
|
||||||
compactPayload as svcCompactPayload,
|
|
||||||
} from './ServiceConfigFields';
|
|
||||||
import './DeckyFleet.css';
|
import './DeckyFleet.css';
|
||||||
import type {
|
import type {
|
||||||
Decky,
|
Decky,
|
||||||
@@ -19,537 +13,11 @@ import type {
|
|||||||
} from './DeckyFleet/types';
|
} from './DeckyFleet/types';
|
||||||
import {
|
import {
|
||||||
archetypeIcon as _archetypeIcon,
|
archetypeIcon as _archetypeIcon,
|
||||||
PickIcon,
|
|
||||||
dotFor as _dotFor,
|
dotFor as _dotFor,
|
||||||
hitsFor as _hitsFor,
|
|
||||||
stateColor as _stateColor,
|
|
||||||
} from './DeckyFleet/helpers';
|
} from './DeckyFleet/helpers';
|
||||||
|
|
||||||
import { DeckyInspectPanel } from './DeckyFleet/DeckyInspectPanel';
|
import { DeckyInspectPanel } from './DeckyFleet/DeckyInspectPanel';
|
||||||
import { DeckyCard } from './DeckyFleet/DeckyCard';
|
import { DeckyCard } from './DeckyFleet/DeckyCard';
|
||||||
|
import { DeployWizard } from './DeckyFleet/DeployWizard';
|
||||||
// ─── Deploy wizard ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface DeployWizardProps {
|
|
||||||
open: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onComplete: (count: number) => void;
|
|
||||||
archetypes: Archetype[];
|
|
||||||
fleetSize: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
type PickMode = 'archetype' | 'services';
|
|
||||||
|
|
||||||
const PLACEHOLDER_LINES = (
|
|
||||||
archetypeName: string, services: string[], count: number, fleetSize: number,
|
|
||||||
): string[] => [
|
|
||||||
'[INIT] allocating MAC addresses...',
|
|
||||||
'[NET] binding macvlan interfaces...',
|
|
||||||
`[FP] spoofing OS fingerprint → ${archetypeName}`,
|
|
||||||
`[SVC] starting services: ${services.join(', ') || '—'}`,
|
|
||||||
'[TLS] provisioning self-signed certs...',
|
|
||||||
'[SENSE] attaching syslog sinks to logging stack...',
|
|
||||||
`[OK] ${count} deckies online — fleet size now ${fleetSize + count}`,
|
|
||||||
];
|
|
||||||
|
|
||||||
// UTF-8-safe base64 encode (btoa alone breaks on non-ASCII).
|
|
||||||
const _b64encodeUtf8 = (s: string): string => {
|
|
||||||
const bytes = new TextEncoder().encode(s);
|
|
||||||
let bin = '';
|
|
||||||
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
|
|
||||||
return btoa(bin);
|
|
||||||
};
|
|
||||||
|
|
||||||
const _buildIni = (
|
|
||||||
prefix: string, count: number, fleetSize: number,
|
|
||||||
mode: PickMode, archetype: Archetype | null, services: string[],
|
|
||||||
mutate: boolean, mutateEvery: number,
|
|
||||||
serviceConfigs: Record<string, Record<string, unknown>>,
|
|
||||||
serviceSchemas: Record<string, SvcFieldDTO[]>,
|
|
||||||
): string => {
|
|
||||||
const lines: string[] = [];
|
|
||||||
for (let i = 0; i < count; i++) {
|
|
||||||
const name = `${prefix}-${String(fleetSize + i + 1).padStart(2, '0')}`;
|
|
||||||
lines.push(`[${name}]`);
|
|
||||||
if (mode === 'archetype' && archetype) {
|
|
||||||
lines.push(`archetype=${archetype.slug}`);
|
|
||||||
} else if (mode === 'services' && services.length) {
|
|
||||||
lines.push(`services=${services.join(',')}`);
|
|
||||||
}
|
|
||||||
if (mutate) lines.push(`mutate_interval=${mutateEvery}`);
|
|
||||||
lines.push('');
|
|
||||||
}
|
|
||||||
// Per-service overrides emitted as [<prefix>.<svc>] group subsections.
|
|
||||||
// The INI loader (decnet/ini_loader.py) prefix-matches these onto every
|
|
||||||
// ``${prefix}-NN`` decky in the batch, so one block covers all clones.
|
|
||||||
for (const svc of services) {
|
|
||||||
const cfg = serviceConfigs[svc];
|
|
||||||
if (!cfg || Object.keys(cfg).length === 0) continue;
|
|
||||||
const fieldTypes: Record<string, SvcFieldDTO['type']> = {};
|
|
||||||
for (const f of serviceSchemas[svc] ?? []) fieldTypes[f.key] = f.type;
|
|
||||||
lines.push(`[${prefix}.${svc}]`);
|
|
||||||
for (const [k, v] of Object.entries(cfg)) {
|
|
||||||
// textarea values may contain newlines that ConfigParser can't carry
|
|
||||||
// on a single line; wrap them in `b64:` so validate_cfg decodes back
|
|
||||||
// to the original UTF-8 string. Other types are emitted raw.
|
|
||||||
let serialised: string;
|
|
||||||
if (fieldTypes[k] === 'textarea' && typeof v === 'string') {
|
|
||||||
serialised = `b64:${_b64encodeUtf8(v)}`;
|
|
||||||
} else {
|
|
||||||
serialised = typeof v === 'string' ? v : String(v);
|
|
||||||
}
|
|
||||||
lines.push(`${k}=${serialised}`);
|
|
||||||
}
|
|
||||||
lines.push('');
|
|
||||||
}
|
|
||||||
return lines.join('\n');
|
|
||||||
};
|
|
||||||
|
|
||||||
const DeployWizard: React.FC<DeployWizardProps> = ({
|
|
||||||
open, onClose, onComplete, archetypes, fleetSize,
|
|
||||||
}) => {
|
|
||||||
const [step, setStep] = useState(0);
|
|
||||||
const [pickMode, setPickMode] = useState<PickMode>('archetype');
|
|
||||||
const [archetype, setArchetype] = useState<Archetype | null>(null);
|
|
||||||
const [selectedServices, setSelectedServices] = useState<string[]>([]);
|
|
||||||
const [prefix, setPrefix] = useState('decky');
|
|
||||||
const [count, setCount] = useState(3);
|
|
||||||
const [mutate, setMutate] = useState(true);
|
|
||||||
const [mutateEvery, setMutateEvery] = useState(30);
|
|
||||||
const [deploying, setDeploying] = useState(false);
|
|
||||||
const [log, setLog] = useState<string[]>([]);
|
|
||||||
const [deployErr, setDeployErr] = useState<string | null>(null);
|
|
||||||
// Per-service config dicts keyed by service slug. Edits flow into
|
|
||||||
// the INI as [<decky>.<svc>] subsections at deploy time so the
|
|
||||||
// initial container build picks them up — no follow-up apply needed.
|
|
||||||
const [serviceConfigs, setServiceConfigs] = useState<Record<string, SvcFormState>>({});
|
|
||||||
const [serviceSchemas, setServiceSchemas] = useState<Record<string, SvcFieldDTO[]>>({});
|
|
||||||
const [openSvcCfg, setOpenSvcCfg] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
setStep(0);
|
|
||||||
setPickMode('archetype');
|
|
||||||
setArchetype(null);
|
|
||||||
setSelectedServices([]);
|
|
||||||
setPrefix('decky');
|
|
||||||
setCount(3);
|
|
||||||
setMutate(true);
|
|
||||||
setMutateEvery(30);
|
|
||||||
setDeploying(false);
|
|
||||||
setLog([]);
|
|
||||||
setDeployErr(null);
|
|
||||||
setServiceConfigs({});
|
|
||||||
setServiceSchemas({});
|
|
||||||
setOpenSvcCfg(null);
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
const effectiveArchetypeName = archetype?.name
|
|
||||||
?? (pickMode === 'services' && selectedServices.length ? 'custom services' : 'linux-server');
|
|
||||||
const effectiveServices = pickMode === 'archetype'
|
|
||||||
? (archetype?.services ?? [])
|
|
||||||
: selectedServices;
|
|
||||||
|
|
||||||
// Drop config for services no longer in the selection so the INI
|
|
||||||
// doesn't carry orphaned subsections, and auto-collapse the open
|
|
||||||
// panel if its service got removed.
|
|
||||||
useEffect(() => {
|
|
||||||
setServiceConfigs((prev) => {
|
|
||||||
const allowed = new Set(effectiveServices);
|
|
||||||
const next: Record<string, SvcFormState> = {};
|
|
||||||
for (const [k, v] of Object.entries(prev)) if (allowed.has(k)) next[k] = v;
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
setOpenSvcCfg((cur) => (cur && effectiveServices.includes(cur) ? cur : null));
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [effectiveServices.join('|')]);
|
|
||||||
|
|
||||||
// Preview lines, count-aware (shows up to 6, with "…and N more" footer).
|
|
||||||
const previewLines = useMemo(() => {
|
|
||||||
const out: string[] = [];
|
|
||||||
const cap = Math.min(count, 6);
|
|
||||||
for (let i = 0; i < cap; i++) {
|
|
||||||
const name = `${prefix}-${String(fleetSize + i + 1).padStart(2, '0')}`;
|
|
||||||
out.push(`${name} → ${effectiveArchetypeName} [${effectiveServices.join(', ') || '—'}]`);
|
|
||||||
}
|
|
||||||
if (count > 6) out.push(`...and ${count - 6} more`);
|
|
||||||
return out;
|
|
||||||
}, [count, prefix, fleetSize, effectiveArchetypeName, effectiveServices]);
|
|
||||||
|
|
||||||
const [deployOk, setDeployOk] = useState(false);
|
|
||||||
const [deployFailures, setDeployFailures] = useState<string[]>([]);
|
|
||||||
|
|
||||||
// Fake log stream during "deploying" (runs as visual backdrop; real API
|
|
||||||
// lines are spliced in by startDeploy once the HTTP call resolves).
|
|
||||||
useEffect(() => {
|
|
||||||
if (step !== 3 || !deploying) return;
|
|
||||||
const msgs = PLACEHOLDER_LINES(effectiveArchetypeName, effectiveServices, count, fleetSize);
|
|
||||||
let i = 0;
|
|
||||||
const t = window.setInterval(() => {
|
|
||||||
setLog((prev) => [...prev, msgs[i]]);
|
|
||||||
i++;
|
|
||||||
if (i >= msgs.length) {
|
|
||||||
window.clearInterval(t);
|
|
||||||
// Only auto-close if the server accepted.
|
|
||||||
if (deployOk) {
|
|
||||||
window.setTimeout(() => onComplete(count), 500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 420);
|
|
||||||
return () => window.clearInterval(t);
|
|
||||||
}, [step, deploying, effectiveArchetypeName, effectiveServices, count, fleetSize, onComplete, deployOk]);
|
|
||||||
|
|
||||||
const canNext = step === 0
|
|
||||||
? (pickMode === 'archetype' ? !!archetype : selectedServices.length > 0)
|
|
||||||
: true;
|
|
||||||
|
|
||||||
const startDeploy = async () => {
|
|
||||||
setDeployErr(null);
|
|
||||||
setLog([]);
|
|
||||||
setDeployOk(false);
|
|
||||||
setDeployFailures([]);
|
|
||||||
setDeploying(true);
|
|
||||||
// Roll the per-service forms into the compact payload the server
|
|
||||||
// expects — empty values dropped, types coerced where the schema
|
|
||||||
// already pulled in primitives.
|
|
||||||
const rolled: Record<string, Record<string, unknown>> = {};
|
|
||||||
for (const svc of effectiveServices) {
|
|
||||||
const fields = serviceSchemas[svc];
|
|
||||||
const state = serviceConfigs[svc];
|
|
||||||
if (!fields || !state) continue;
|
|
||||||
const compact = svcCompactPayload(fields, state);
|
|
||||||
if (Object.keys(compact).length > 0) rolled[svc] = compact;
|
|
||||||
}
|
|
||||||
const servicesForIni = pickMode === 'archetype'
|
|
||||||
? (archetype?.services ?? [])
|
|
||||||
: selectedServices;
|
|
||||||
const ini = _buildIni(
|
|
||||||
prefix, count, fleetSize, pickMode, archetype, servicesForIni,
|
|
||||||
mutate, mutateEvery, rolled, serviceSchemas,
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
const res = await api.post<{ failures?: { name: string; reason: string }[] }>(
|
|
||||||
'/deckies/deploy',
|
|
||||||
{ ini_content: ini },
|
|
||||||
{ timeout: 180000 },
|
|
||||||
);
|
|
||||||
const failures = res.data?.failures ?? [];
|
|
||||||
setDeployFailures(failures.map(f => `[FAIL] ${f.name}: ${f.reason}`));
|
|
||||||
if (failures.length > 0) {
|
|
||||||
setLog(prev => [...prev, `[OK] server accepted ${count - failures.length}/${count}`,
|
|
||||||
...failures.map(f => `[FAIL] ${f.name}: ${f.reason}`)]);
|
|
||||||
} else {
|
|
||||||
setLog(prev => [...prev, `[OK] server accepted ${count} deckies`]);
|
|
||||||
}
|
|
||||||
setDeployOk(true);
|
|
||||||
} catch (e: unknown) {
|
|
||||||
const err = e as { response?: { data?: { detail?: string } }; message?: string };
|
|
||||||
setDeployErr(err?.response?.data?.detail || err?.message || 'Deploy failed');
|
|
||||||
setDeploying(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleService = (slug: string) => {
|
|
||||||
setSelectedServices((prev) =>
|
|
||||||
prev.includes(slug) ? prev.filter((s) => s !== slug) : [...prev, slug]);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
open={open}
|
|
||||||
onClose={onClose}
|
|
||||||
title="DEPLOY NEW DECKIES"
|
|
||||||
icon={PlusCircle}
|
|
||||||
accent="violet"
|
|
||||||
width="wide"
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
className="btn ghost"
|
|
||||||
onClick={onClose}
|
|
||||||
disabled={deploying && !deployOk}
|
|
||||||
>
|
|
||||||
{deployOk ? 'CLOSE' : 'CANCEL'}
|
|
||||||
</button>
|
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
|
||||||
{step > 0 && !deploying && (
|
|
||||||
<button className="btn ghost" onClick={() => setStep((s) => s - 1)}>← BACK</button>
|
|
||||||
)}
|
|
||||||
{step < 3 && (
|
|
||||||
<button className="btn" disabled={!canNext} onClick={() => setStep((s) => s + 1)}>
|
|
||||||
NEXT →
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{step === 3 && !deploying && (
|
|
||||||
<button className="btn violet" onClick={startDeploy}>ESTABLISH FLEET</button>
|
|
||||||
)}
|
|
||||||
{step === 3 && deploying && !deployOk && (
|
|
||||||
<button className="btn" disabled>DEPLOYING...</button>
|
|
||||||
)}
|
|
||||||
{step === 3 && deployOk && deployFailures.length > 0 && (
|
|
||||||
<button className="btn alert" disabled>{deployFailures.length} FAILED</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<>
|
|
||||||
<div className="wizard-steps">
|
|
||||||
{['ARCHETYPE', 'CONFIGURATION', 'MUTATION', 'DEPLOY'].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="wizard-subtabs">
|
|
||||||
<button
|
|
||||||
className={`wizard-subtab ${pickMode === 'archetype' ? 'active' : ''}`}
|
|
||||||
onClick={() => setPickMode('archetype')}
|
|
||||||
>
|
|
||||||
PICK ARCHETYPE
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`wizard-subtab ${pickMode === 'services' ? 'active' : ''}`}
|
|
||||||
onClick={() => setPickMode('services')}
|
|
||||||
>
|
|
||||||
PICK SERVICES
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{pickMode === 'archetype' ? (
|
|
||||||
<>
|
|
||||||
<div className="type-label">Pick the archetype the deckies should masquerade as.</div>
|
|
||||||
<div className="pick-grid">
|
|
||||||
{archetypes.map((a) => (
|
|
||||||
<button
|
|
||||||
key={a.slug}
|
|
||||||
className={`pick-card ${archetype?.slug === a.slug ? 'active' : ''}`}
|
|
||||||
onClick={() => setArchetype(a)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<div className="pc-title">
|
|
||||||
<PickIcon name={a.icon} size={16} className="violet-accent" />
|
|
||||||
<span>{a.name}</span>
|
|
||||||
</div>
|
|
||||||
<div className="pc-slug">{a.slug}</div>
|
|
||||||
<div className="pc-services">
|
|
||||||
{a.services.map((s) => <span key={s} className="service-tag">{s}</span>)}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="type-label">
|
|
||||||
Pick individual services. Every selected decky will expose the same set.
|
|
||||||
</div>
|
|
||||||
<div className="pick-grid">
|
|
||||||
{DEFAULT_SERVICES.map((s) => {
|
|
||||||
const on = selectedServices.includes(s.slug);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={s.slug}
|
|
||||||
className={`pick-card ${on ? 'active' : ''}`}
|
|
||||||
onClick={() => toggleService(s.slug)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<div className="pc-title">
|
|
||||||
<PickIcon name={s.icon} size={14} className="violet-accent" />
|
|
||||||
<span>{s.name}</span>
|
|
||||||
</div>
|
|
||||||
<div className="pc-slug">
|
|
||||||
{s.proto.toUpperCase()} · {s.port} · risk={s.risk}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 1 && (
|
|
||||||
<>
|
|
||||||
<div className="type-label">How many, and what to call them.</div>
|
|
||||||
<div className="grid-2">
|
|
||||||
<div className="tweak-group">
|
|
||||||
<label>PREFIX</label>
|
|
||||||
<input
|
|
||||||
className="input"
|
|
||||||
value={prefix}
|
|
||||||
onChange={(e) => setPrefix(e.target.value.replace(/\s+/g, '-'))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="tweak-group">
|
|
||||||
<label>COUNT ({count})</label>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={1}
|
|
||||||
max={50}
|
|
||||||
value={count}
|
|
||||||
onChange={(e) => setCount(parseInt(e.target.value, 10))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{effectiveServices.length > 0 && (
|
|
||||||
<div className="tweak-group">
|
|
||||||
<label>PER-SERVICE CONFIG</label>
|
|
||||||
<div className="dim" style={{ fontSize: '0.62rem', letterSpacing: 1, marginBottom: 6 }}>
|
|
||||||
Click a service to set passwords, banners, response codes, TLS
|
|
||||||
material — applied to every decky in this batch via INI
|
|
||||||
subsections.
|
|
||||||
</div>
|
|
||||||
<div className="wizard-svc-list">
|
|
||||||
{effectiveServices.map((svc) => {
|
|
||||||
const open = openSvcCfg === svc;
|
|
||||||
const overrideCount = Object.values(serviceConfigs[svc] ?? {})
|
|
||||||
.filter((v) => v !== '' && v !== undefined && v !== null && v !== false)
|
|
||||||
.length;
|
|
||||||
return (
|
|
||||||
<div key={svc} className="wizard-svc-block">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`wizard-svc-toggle ${open ? 'open' : ''}`}
|
|
||||||
onClick={() => setOpenSvcCfg(open ? null : svc)}
|
|
||||||
>
|
|
||||||
<span className="wizard-svc-caret">{open ? '▾' : '▸'}</span>
|
|
||||||
<span className="wizard-svc-name">{svc}</span>
|
|
||||||
{overrideCount > 0 && (
|
|
||||||
<span className="wizard-svc-badge">{overrideCount} set</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
{open && (
|
|
||||||
<div className="wizard-svc-fields">
|
|
||||||
<ServiceConfigFields
|
|
||||||
serviceSlug={svc}
|
|
||||||
idScope={`wizard-${svc}`}
|
|
||||||
value={serviceConfigs[svc] ?? {}}
|
|
||||||
onChange={(next) =>
|
|
||||||
setServiceConfigs((s) => ({ ...s, [svc]: next }))}
|
|
||||||
onSchema={(sch) =>
|
|
||||||
setServiceSchemas((s) => ({ ...s, [svc]: sch.fields }))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="code-block">
|
|
||||||
<span className="comment"># preview: deckies that will come online</span>
|
|
||||||
{'\n'}
|
|
||||||
{previewLines.join('\n')}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 2 && (
|
|
||||||
<>
|
|
||||||
<div className="type-label">
|
|
||||||
Mutation rotates MAC / IP / hostname so attackers can't re-target.
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex', gap: 10, alignItems: 'center',
|
|
||||||
padding: 14, border: '1px solid var(--border)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
id="mut"
|
|
||||||
type="checkbox"
|
|
||||||
checked={mutate}
|
|
||||||
onChange={(e) => setMutate(e.target.checked)}
|
|
||||||
style={{ accentColor: 'var(--matrix)' }}
|
|
||||||
/>
|
|
||||||
<label htmlFor="mut" style={{ fontSize: '0.8rem', letterSpacing: 1 }}>
|
|
||||||
ENABLE PERIODIC MUTATION
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
{mutate && (
|
|
||||||
<div className="tweak-group">
|
|
||||||
<label>INTERVAL ({mutateEvery} minutes)</label>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={5}
|
|
||||||
max={240}
|
|
||||||
step={5}
|
|
||||||
value={mutateEvery}
|
|
||||||
onChange={(e) => setMutateEvery(parseInt(e.target.value, 10))}
|
|
||||||
/>
|
|
||||||
<div className="dim" style={{ fontSize: '0.65rem', letterSpacing: 1 }}>
|
|
||||||
Next mutation will occur {mutateEvery}m after deploy.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 3 && (
|
|
||||||
<>
|
|
||||||
<div className="type-label">
|
|
||||||
{!deploying
|
|
||||||
? 'Ready to deploy. This will write to the fleet and start the listener.'
|
|
||||||
: 'Deploying...'}
|
|
||||||
</div>
|
|
||||||
<div className="code-block" style={{ minHeight: 180 }}>
|
|
||||||
{log.length === 0 && !deploying && (
|
|
||||||
<>
|
|
||||||
<span className="comment"># decnet deploy \</span>{'\n'}
|
|
||||||
{pickMode === 'archetype' && archetype && (
|
|
||||||
<>
|
|
||||||
<span className="key"> --archetype</span>{' '}
|
|
||||||
<span className="str">{archetype.slug}</span>{' \\'}{'\n'}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<span className="key"> --count</span>{' '}
|
|
||||||
<span className="str">{count}</span>{' \\'}{'\n'}
|
|
||||||
<span className="key"> --prefix</span>{' '}
|
|
||||||
<span className="str">{prefix}</span>{' \\'}{'\n'}
|
|
||||||
<span className="key"> --mutate</span>{' '}
|
|
||||||
<span className="str">{mutate ? `${mutateEvery}m` : 'off'}</span>
|
|
||||||
{pickMode === 'services' && selectedServices.length > 0 && (
|
|
||||||
<>
|
|
||||||
{' \\'}{'\n'}
|
|
||||||
<span className="key"> --services</span>{' '}
|
|
||||||
<span className="str">{selectedServices.join(',')}</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{log.map((l, i) => <div key={i}>{l}</div>)}
|
|
||||||
{deploying && log.length < 7 && <span className="replay-cursor" />}
|
|
||||||
</div>
|
|
||||||
{deployErr && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
border: '1px solid var(--alert)',
|
|
||||||
color: 'var(--alert)',
|
|
||||||
padding: '8px 12px',
|
|
||||||
fontSize: '0.75rem',
|
|
||||||
letterSpacing: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
✖ {deployErr}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
import { IntervalEditor } from './DeckyFleet/IntervalEditor';
|
import { IntervalEditor } from './DeckyFleet/IntervalEditor';
|
||||||
|
|
||||||
// ─── Fleet page ──────────────────────────────────────────────────────────
|
// ─── Fleet page ──────────────────────────────────────────────────────────
|
||||||
|
|||||||
84
decnet_web/src/components/DeckyFleet/DeployWizard.test.tsx
Normal file
84
decnet_web/src/components/DeckyFleet/DeployWizard.test.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { DeployWizard } from './DeployWizard';
|
||||||
|
import type { Archetype } from './types';
|
||||||
|
|
||||||
|
// ServiceConfigFields fetches the per-service schema; replace with a stub
|
||||||
|
// so the wizard tests don't need MSW handlers for that side-channel.
|
||||||
|
vi.mock('../ServiceConfigFields', async () => {
|
||||||
|
const actual = await vi.importActual<object>('../ServiceConfigFields');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
default: () => null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const archetypes: Archetype[] = [
|
||||||
|
{ slug: 'web-server', name: 'Web Server', services: ['http', 'https'], icon: 'globe' },
|
||||||
|
{ slug: 'database', name: 'Database', services: ['postgres'], icon: 'database' },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('DeployWizard', () => {
|
||||||
|
it('renders nothing meaningful when closed', () => {
|
||||||
|
render(
|
||||||
|
<DeployWizard
|
||||||
|
open={false}
|
||||||
|
onClose={() => {}}
|
||||||
|
onComplete={() => {}}
|
||||||
|
archetypes={archetypes}
|
||||||
|
fleetSize={0}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.queryByText('DEPLOY NEW DECKIES')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens at step 0 with archetype list rendered', () => {
|
||||||
|
render(
|
||||||
|
<DeployWizard
|
||||||
|
open
|
||||||
|
onClose={() => {}}
|
||||||
|
onComplete={() => {}}
|
||||||
|
archetypes={archetypes}
|
||||||
|
fleetSize={0}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('DEPLOY NEW DECKIES')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Web Server')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Database')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables NEXT until an archetype is selected', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<DeployWizard
|
||||||
|
open
|
||||||
|
onClose={() => {}}
|
||||||
|
onComplete={() => {}}
|
||||||
|
archetypes={archetypes}
|
||||||
|
fleetSize={0}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const nextBtn = screen.getByText('NEXT →') as HTMLButtonElement;
|
||||||
|
expect(nextBtn.disabled).toBe(true);
|
||||||
|
|
||||||
|
await user.click(screen.getByText('Web Server'));
|
||||||
|
expect(nextBtn.disabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CANCEL button invokes onClose', async () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<DeployWizard
|
||||||
|
open
|
||||||
|
onClose={onClose}
|
||||||
|
onComplete={() => {}}
|
||||||
|
archetypes={archetypes}
|
||||||
|
fleetSize={0}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await user.click(screen.getByText('CANCEL'));
|
||||||
|
expect(onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
538
decnet_web/src/components/DeckyFleet/DeployWizard.tsx
Normal file
538
decnet_web/src/components/DeckyFleet/DeployWizard.tsx
Normal file
@@ -0,0 +1,538 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { PlusCircle } from '../../icons';
|
||||||
|
import api from '../../utils/api';
|
||||||
|
import Modal from '../Modal/Modal';
|
||||||
|
import { DEFAULT_SERVICES } from '../MazeNET/data';
|
||||||
|
import ServiceConfigFields, {
|
||||||
|
type FormState as SvcFormState,
|
||||||
|
type ServiceConfigFieldDTO as SvcFieldDTO,
|
||||||
|
compactPayload as svcCompactPayload,
|
||||||
|
} from '../ServiceConfigFields';
|
||||||
|
import { PickIcon } from './helpers';
|
||||||
|
import type { Archetype } from './types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onComplete: (count: number) => void;
|
||||||
|
archetypes: Archetype[];
|
||||||
|
fleetSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PickMode = 'archetype' | 'services';
|
||||||
|
|
||||||
|
const PLACEHOLDER_LINES = (
|
||||||
|
archetypeName: string, services: string[], count: number, fleetSize: number,
|
||||||
|
): string[] => [
|
||||||
|
'[INIT] allocating MAC addresses...',
|
||||||
|
'[NET] binding macvlan interfaces...',
|
||||||
|
`[FP] spoofing OS fingerprint → ${archetypeName}`,
|
||||||
|
`[SVC] starting services: ${services.join(', ') || '—'}`,
|
||||||
|
'[TLS] provisioning self-signed certs...',
|
||||||
|
'[SENSE] attaching syslog sinks to logging stack...',
|
||||||
|
`[OK] ${count} deckies online — fleet size now ${fleetSize + count}`,
|
||||||
|
];
|
||||||
|
|
||||||
|
// UTF-8-safe base64 encode (btoa alone breaks on non-ASCII).
|
||||||
|
const b64encodeUtf8 = (s: string): string => {
|
||||||
|
const bytes = new TextEncoder().encode(s);
|
||||||
|
let bin = '';
|
||||||
|
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
|
||||||
|
return btoa(bin);
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildIni = (
|
||||||
|
prefix: string, count: number, fleetSize: number,
|
||||||
|
mode: PickMode, archetype: Archetype | null, services: string[],
|
||||||
|
mutate: boolean, mutateEvery: number,
|
||||||
|
serviceConfigs: Record<string, Record<string, unknown>>,
|
||||||
|
serviceSchemas: Record<string, SvcFieldDTO[]>,
|
||||||
|
): string => {
|
||||||
|
const lines: string[] = [];
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const name = `${prefix}-${String(fleetSize + i + 1).padStart(2, '0')}`;
|
||||||
|
lines.push(`[${name}]`);
|
||||||
|
if (mode === 'archetype' && archetype) {
|
||||||
|
lines.push(`archetype=${archetype.slug}`);
|
||||||
|
} else if (mode === 'services' && services.length) {
|
||||||
|
lines.push(`services=${services.join(',')}`);
|
||||||
|
}
|
||||||
|
if (mutate) lines.push(`mutate_interval=${mutateEvery}`);
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
// Per-service overrides emitted as [<prefix>.<svc>] group subsections.
|
||||||
|
// The INI loader (decnet/ini_loader.py) prefix-matches these onto every
|
||||||
|
// ``${prefix}-NN`` decky in the batch, so one block covers all clones.
|
||||||
|
for (const svc of services) {
|
||||||
|
const cfg = serviceConfigs[svc];
|
||||||
|
if (!cfg || Object.keys(cfg).length === 0) continue;
|
||||||
|
const fieldTypes: Record<string, SvcFieldDTO['type']> = {};
|
||||||
|
for (const f of serviceSchemas[svc] ?? []) fieldTypes[f.key] = f.type;
|
||||||
|
lines.push(`[${prefix}.${svc}]`);
|
||||||
|
for (const [k, v] of Object.entries(cfg)) {
|
||||||
|
// textarea values may contain newlines that ConfigParser can't carry
|
||||||
|
// on a single line; wrap them in `b64:` so validate_cfg decodes back
|
||||||
|
// to the original UTF-8 string. Other types are emitted raw.
|
||||||
|
let serialised: string;
|
||||||
|
if (fieldTypes[k] === 'textarea' && typeof v === 'string') {
|
||||||
|
serialised = `b64:${b64encodeUtf8(v)}`;
|
||||||
|
} else {
|
||||||
|
serialised = typeof v === 'string' ? v : String(v);
|
||||||
|
}
|
||||||
|
lines.push(`${k}=${serialised}`);
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Multi-step deploy wizard for the fleet. Steps:
|
||||||
|
* 0 ARCHETYPE - pick archetype OR pick individual services
|
||||||
|
* 1 CONFIGURATION - prefix + count + per-service overrides
|
||||||
|
* 2 MUTATION - enable + interval slider
|
||||||
|
* 3 DEPLOY - preview command, fire POST /deckies/deploy
|
||||||
|
*/
|
||||||
|
export const DeployWizard: React.FC<Props> = ({
|
||||||
|
open, onClose, onComplete, archetypes, fleetSize,
|
||||||
|
}) => {
|
||||||
|
const [step, setStep] = useState(0);
|
||||||
|
const [pickMode, setPickMode] = useState<PickMode>('archetype');
|
||||||
|
const [archetype, setArchetype] = useState<Archetype | null>(null);
|
||||||
|
const [selectedServices, setSelectedServices] = useState<string[]>([]);
|
||||||
|
const [prefix, setPrefix] = useState('decky');
|
||||||
|
const [count, setCount] = useState(3);
|
||||||
|
const [mutate, setMutate] = useState(true);
|
||||||
|
const [mutateEvery, setMutateEvery] = useState(30);
|
||||||
|
const [deploying, setDeploying] = useState(false);
|
||||||
|
const [log, setLog] = useState<string[]>([]);
|
||||||
|
const [deployErr, setDeployErr] = useState<string | null>(null);
|
||||||
|
// Per-service config dicts keyed by service slug. Edits flow into
|
||||||
|
// the INI as [<decky>.<svc>] subsections at deploy time so the
|
||||||
|
// initial container build picks them up — no follow-up apply needed.
|
||||||
|
const [serviceConfigs, setServiceConfigs] = useState<Record<string, SvcFormState>>({});
|
||||||
|
const [serviceSchemas, setServiceSchemas] = useState<Record<string, SvcFieldDTO[]>>({});
|
||||||
|
const [openSvcCfg, setOpenSvcCfg] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setStep(0);
|
||||||
|
setPickMode('archetype');
|
||||||
|
setArchetype(null);
|
||||||
|
setSelectedServices([]);
|
||||||
|
setPrefix('decky');
|
||||||
|
setCount(3);
|
||||||
|
setMutate(true);
|
||||||
|
setMutateEvery(30);
|
||||||
|
setDeploying(false);
|
||||||
|
setLog([]);
|
||||||
|
setDeployErr(null);
|
||||||
|
setServiceConfigs({});
|
||||||
|
setServiceSchemas({});
|
||||||
|
setOpenSvcCfg(null);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const effectiveArchetypeName = archetype?.name
|
||||||
|
?? (pickMode === 'services' && selectedServices.length ? 'custom services' : 'linux-server');
|
||||||
|
const effectiveServices = pickMode === 'archetype'
|
||||||
|
? (archetype?.services ?? [])
|
||||||
|
: selectedServices;
|
||||||
|
|
||||||
|
// Drop config for services no longer in the selection so the INI
|
||||||
|
// doesn't carry orphaned subsections, and auto-collapse the open
|
||||||
|
// panel if its service got removed.
|
||||||
|
useEffect(() => {
|
||||||
|
setServiceConfigs((prev) => {
|
||||||
|
const allowed = new Set(effectiveServices);
|
||||||
|
const next: Record<string, SvcFormState> = {};
|
||||||
|
for (const [k, v] of Object.entries(prev)) if (allowed.has(k)) next[k] = v;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setOpenSvcCfg((cur) => (cur && effectiveServices.includes(cur) ? cur : null));
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [effectiveServices.join('|')]);
|
||||||
|
|
||||||
|
// Preview lines, count-aware (shows up to 6, with "…and N more" footer).
|
||||||
|
const previewLines = useMemo(() => {
|
||||||
|
const out: string[] = [];
|
||||||
|
const cap = Math.min(count, 6);
|
||||||
|
for (let i = 0; i < cap; i++) {
|
||||||
|
const name = `${prefix}-${String(fleetSize + i + 1).padStart(2, '0')}`;
|
||||||
|
out.push(`${name} → ${effectiveArchetypeName} [${effectiveServices.join(', ') || '—'}]`);
|
||||||
|
}
|
||||||
|
if (count > 6) out.push(`...and ${count - 6} more`);
|
||||||
|
return out;
|
||||||
|
}, [count, prefix, fleetSize, effectiveArchetypeName, effectiveServices]);
|
||||||
|
|
||||||
|
const [deployOk, setDeployOk] = useState(false);
|
||||||
|
const [deployFailures, setDeployFailures] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// Fake log stream during "deploying" (runs as visual backdrop; real API
|
||||||
|
// lines are spliced in by startDeploy once the HTTP call resolves).
|
||||||
|
useEffect(() => {
|
||||||
|
if (step !== 3 || !deploying) return;
|
||||||
|
const msgs = PLACEHOLDER_LINES(effectiveArchetypeName, effectiveServices, count, fleetSize);
|
||||||
|
let i = 0;
|
||||||
|
const t = window.setInterval(() => {
|
||||||
|
setLog((prev) => [...prev, msgs[i]]);
|
||||||
|
i++;
|
||||||
|
if (i >= msgs.length) {
|
||||||
|
window.clearInterval(t);
|
||||||
|
// Only auto-close if the server accepted.
|
||||||
|
if (deployOk) {
|
||||||
|
window.setTimeout(() => onComplete(count), 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 420);
|
||||||
|
return () => window.clearInterval(t);
|
||||||
|
}, [step, deploying, effectiveArchetypeName, effectiveServices, count, fleetSize, onComplete, deployOk]);
|
||||||
|
|
||||||
|
const canNext = step === 0
|
||||||
|
? (pickMode === 'archetype' ? !!archetype : selectedServices.length > 0)
|
||||||
|
: true;
|
||||||
|
|
||||||
|
const startDeploy = async () => {
|
||||||
|
setDeployErr(null);
|
||||||
|
setLog([]);
|
||||||
|
setDeployOk(false);
|
||||||
|
setDeployFailures([]);
|
||||||
|
setDeploying(true);
|
||||||
|
// Roll the per-service forms into the compact payload the server
|
||||||
|
// expects — empty values dropped, types coerced where the schema
|
||||||
|
// already pulled in primitives.
|
||||||
|
const rolled: Record<string, Record<string, unknown>> = {};
|
||||||
|
for (const svc of effectiveServices) {
|
||||||
|
const fields = serviceSchemas[svc];
|
||||||
|
const state = serviceConfigs[svc];
|
||||||
|
if (!fields || !state) continue;
|
||||||
|
const compact = svcCompactPayload(fields, state);
|
||||||
|
if (Object.keys(compact).length > 0) rolled[svc] = compact;
|
||||||
|
}
|
||||||
|
const servicesForIni = pickMode === 'archetype'
|
||||||
|
? (archetype?.services ?? [])
|
||||||
|
: selectedServices;
|
||||||
|
const ini = buildIni(
|
||||||
|
prefix, count, fleetSize, pickMode, archetype, servicesForIni,
|
||||||
|
mutate, mutateEvery, rolled, serviceSchemas,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const res = await api.post<{ failures?: { name: string; reason: string }[] }>(
|
||||||
|
'/deckies/deploy',
|
||||||
|
{ ini_content: ini },
|
||||||
|
{ timeout: 180000 },
|
||||||
|
);
|
||||||
|
const failures = res.data?.failures ?? [];
|
||||||
|
setDeployFailures(failures.map(f => `[FAIL] ${f.name}: ${f.reason}`));
|
||||||
|
if (failures.length > 0) {
|
||||||
|
setLog(prev => [...prev, `[OK] server accepted ${count - failures.length}/${count}`,
|
||||||
|
...failures.map(f => `[FAIL] ${f.name}: ${f.reason}`)]);
|
||||||
|
} else {
|
||||||
|
setLog(prev => [...prev, `[OK] server accepted ${count} deckies`]);
|
||||||
|
}
|
||||||
|
setDeployOk(true);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const err = e as { response?: { data?: { detail?: string } }; message?: string };
|
||||||
|
setDeployErr(err?.response?.data?.detail || err?.message || 'Deploy failed');
|
||||||
|
setDeploying(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleService = (slug: string) => {
|
||||||
|
setSelectedServices((prev) =>
|
||||||
|
prev.includes(slug) ? prev.filter((s) => s !== slug) : [...prev, slug]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
title="DEPLOY NEW DECKIES"
|
||||||
|
icon={PlusCircle}
|
||||||
|
accent="violet"
|
||||||
|
width="wide"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="btn ghost"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={deploying && !deployOk}
|
||||||
|
>
|
||||||
|
{deployOk ? 'CLOSE' : 'CANCEL'}
|
||||||
|
</button>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
{step > 0 && !deploying && (
|
||||||
|
<button className="btn ghost" onClick={() => setStep((s) => s - 1)}>← BACK</button>
|
||||||
|
)}
|
||||||
|
{step < 3 && (
|
||||||
|
<button className="btn" disabled={!canNext} onClick={() => setStep((s) => s + 1)}>
|
||||||
|
NEXT →
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{step === 3 && !deploying && (
|
||||||
|
<button className="btn violet" onClick={startDeploy}>ESTABLISH FLEET</button>
|
||||||
|
)}
|
||||||
|
{step === 3 && deploying && !deployOk && (
|
||||||
|
<button className="btn" disabled>DEPLOYING...</button>
|
||||||
|
)}
|
||||||
|
{step === 3 && deployOk && deployFailures.length > 0 && (
|
||||||
|
<button className="btn alert" disabled>{deployFailures.length} FAILED</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<>
|
||||||
|
<div className="wizard-steps">
|
||||||
|
{['ARCHETYPE', 'CONFIGURATION', 'MUTATION', 'DEPLOY'].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="wizard-subtabs">
|
||||||
|
<button
|
||||||
|
className={`wizard-subtab ${pickMode === 'archetype' ? 'active' : ''}`}
|
||||||
|
onClick={() => setPickMode('archetype')}
|
||||||
|
>
|
||||||
|
PICK ARCHETYPE
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`wizard-subtab ${pickMode === 'services' ? 'active' : ''}`}
|
||||||
|
onClick={() => setPickMode('services')}
|
||||||
|
>
|
||||||
|
PICK SERVICES
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{pickMode === 'archetype' ? (
|
||||||
|
<>
|
||||||
|
<div className="type-label">Pick the archetype the deckies should masquerade as.</div>
|
||||||
|
<div className="pick-grid">
|
||||||
|
{archetypes.map((a) => (
|
||||||
|
<button
|
||||||
|
key={a.slug}
|
||||||
|
className={`pick-card ${archetype?.slug === a.slug ? 'active' : ''}`}
|
||||||
|
onClick={() => setArchetype(a)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<div className="pc-title">
|
||||||
|
<PickIcon name={a.icon} size={16} className="violet-accent" />
|
||||||
|
<span>{a.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="pc-slug">{a.slug}</div>
|
||||||
|
<div className="pc-services">
|
||||||
|
{a.services.map((s) => <span key={s} className="service-tag">{s}</span>)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="type-label">
|
||||||
|
Pick individual services. Every selected decky will expose the same set.
|
||||||
|
</div>
|
||||||
|
<div className="pick-grid">
|
||||||
|
{DEFAULT_SERVICES.map((s) => {
|
||||||
|
const on = selectedServices.includes(s.slug);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={s.slug}
|
||||||
|
className={`pick-card ${on ? 'active' : ''}`}
|
||||||
|
onClick={() => toggleService(s.slug)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<div className="pc-title">
|
||||||
|
<PickIcon name={s.icon} size={14} className="violet-accent" />
|
||||||
|
<span>{s.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="pc-slug">
|
||||||
|
{s.proto.toUpperCase()} · {s.port} · risk={s.risk}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 1 && (
|
||||||
|
<>
|
||||||
|
<div className="type-label">How many, and what to call them.</div>
|
||||||
|
<div className="grid-2">
|
||||||
|
<div className="tweak-group">
|
||||||
|
<label>PREFIX</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={prefix}
|
||||||
|
onChange={(e) => setPrefix(e.target.value.replace(/\s+/g, '-'))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="tweak-group">
|
||||||
|
<label>COUNT ({count})</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={1}
|
||||||
|
max={50}
|
||||||
|
value={count}
|
||||||
|
onChange={(e) => setCount(parseInt(e.target.value, 10))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{effectiveServices.length > 0 && (
|
||||||
|
<div className="tweak-group">
|
||||||
|
<label>PER-SERVICE CONFIG</label>
|
||||||
|
<div className="dim" style={{ fontSize: '0.62rem', letterSpacing: 1, marginBottom: 6 }}>
|
||||||
|
Click a service to set passwords, banners, response codes, TLS
|
||||||
|
material — applied to every decky in this batch via INI
|
||||||
|
subsections.
|
||||||
|
</div>
|
||||||
|
<div className="wizard-svc-list">
|
||||||
|
{effectiveServices.map((svc) => {
|
||||||
|
const open = openSvcCfg === svc;
|
||||||
|
const overrideCount = Object.values(serviceConfigs[svc] ?? {})
|
||||||
|
.filter((v) => v !== '' && v !== undefined && v !== null && v !== false)
|
||||||
|
.length;
|
||||||
|
return (
|
||||||
|
<div key={svc} className="wizard-svc-block">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`wizard-svc-toggle ${open ? 'open' : ''}`}
|
||||||
|
onClick={() => setOpenSvcCfg(open ? null : svc)}
|
||||||
|
>
|
||||||
|
<span className="wizard-svc-caret">{open ? '▾' : '▸'}</span>
|
||||||
|
<span className="wizard-svc-name">{svc}</span>
|
||||||
|
{overrideCount > 0 && (
|
||||||
|
<span className="wizard-svc-badge">{overrideCount} set</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="wizard-svc-fields">
|
||||||
|
<ServiceConfigFields
|
||||||
|
serviceSlug={svc}
|
||||||
|
idScope={`wizard-${svc}`}
|
||||||
|
value={serviceConfigs[svc] ?? {}}
|
||||||
|
onChange={(next) =>
|
||||||
|
setServiceConfigs((s) => ({ ...s, [svc]: next }))}
|
||||||
|
onSchema={(sch) =>
|
||||||
|
setServiceSchemas((s) => ({ ...s, [svc]: sch.fields }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="code-block">
|
||||||
|
<span className="comment"># preview: deckies that will come online</span>
|
||||||
|
{'\n'}
|
||||||
|
{previewLines.join('\n')}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 2 && (
|
||||||
|
<>
|
||||||
|
<div className="type-label">
|
||||||
|
Mutation rotates MAC / IP / hostname so attackers can't re-target.
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex', gap: 10, alignItems: 'center',
|
||||||
|
padding: 14, border: '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="mut"
|
||||||
|
type="checkbox"
|
||||||
|
checked={mutate}
|
||||||
|
onChange={(e) => setMutate(e.target.checked)}
|
||||||
|
style={{ accentColor: 'var(--matrix)' }}
|
||||||
|
/>
|
||||||
|
<label htmlFor="mut" style={{ fontSize: '0.8rem', letterSpacing: 1 }}>
|
||||||
|
ENABLE PERIODIC MUTATION
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{mutate && (
|
||||||
|
<div className="tweak-group">
|
||||||
|
<label>INTERVAL ({mutateEvery} minutes)</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={5}
|
||||||
|
max={240}
|
||||||
|
step={5}
|
||||||
|
value={mutateEvery}
|
||||||
|
onChange={(e) => setMutateEvery(parseInt(e.target.value, 10))}
|
||||||
|
/>
|
||||||
|
<div className="dim" style={{ fontSize: '0.65rem', letterSpacing: 1 }}>
|
||||||
|
Next mutation will occur {mutateEvery}m after deploy.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 3 && (
|
||||||
|
<>
|
||||||
|
<div className="type-label">
|
||||||
|
{!deploying
|
||||||
|
? 'Ready to deploy. This will write to the fleet and start the listener.'
|
||||||
|
: 'Deploying...'}
|
||||||
|
</div>
|
||||||
|
<div className="code-block" style={{ minHeight: 180 }}>
|
||||||
|
{log.length === 0 && !deploying && (
|
||||||
|
<>
|
||||||
|
<span className="comment"># decnet deploy \</span>{'\n'}
|
||||||
|
{pickMode === 'archetype' && archetype && (
|
||||||
|
<>
|
||||||
|
<span className="key"> --archetype</span>{' '}
|
||||||
|
<span className="str">{archetype.slug}</span>{' \\'}{'\n'}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span className="key"> --count</span>{' '}
|
||||||
|
<span className="str">{count}</span>{' \\'}{'\n'}
|
||||||
|
<span className="key"> --prefix</span>{' '}
|
||||||
|
<span className="str">{prefix}</span>{' \\'}{'\n'}
|
||||||
|
<span className="key"> --mutate</span>{' '}
|
||||||
|
<span className="str">{mutate ? `${mutateEvery}m` : 'off'}</span>
|
||||||
|
{pickMode === 'services' && selectedServices.length > 0 && (
|
||||||
|
<>
|
||||||
|
{' \\'}{'\n'}
|
||||||
|
<span className="key"> --services</span>{' '}
|
||||||
|
<span className="str">{selectedServices.join(',')}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{log.map((l, i) => <div key={i}>{l}</div>)}
|
||||||
|
{deploying && log.length < 7 && <span className="replay-cursor" />}
|
||||||
|
</div>
|
||||||
|
{deployErr && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: '1px solid var(--alert)',
|
||||||
|
color: 'var(--alert)',
|
||||||
|
padding: '8px 12px',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
letterSpacing: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✖ {deployErr}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user