Files
DECNET/decnet_web/src/components/ServiceConfigForm.tsx
anti d8fa7cc73d 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.
2026-04-29 12:08:17 -04:00

138 lines
4.7 KiB
TypeScript

import React, { useMemo, useState } from 'react';
import api from '../utils/api';
import { useToast } from './Toasts/useToast';
import ServiceConfigFields, {
type FormState,
type SchemaResponse,
buildInitial,
compactPayload,
fmtSchemaError,
} from './ServiceConfigFields';
import './ServiceConfigForm.css';
export type { ServiceConfigFieldDTO } from './ServiceConfigFields';
interface Props {
/** Decky the service runs on. */
deckyName: string;
/** Service slug, e.g. "ssh". */
serviceSlug: string;
/** Topology id when this is a MazeNET decky; omit / null for fleet. */
topologyId?: string | null;
/** Currently-persisted service_config[serviceSlug] from the parent. */
currentConfig?: Record<string, unknown>;
/** Fired after a successful PUT or apply, with the post-validation cfg. */
onApplied?: (cfg: Record<string, unknown>, recreated: boolean) => void;
}
const ServiceConfigForm: React.FC<Props> = ({
deckyName, serviceSlug, topologyId, currentConfig, onApplied,
}) => {
const { push } = useToast();
const [schema, setSchema] = useState<SchemaResponse | null>(null);
const [form, setForm] = useState<FormState>({});
const [initial, setInitial] = useState<FormState>({});
const [busy, setBusy] = useState<'save' | 'apply' | null>(null);
// Reseed form values when currentConfig changes meaningfully (by JSON
// identity, not reference — parents pass fresh `{}` literals).
const seedKey = useMemo(() => JSON.stringify(currentConfig ?? {}), [currentConfig]);
React.useEffect(() => {
if (!schema) return;
const init = buildInitial(schema.fields, currentConfig ?? {});
setForm(init);
setInitial(init);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [schema, seedKey]);
const dirty = useMemo(() => {
const keys = new Set([...Object.keys(form), ...Object.keys(initial)]);
for (const k of keys) if (form[k] !== initial[k]) return true;
return false;
}, [form, initial]);
const baseUrl = topologyId
? `/topologies/${encodeURIComponent(topologyId)}/deckies/${encodeURIComponent(deckyName)}/services/${encodeURIComponent(serviceSlug)}`
: `/deckies/${encodeURIComponent(deckyName)}/services/${encodeURIComponent(serviceSlug)}`;
const save = async () => {
if (busy || !schema) return;
setBusy('save');
try {
const { data } = await api.put<{ config: Record<string, unknown>; recreated: boolean }>(
`${baseUrl}/config`, { config: compactPayload(schema.fields, form) },
);
const next = buildInitial(schema.fields, data.config);
setForm(next);
setInitial(next);
onApplied?.(data.config, false);
push({ text: `${serviceSlug} config saved (no restart).`, tone: 'matrix' });
} catch (err) {
push({ text: fmtSchemaError(err, 'Save failed.'), tone: 'alert' });
} finally {
setBusy(null);
}
};
const apply = async () => {
if (busy || !schema) return;
const ok = window.confirm(
`Apply ${serviceSlug} config on ${deckyName}?\n\n` +
`This force-recreates the ${deckyName}-${serviceSlug} container so the new ` +
'env takes effect. In-container session state on this service is lost.',
);
if (!ok) return;
setBusy('apply');
try {
const { data } = await api.post<{ config: Record<string, unknown>; recreated: boolean }>(
`${baseUrl}/apply`, { config: compactPayload(schema.fields, form) },
);
const next = buildInitial(schema.fields, data.config);
setForm(next);
setInitial(next);
onApplied?.(data.config, true);
push({ text: `${serviceSlug} applied — container recreated.`, tone: 'matrix' });
} catch (err) {
push({ text: fmtSchemaError(err, 'Apply failed.'), tone: 'alert' });
} finally {
setBusy(null);
}
};
return (
<div className="service-config-form">
<ServiceConfigFields
serviceSlug={serviceSlug}
value={form}
onChange={setForm}
idScope={`${deckyName}-${serviceSlug}`}
onSchema={setSchema}
/>
{schema && schema.fields.length > 0 && (
<div className="svc-cfg-actions">
{dirty && <span className="svc-cfg-dirty-tag">UNSAVED</span>}
<button
type="button"
className="svc-cfg-btn"
disabled={!dirty || !!busy}
onClick={save}
>
{busy === 'save' ? 'SAVING…' : 'SAVE'}
</button>
<button
type="button"
className="svc-cfg-btn violet"
disabled={!!busy}
onClick={apply}
title="Persist + force-recreate the service container."
>
{busy === 'apply' ? 'APPLYING…' : 'APPLY'}
</button>
</div>
)}
</div>
);
};
export default ServiceConfigForm;