diff --git a/decnet_web/src/components/DeckyFleet.css b/decnet_web/src/components/DeckyFleet.css index a887ca5f..2d1b3dfd 100644 --- a/decnet_web/src/components/DeckyFleet.css +++ b/decnet_web/src/components/DeckyFleet.css @@ -141,6 +141,45 @@ } .info-banner em { color: var(--matrix); font-style: normal; } +/* Per-service config accordion in the deploy wizard's CONFIGURATION + step. Each block is one service slug; clicking the toggle reveals + the schema-driven fields rendered by ServiceConfigFields. */ +.wizard-svc-list { display: flex; flex-direction: column; gap: 6px; } +.wizard-svc-block { border: 1px solid var(--border); } +.wizard-svc-toggle { + display: flex; align-items: center; gap: 8px; + width: 100%; + padding: 8px 12px; + background: rgba(255, 255, 255, 0.02); + border: none; + color: inherit; + font-family: var(--font-mono); + font-size: 0.75rem; + letter-spacing: 1px; + text-transform: uppercase; + cursor: pointer; + text-align: left; +} +.wizard-svc-toggle:hover { background: rgba(255, 255, 255, 0.05); } +.wizard-svc-toggle.open { border-bottom: 1px solid var(--border); } +.wizard-svc-caret { color: var(--violet); width: 10px; } +.wizard-svc-name { color: var(--matrix); flex: 1; } +.wizard-svc-badge { + font-size: 0.6rem; + letter-spacing: 1px; + color: var(--violet); + border: 1px solid var(--violet); + padding: 1px 6px; + border-radius: 999px; +} +.wizard-svc-fields { + display: flex; + flex-direction: column; + gap: 8px; + padding: 12px; + background: rgba(0, 0, 0, 0.25); +} + /* Status dots */ .status-dot { display: inline-block; diff --git a/decnet_web/src/components/DeckyFleet.tsx b/decnet_web/src/components/DeckyFleet.tsx index 53d0edee..8c364a79 100644 --- a/decnet_web/src/components/DeckyFleet.tsx +++ b/decnet_web/src/components/DeckyFleet.tsx @@ -9,6 +9,11 @@ import { useToast } from './Toasts/useToast'; import Modal from './Modal/Modal'; import { useServiceRegistry } from '../hooks/useServiceRegistry'; import ServiceConfigForm from './ServiceConfigForm'; +import ServiceConfigFields, { + type FormState as SvcFormState, + type ServiceConfigFieldDTO as SvcFieldDTO, + compactPayload as svcCompactPayload, +} from './ServiceConfigFields'; import './DeckyFleet.css'; // ─── Types ──────────────────────────────────────────────────────────────── @@ -436,6 +441,7 @@ const _buildIni = ( prefix: string, count: number, fleetSize: number, mode: PickMode, archetype: Archetype | null, services: string[], mutate: boolean, mutateEvery: number, + serviceConfigs: Record>, ): string => { const lines: string[] = []; for (let i = 0; i < count; i++) { @@ -449,6 +455,24 @@ const _buildIni = ( 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; + lines.push(`[${prefix}.${svc}]`); + for (const [k, v] of Object.entries(cfg)) { + // INI values can't carry literal newlines; collapse multi-line + // values (PEM textareas etc.) to \n escapes. Single-line values + // are unaffected; multi-line consumers must re-expand. + const serialised = typeof v === 'string' + ? v.replace(/\r?\n/g, '\\n') + : String(v); + lines.push(`${k}=${serialised}`); + } + lines.push(''); + } return lines.join('\n'); }; @@ -466,6 +490,12 @@ const DeployWizard: React.FC = ({ 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; @@ -480,6 +510,9 @@ const DeployWizard: React.FC = ({ setDeploying(false); setLog([]); setDeployErr(null); + setServiceConfigs({}); + setServiceSchemas({}); + setOpenSvcCfg(null); }, [open]); const effectiveArchetypeName = archetype?.name @@ -488,6 +521,20 @@ const DeployWizard: React.FC = ({ ? (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[] = []; @@ -533,9 +580,23 @@ const DeployWizard: React.FC = ({ 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, selectedServices, - mutate, mutateEvery, + prefix, count, fleetSize, pickMode, archetype, servicesForIni, + mutate, mutateEvery, rolled, ); try { const res = await api.post<{ failures?: { name: string; reason: string }[] }>( @@ -707,11 +768,52 @@ const DeployWizard: React.FC = ({ -
- Per-service config (passwords, banners, response codes, TLS material…) - is set after deployment in the Inspector: click a service tag on the - deployed decky to open its schema-driven form. -
+ {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 diff --git a/decnet_web/src/components/ServiceConfigFields.tsx b/decnet_web/src/components/ServiceConfigFields.tsx new file mode 100644 index 00000000..87dade5c --- /dev/null +++ b/decnet_web/src/components/ServiceConfigFields.tsx @@ -0,0 +1,203 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import api from '../utils/api'; +import './ServiceConfigForm.css'; + +export interface ServiceConfigFieldDTO { + key: string; + label: string; + type: 'string' | 'password' | 'int' | 'bool' | 'textarea' | 'enum'; + default?: unknown; + secret?: boolean; + help?: string | null; + enum?: string[] | null; + placeholder?: string | null; +} + +export interface SchemaResponse { + name: string; + ports: number[]; + fleet_singleton: boolean; + fields: ServiceConfigFieldDTO[]; +} + +export type FormValue = string | number | boolean; +export type FormState = Record; + +export function toFormValue(field: ServiceConfigFieldDTO, raw: unknown): FormValue { + if (raw === undefined || raw === null) { + if (field.type === 'bool') return Boolean(field.default); + if (field.type === 'int') return field.default == null ? ('' as unknown as number) : Number(field.default); + return (field.default as string | undefined) ?? ''; + } + if (field.type === 'bool') return Boolean(raw); + if (field.type === 'int') return Number(raw); + return String(raw); +} + +export function buildInitial( + fields: ServiceConfigFieldDTO[], current: Record, +): FormState { + const out: FormState = {}; + for (const f of fields) out[f.key] = toFormValue(f, current[f.key]); + return out; +} + +/** Strip empty strings, null, undefined — server's validate_cfg drops + * them anyway and the wizard wants a tight payload. */ +export function compactPayload( + fields: ServiceConfigFieldDTO[], state: FormState, +): Record { + const out: Record = {}; + for (const f of fields) { + const v = state[f.key]; + if (v === '' || v === undefined || v === null) continue; + out[f.key] = v; + } + return out; +} + +export const fmtSchemaError = (err: unknown, fallback: string): string => + (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail + ?? fallback; + +interface Props { + serviceSlug: string; + /** Current values keyed by field.key. Held by the parent. */ + value: FormState; + onChange: (next: FormState) => void; + /** Optional id-prefix used to disambiguate label-for/input-id pairs + * when multiple instances of the same slug live on a single page. */ + idScope?: string; + /** Surface schema metadata back to the parent (fields list etc.). */ + onSchema?: (schema: SchemaResponse) => void; + /** Initial seed when the schema lands and the parent's value is empty. */ + seedFromDefaults?: boolean; +} + +const ServiceConfigFields: React.FC = ({ + serviceSlug, value, onChange, idScope, onSchema, seedFromDefaults, +}) => { + const [schema, setSchema] = useState(null); + const [loadErr, setLoadErr] = useState(null); + const [revealed, setRevealed] = useState>({}); + + useEffect(() => { + let cancelled = false; + setSchema(null); + setLoadErr(null); + api.get(`/topologies/services/${encodeURIComponent(serviceSlug)}/schema`) + .then(({ data }) => { + if (cancelled) return; + setSchema(data); + onSchema?.(data); + if (seedFromDefaults && Object.keys(value).length === 0) { + onChange(buildInitial(data.fields, {})); + } + }) + .catch((err) => { + if (cancelled) return; + setLoadErr(fmtSchemaError(err, 'Schema load failed.')); + }); + return () => { cancelled = true; }; + // serviceSlug is the only thing that should drive a refetch. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [serviceSlug]); + + const fields = useMemo(() => schema?.fields ?? [], [schema]); + + if (loadErr) return
{loadErr}
; + if (!schema) return
Loading schema…
; + if (fields.length === 0) { + return ( +
+ No customizable fields for {schema.name}. +
+ ); + } + + const setVal = (key: string, v: FormValue) => onChange({ ...value, [key]: v }); + + return ( + <> + {fields.map((f) => { + const id = `svc-cfg-${idScope ?? serviceSlug}-${f.key}`; + const v = value[f.key] ?? toFormValue(f, undefined); + const help = f.help ?
{f.help}
: null; + return ( +
+ + {f.type === 'bool' ? ( + setVal(f.key, e.target.checked)} + /> + ) : f.type === 'enum' ? ( + + ) : f.type === 'textarea' ? ( +