feat(ui): per-service config in the deploy wizard's CONFIGURATION step

Setting a password, banner or TLS material AFTER deployment forces a
container recreate on every change. The deploy wizard now lets the
operator set service config up-front so the initial build has the
right env from the start.

Mechanics:
- Extracted the schema-driven field rendering out of ServiceConfigForm
  into a standalone ServiceConfigFields component (no API/buttons,
  just inputs + onChange).  ServiceConfigForm now delegates to it.
- Wizard step 2 (CONFIGURATION) renders one accordion block per
  selected service; clicking a service reveals its schema-driven
  inputs and a 'N set' badge tracks how many overrides are populated.
  Removing a service (back to step 1) drops its config so the INI
  doesn't carry orphans.
- _buildIni emits one [<prefix>.<svc>] group subsection per service
  with at least one override.  The INI loader's prefix-matcher
  applies it to every ${prefix}-NN decky in the batch, so one block
  covers all clones.
- Multi-line string values (PEM textareas etc.) are escaped as \n
  on the way into INI; downstream consumers re-expand.
This commit is contained in:
2026-04-29 12:08:17 -04:00
parent 97260daf8d
commit d8fa7cc73d
4 changed files with 400 additions and 206 deletions

View File

@@ -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, Record<string, unknown>>,
): 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 [<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;
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<DeployWizardProps> = ({
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;
@@ -480,6 +510,9 @@ const DeployWizard: React.FC<DeployWizardProps> = ({
setDeploying(false);
setLog([]);
setDeployErr(null);
setServiceConfigs({});
setServiceSchemas({});
setOpenSvcCfg(null);
}, [open]);
const effectiveArchetypeName = archetype?.name
@@ -488,6 +521,20 @@ const DeployWizard: React.FC<DeployWizardProps> = ({
? (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[] = [];
@@ -533,9 +580,23 @@ const DeployWizard: React.FC<DeployWizardProps> = ({
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, 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<DeployWizardProps> = ({
</div>
</div>
<div className="info-banner" style={{ fontSize: '0.7rem', lineHeight: 1.5 }}>
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.
</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>