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:
2026-05-09 05:01:33 -04:00
parent 849caffaf1
commit 1e2bc41ab1
3 changed files with 624 additions and 534 deletions

View File

@@ -1,15 +1,9 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { PlusCircle, Server } from '../icons';
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 Modal from './Modal/Modal';
import { useServiceRegistry } from '../hooks/useServiceRegistry';
import ServiceConfigFields, {
type FormState as SvcFormState,
type ServiceConfigFieldDTO as SvcFieldDTO,
compactPayload as svcCompactPayload,
} from './ServiceConfigFields';
import './DeckyFleet.css';
import type {
Decky,
@@ -19,537 +13,11 @@ import type {
} from './DeckyFleet/types';
import {
archetypeIcon as _archetypeIcon,
PickIcon,
dotFor as _dotFor,
hitsFor as _hitsFor,
stateColor as _stateColor,
} from './DeckyFleet/helpers';
import { DeckyInspectPanel } from './DeckyFleet/DeckyInspectPanel';
import { DeckyCard } from './DeckyFleet/DeckyCard';
// ─── 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 { DeployWizard } from './DeckyFleet/DeployWizard';
import { IntervalEditor } from './DeckyFleet/IntervalEditor';
// ─── Fleet page ──────────────────────────────────────────────────────────

View 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();
});
});

View 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>
);
};