diff --git a/decnet_web/src/components/DeckyFleet.tsx b/decnet_web/src/components/DeckyFleet.tsx index 3bac8af1..e9d53641 100644 --- a/decnet_web/src/components/DeckyFleet.tsx +++ b/decnet_web/src/components/DeckyFleet.tsx @@ -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>, - serviceSchemas: Record, -): 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 [.] 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 = {}; - 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 = ({ - open, onClose, onComplete, archetypes, fleetSize, -}) => { - const [step, setStep] = useState(0); - const [pickMode, setPickMode] = useState('archetype'); - const [archetype, setArchetype] = useState(null); - const [selectedServices, setSelectedServices] = useState([]); - 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([]); - const [deployErr, setDeployErr] = useState(null); - // Per-service config dicts keyed by service slug. Edits flow into - // the INI as [.] subsections at deploy time so the - // initial container build picks them up — no follow-up apply needed. - const [serviceConfigs, setServiceConfigs] = useState>({}); - const [serviceSchemas, setServiceSchemas] = useState>({}); - const [openSvcCfg, setOpenSvcCfg] = useState(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 = {}; - 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([]); - - // 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> = {}; - 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 ( - - -
- {step > 0 && !deploying && ( - - )} - {step < 3 && ( - - )} - {step === 3 && !deploying && ( - - )} - {step === 3 && deploying && !deployOk && ( - - )} - {step === 3 && deployOk && deployFailures.length > 0 && ( - - )} -
- - } - > - <> -
- {['ARCHETYPE', 'CONFIGURATION', 'MUTATION', 'DEPLOY'].map((l, i) => ( -
- {i + 1}. {l} -
- ))} -
- -
- {step === 0 && ( - <> -
- - -
- {pickMode === 'archetype' ? ( - <> -
Pick the archetype the deckies should masquerade as.
-
- {archetypes.map((a) => ( - - ))} -
- - ) : ( - <> -
- Pick individual services. Every selected decky will expose the same set. -
-
- {DEFAULT_SERVICES.map((s) => { - const on = selectedServices.includes(s.slug); - return ( - - ); - })} -
- - )} - - )} - - {step === 1 && ( - <> -
How many, and what to call them.
-
-
- - setPrefix(e.target.value.replace(/\s+/g, '-'))} - /> -
-
- - setCount(parseInt(e.target.value, 10))} - /> -
-
- - {effectiveServices.length > 0 && ( -
- -
- Click a service to set passwords, banners, response codes, TLS - material — applied to every decky in this batch via INI - subsections. -
-
- {effectiveServices.map((svc) => { - const open = openSvcCfg === svc; - const overrideCount = Object.values(serviceConfigs[svc] ?? {}) - .filter((v) => v !== '' && v !== undefined && v !== null && v !== false) - .length; - return ( -
- - {open && ( -
- - setServiceConfigs((s) => ({ ...s, [svc]: next }))} - onSchema={(sch) => - setServiceSchemas((s) => ({ ...s, [svc]: sch.fields }))} - /> -
- )} -
- ); - })} -
-
- )} - -
- # preview: deckies that will come online - {'\n'} - {previewLines.join('\n')} -
- - )} - - {step === 2 && ( - <> -
- Mutation rotates MAC / IP / hostname so attackers can't re-target. -
-
- setMutate(e.target.checked)} - style={{ accentColor: 'var(--matrix)' }} - /> - -
- {mutate && ( -
- - setMutateEvery(parseInt(e.target.value, 10))} - /> -
- Next mutation will occur {mutateEvery}m after deploy. -
-
- )} - - )} - - {step === 3 && ( - <> -
- {!deploying - ? 'Ready to deploy. This will write to the fleet and start the listener.' - : 'Deploying...'} -
-
- {log.length === 0 && !deploying && ( - <> - # decnet deploy \{'\n'} - {pickMode === 'archetype' && archetype && ( - <> - --archetype{' '} - {archetype.slug}{' \\'}{'\n'} - - )} - --count{' '} - {count}{' \\'}{'\n'} - --prefix{' '} - {prefix}{' \\'}{'\n'} - --mutate{' '} - {mutate ? `${mutateEvery}m` : 'off'} - {pickMode === 'services' && selectedServices.length > 0 && ( - <> - {' \\'}{'\n'} - --services{' '} - {selectedServices.join(',')} - - )} - - )} - {log.map((l, i) =>
{l}
)} - {deploying && log.length < 7 && } -
- {deployErr && ( -
- ✖ {deployErr} -
- )} - - )} -
- - -
- ); -}; - +import { DeployWizard } from './DeckyFleet/DeployWizard'; import { IntervalEditor } from './DeckyFleet/IntervalEditor'; // ─── Fleet page ────────────────────────────────────────────────────────── diff --git a/decnet_web/src/components/DeckyFleet/DeployWizard.test.tsx b/decnet_web/src/components/DeckyFleet/DeployWizard.test.tsx new file mode 100644 index 00000000..25b44dbb --- /dev/null +++ b/decnet_web/src/components/DeckyFleet/DeployWizard.test.tsx @@ -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('../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( + {}} + onComplete={() => {}} + archetypes={archetypes} + fleetSize={0} + />, + ); + expect(screen.queryByText('DEPLOY NEW DECKIES')).not.toBeInTheDocument(); + }); + + it('opens at step 0 with archetype list rendered', () => { + render( + {}} + 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( + {}} + 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( + {}} + archetypes={archetypes} + fleetSize={0} + />, + ); + await user.click(screen.getByText('CANCEL')); + expect(onClose).toHaveBeenCalled(); + }); +}); diff --git a/decnet_web/src/components/DeckyFleet/DeployWizard.tsx b/decnet_web/src/components/DeckyFleet/DeployWizard.tsx new file mode 100644 index 00000000..1902cdfb --- /dev/null +++ b/decnet_web/src/components/DeckyFleet/DeployWizard.tsx @@ -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>, + serviceSchemas: Record, +): 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 [.] 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 = {}; + 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 = ({ + open, onClose, onComplete, archetypes, fleetSize, +}) => { + const [step, setStep] = useState(0); + const [pickMode, setPickMode] = useState('archetype'); + const [archetype, setArchetype] = useState(null); + const [selectedServices, setSelectedServices] = useState([]); + 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([]); + const [deployErr, setDeployErr] = useState(null); + // Per-service config dicts keyed by service slug. Edits flow into + // the INI as [.] subsections at deploy time so the + // initial container build picks them up — no follow-up apply needed. + const [serviceConfigs, setServiceConfigs] = useState>({}); + const [serviceSchemas, setServiceSchemas] = useState>({}); + const [openSvcCfg, setOpenSvcCfg] = useState(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 = {}; + 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([]); + + // 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> = {}; + 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 ( + + +
+ {step > 0 && !deploying && ( + + )} + {step < 3 && ( + + )} + {step === 3 && !deploying && ( + + )} + {step === 3 && deploying && !deployOk && ( + + )} + {step === 3 && deployOk && deployFailures.length > 0 && ( + + )} +
+ + } + > + <> +
+ {['ARCHETYPE', 'CONFIGURATION', 'MUTATION', 'DEPLOY'].map((l, i) => ( +
+ {i + 1}. {l} +
+ ))} +
+ +
+ {step === 0 && ( + <> +
+ + +
+ {pickMode === 'archetype' ? ( + <> +
Pick the archetype the deckies should masquerade as.
+
+ {archetypes.map((a) => ( + + ))} +
+ + ) : ( + <> +
+ Pick individual services. Every selected decky will expose the same set. +
+
+ {DEFAULT_SERVICES.map((s) => { + const on = selectedServices.includes(s.slug); + return ( + + ); + })} +
+ + )} + + )} + + {step === 1 && ( + <> +
How many, and what to call them.
+
+
+ + setPrefix(e.target.value.replace(/\s+/g, '-'))} + /> +
+
+ + setCount(parseInt(e.target.value, 10))} + /> +
+
+ + {effectiveServices.length > 0 && ( +
+ +
+ Click a service to set passwords, banners, response codes, TLS + material — applied to every decky in this batch via INI + subsections. +
+
+ {effectiveServices.map((svc) => { + const open = openSvcCfg === svc; + const overrideCount = Object.values(serviceConfigs[svc] ?? {}) + .filter((v) => v !== '' && v !== undefined && v !== null && v !== false) + .length; + return ( +
+ + {open && ( +
+ + setServiceConfigs((s) => ({ ...s, [svc]: next }))} + onSchema={(sch) => + setServiceSchemas((s) => ({ ...s, [svc]: sch.fields }))} + /> +
+ )} +
+ ); + })} +
+
+ )} + +
+ # preview: deckies that will come online + {'\n'} + {previewLines.join('\n')} +
+ + )} + + {step === 2 && ( + <> +
+ Mutation rotates MAC / IP / hostname so attackers can't re-target. +
+
+ setMutate(e.target.checked)} + style={{ accentColor: 'var(--matrix)' }} + /> + +
+ {mutate && ( +
+ + setMutateEvery(parseInt(e.target.value, 10))} + /> +
+ Next mutation will occur {mutateEvery}m after deploy. +
+
+ )} + + )} + + {step === 3 && ( + <> +
+ {!deploying + ? 'Ready to deploy. This will write to the fleet and start the listener.' + : 'Deploying...'} +
+
+ {log.length === 0 && !deploying && ( + <> + # decnet deploy \{'\n'} + {pickMode === 'archetype' && archetype && ( + <> + --archetype{' '} + {archetype.slug}{' \\'}{'\n'} + + )} + --count{' '} + {count}{' \\'}{'\n'} + --prefix{' '} + {prefix}{' \\'}{'\n'} + --mutate{' '} + {mutate ? `${mutateEvery}m` : 'off'} + {pickMode === 'services' && selectedServices.length > 0 && ( + <> + {' \\'}{'\n'} + --services{' '} + {selectedServices.join(',')} + + )} + + )} + {log.map((l, i) =>
{l}
)} + {deploying && log.length < 7 && } +
+ {deployErr && ( +
+ ✖ {deployErr} +
+ )} + + )} +
+ + +
+ ); +};