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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user