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:
@@ -141,6 +141,45 @@
|
|||||||
}
|
}
|
||||||
.info-banner em { color: var(--matrix); font-style: normal; }
|
.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 dots */
|
||||||
.status-dot {
|
.status-dot {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ import { useToast } from './Toasts/useToast';
|
|||||||
import Modal from './Modal/Modal';
|
import Modal from './Modal/Modal';
|
||||||
import { useServiceRegistry } from '../hooks/useServiceRegistry';
|
import { useServiceRegistry } from '../hooks/useServiceRegistry';
|
||||||
import ServiceConfigForm from './ServiceConfigForm';
|
import ServiceConfigForm from './ServiceConfigForm';
|
||||||
|
import ServiceConfigFields, {
|
||||||
|
type FormState as SvcFormState,
|
||||||
|
type ServiceConfigFieldDTO as SvcFieldDTO,
|
||||||
|
compactPayload as svcCompactPayload,
|
||||||
|
} from './ServiceConfigFields';
|
||||||
import './DeckyFleet.css';
|
import './DeckyFleet.css';
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────
|
||||||
@@ -436,6 +441,7 @@ const _buildIni = (
|
|||||||
prefix: string, count: number, fleetSize: number,
|
prefix: string, count: number, fleetSize: number,
|
||||||
mode: PickMode, archetype: Archetype | null, services: string[],
|
mode: PickMode, archetype: Archetype | null, services: string[],
|
||||||
mutate: boolean, mutateEvery: number,
|
mutate: boolean, mutateEvery: number,
|
||||||
|
serviceConfigs: Record<string, Record<string, unknown>>,
|
||||||
): string => {
|
): string => {
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
@@ -449,6 +455,24 @@ const _buildIni = (
|
|||||||
if (mutate) lines.push(`mutate_interval=${mutateEvery}`);
|
if (mutate) lines.push(`mutate_interval=${mutateEvery}`);
|
||||||
lines.push('');
|
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');
|
return lines.join('\n');
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -466,6 +490,12 @@ const DeployWizard: React.FC<DeployWizardProps> = ({
|
|||||||
const [deploying, setDeploying] = useState(false);
|
const [deploying, setDeploying] = useState(false);
|
||||||
const [log, setLog] = useState<string[]>([]);
|
const [log, setLog] = useState<string[]>([]);
|
||||||
const [deployErr, setDeployErr] = useState<string | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
@@ -480,6 +510,9 @@ const DeployWizard: React.FC<DeployWizardProps> = ({
|
|||||||
setDeploying(false);
|
setDeploying(false);
|
||||||
setLog([]);
|
setLog([]);
|
||||||
setDeployErr(null);
|
setDeployErr(null);
|
||||||
|
setServiceConfigs({});
|
||||||
|
setServiceSchemas({});
|
||||||
|
setOpenSvcCfg(null);
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
const effectiveArchetypeName = archetype?.name
|
const effectiveArchetypeName = archetype?.name
|
||||||
@@ -488,6 +521,20 @@ const DeployWizard: React.FC<DeployWizardProps> = ({
|
|||||||
? (archetype?.services ?? [])
|
? (archetype?.services ?? [])
|
||||||
: selectedServices;
|
: 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).
|
// Preview lines, count-aware (shows up to 6, with "…and N more" footer).
|
||||||
const previewLines = useMemo(() => {
|
const previewLines = useMemo(() => {
|
||||||
const out: string[] = [];
|
const out: string[] = [];
|
||||||
@@ -533,9 +580,23 @@ const DeployWizard: React.FC<DeployWizardProps> = ({
|
|||||||
setDeployOk(false);
|
setDeployOk(false);
|
||||||
setDeployFailures([]);
|
setDeployFailures([]);
|
||||||
setDeploying(true);
|
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(
|
const ini = _buildIni(
|
||||||
prefix, count, fleetSize, pickMode, archetype, selectedServices,
|
prefix, count, fleetSize, pickMode, archetype, servicesForIni,
|
||||||
mutate, mutateEvery,
|
mutate, mutateEvery, rolled,
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
const res = await api.post<{ failures?: { name: string; reason: string }[] }>(
|
const res = await api.post<{ failures?: { name: string; reason: string }[] }>(
|
||||||
@@ -707,11 +768,52 @@ const DeployWizard: React.FC<DeployWizardProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="info-banner" style={{ fontSize: '0.7rem', lineHeight: 1.5 }}>
|
{effectiveServices.length > 0 && (
|
||||||
Per-service config (passwords, banners, response codes, TLS material…)
|
<div className="tweak-group">
|
||||||
is set after deployment in the Inspector: click a service tag on the
|
<label>PER-SERVICE CONFIG</label>
|
||||||
deployed decky to open its schema-driven form.
|
<div className="dim" style={{ fontSize: '0.62rem', letterSpacing: 1, marginBottom: 6 }}>
|
||||||
</div>
|
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">
|
<div className="code-block">
|
||||||
<span className="comment"># preview: deckies that will come online</span>
|
<span className="comment"># preview: deckies that will come online</span>
|
||||||
|
|||||||
203
decnet_web/src/components/ServiceConfigFields.tsx
Normal file
203
decnet_web/src/components/ServiceConfigFields.tsx
Normal file
@@ -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<string, FormValue>;
|
||||||
|
|
||||||
|
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<string, unknown>,
|
||||||
|
): 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<string, unknown> {
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
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<Props> = ({
|
||||||
|
serviceSlug, value, onChange, idScope, onSchema, seedFromDefaults,
|
||||||
|
}) => {
|
||||||
|
const [schema, setSchema] = useState<SchemaResponse | null>(null);
|
||||||
|
const [loadErr, setLoadErr] = useState<string | null>(null);
|
||||||
|
const [revealed, setRevealed] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setSchema(null);
|
||||||
|
setLoadErr(null);
|
||||||
|
api.get<SchemaResponse>(`/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 <div className="svc-cfg-status alert-text">{loadErr}</div>;
|
||||||
|
if (!schema) return <div className="svc-cfg-status">Loading schema…</div>;
|
||||||
|
if (fields.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="svc-cfg-status">
|
||||||
|
No customizable fields for {schema.name}.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ? <div className="svc-cfg-help">{f.help}</div> : null;
|
||||||
|
return (
|
||||||
|
<div key={f.key} className="svc-cfg-row">
|
||||||
|
<label htmlFor={id} className="svc-cfg-label">
|
||||||
|
{f.label}
|
||||||
|
{f.secret && <span className="svc-cfg-secret-tag">· secret</span>}
|
||||||
|
</label>
|
||||||
|
{f.type === 'bool' ? (
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
type="checkbox"
|
||||||
|
checked={Boolean(v)}
|
||||||
|
onChange={(e) => setVal(f.key, e.target.checked)}
|
||||||
|
/>
|
||||||
|
) : f.type === 'enum' ? (
|
||||||
|
<select
|
||||||
|
id={id}
|
||||||
|
value={String(v ?? '')}
|
||||||
|
onChange={(e) => setVal(f.key, e.target.value)}
|
||||||
|
className="svc-cfg-input"
|
||||||
|
>
|
||||||
|
<option value="">—</option>
|
||||||
|
{(f.enum ?? []).map((opt) => (
|
||||||
|
<option key={opt} value={opt}>{opt}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : f.type === 'textarea' ? (
|
||||||
|
<textarea
|
||||||
|
id={id}
|
||||||
|
value={String(v ?? '')}
|
||||||
|
onChange={(e) => setVal(f.key, e.target.value)}
|
||||||
|
placeholder={f.placeholder ?? ''}
|
||||||
|
rows={3}
|
||||||
|
className="svc-cfg-input"
|
||||||
|
/>
|
||||||
|
) : f.type === 'password' ? (
|
||||||
|
<div className="svc-cfg-pw-wrap">
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
type={revealed[f.key] ? 'text' : 'password'}
|
||||||
|
value={String(v ?? '')}
|
||||||
|
onChange={(e) => setVal(f.key, e.target.value)}
|
||||||
|
placeholder={f.placeholder ?? ''}
|
||||||
|
className="svc-cfg-input"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="svc-cfg-pw-toggle"
|
||||||
|
onClick={() => setRevealed((s) => ({ ...s, [f.key]: !s[f.key] }))}
|
||||||
|
>
|
||||||
|
{revealed[f.key] ? 'HIDE' : 'SHOW'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
type={f.type === 'int' ? 'number' : 'text'}
|
||||||
|
value={String(v ?? '')}
|
||||||
|
onChange={(e) =>
|
||||||
|
setVal(
|
||||||
|
f.key,
|
||||||
|
f.type === 'int' && e.target.value !== ''
|
||||||
|
? Number(e.target.value)
|
||||||
|
: e.target.value,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
placeholder={f.placeholder ?? ''}
|
||||||
|
className="svc-cfg-input"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{help}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ServiceConfigFields;
|
||||||
@@ -1,25 +1,16 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import api from '../utils/api';
|
import api from '../utils/api';
|
||||||
import { useToast } from './Toasts/useToast';
|
import { useToast } from './Toasts/useToast';
|
||||||
|
import ServiceConfigFields, {
|
||||||
|
type FormState,
|
||||||
|
type SchemaResponse,
|
||||||
|
buildInitial,
|
||||||
|
compactPayload,
|
||||||
|
fmtSchemaError,
|
||||||
|
} from './ServiceConfigFields';
|
||||||
import './ServiceConfigForm.css';
|
import './ServiceConfigForm.css';
|
||||||
|
|
||||||
export interface ServiceConfigFieldDTO {
|
export type { ServiceConfigFieldDTO } from './ServiceConfigFields';
|
||||||
key: string;
|
|
||||||
label: string;
|
|
||||||
type: 'string' | 'password' | 'int' | 'bool' | 'textarea' | 'enum';
|
|
||||||
default?: unknown;
|
|
||||||
secret?: boolean;
|
|
||||||
help?: string | null;
|
|
||||||
enum?: string[] | null;
|
|
||||||
placeholder?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SchemaResponse {
|
|
||||||
name: string;
|
|
||||||
ports: number[];
|
|
||||||
fleet_singleton: boolean;
|
|
||||||
fields: ServiceConfigFieldDTO[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** Decky the service runs on. */
|
/** Decky the service runs on. */
|
||||||
@@ -34,67 +25,19 @@ interface Props {
|
|||||||
onApplied?: (cfg: Record<string, unknown>, recreated: boolean) => void;
|
onApplied?: (cfg: Record<string, unknown>, recreated: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type FormValue = string | number | boolean;
|
|
||||||
type FormState = Record<string, FormValue>;
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildInitial(
|
|
||||||
fields: ServiceConfigFieldDTO[], current: Record<string, unknown>,
|
|
||||||
): FormState {
|
|
||||||
const out: FormState = {};
|
|
||||||
for (const f of fields) out[f.key] = toFormValue(f, current[f.key]);
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fmtError = (err: unknown, fallback: string): string =>
|
|
||||||
(err as { response?: { data?: { detail?: string } } })?.response?.data?.detail
|
|
||||||
?? fallback;
|
|
||||||
|
|
||||||
const ServiceConfigForm: React.FC<Props> = ({
|
const ServiceConfigForm: React.FC<Props> = ({
|
||||||
deckyName, serviceSlug, topologyId, currentConfig, onApplied,
|
deckyName, serviceSlug, topologyId, currentConfig, onApplied,
|
||||||
}) => {
|
}) => {
|
||||||
const { push } = useToast();
|
const { push } = useToast();
|
||||||
const [schema, setSchema] = useState<SchemaResponse | null>(null);
|
const [schema, setSchema] = useState<SchemaResponse | null>(null);
|
||||||
const [loadErr, setLoadErr] = useState<string | null>(null);
|
|
||||||
const [form, setForm] = useState<FormState>({});
|
const [form, setForm] = useState<FormState>({});
|
||||||
const [initial, setInitial] = useState<FormState>({});
|
const [initial, setInitial] = useState<FormState>({});
|
||||||
const [busy, setBusy] = useState<'save' | 'apply' | null>(null);
|
const [busy, setBusy] = useState<'save' | 'apply' | null>(null);
|
||||||
const [revealed, setRevealed] = useState<Record<string, boolean>>({});
|
|
||||||
|
|
||||||
// Fetch schema only when the slug changes. currentConfig is a fresh
|
// Reseed form values when currentConfig changes meaningfully (by JSON
|
||||||
// object literal from the parent on every render — depending on it
|
// identity, not reference — parents pass fresh `{}` literals).
|
||||||
// here would re-fetch the schema on every render.
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
setSchema(null);
|
|
||||||
setLoadErr(null);
|
|
||||||
api.get<SchemaResponse>(`/topologies/services/${encodeURIComponent(serviceSlug)}/schema`)
|
|
||||||
.then(({ data }) => {
|
|
||||||
if (cancelled) return;
|
|
||||||
setSchema(data);
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
if (cancelled) return;
|
|
||||||
setLoadErr(fmtError(err, 'Schema load failed.'));
|
|
||||||
});
|
|
||||||
return () => { cancelled = true; };
|
|
||||||
}, [serviceSlug]);
|
|
||||||
|
|
||||||
// Seed form values from currentConfig once the schema is in hand.
|
|
||||||
// Keyed on JSON of the current cfg so a real change reseeds, but a
|
|
||||||
// referentially-new-but-equal object doesn't.
|
|
||||||
const seedKey = useMemo(() => JSON.stringify(currentConfig ?? {}), [currentConfig]);
|
const seedKey = useMemo(() => JSON.stringify(currentConfig ?? {}), [currentConfig]);
|
||||||
useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!schema) return;
|
if (!schema) return;
|
||||||
const init = buildInitial(schema.fields, currentConfig ?? {});
|
const init = buildInitial(schema.fields, currentConfig ?? {});
|
||||||
setForm(init);
|
setForm(init);
|
||||||
@@ -108,45 +51,31 @@ const ServiceConfigForm: React.FC<Props> = ({
|
|||||||
return false;
|
return false;
|
||||||
}, [form, initial]);
|
}, [form, initial]);
|
||||||
|
|
||||||
const buildPayload = (): Record<string, unknown> => {
|
|
||||||
if (!schema) return {};
|
|
||||||
const out: Record<string, unknown> = {};
|
|
||||||
for (const f of schema.fields) {
|
|
||||||
const v = form[f.key];
|
|
||||||
// Skip empty strings on optional fields — server-side validate_cfg
|
|
||||||
// drops them anyway, but sending them risks surprising users when
|
|
||||||
// the round-trip echoes a missing key.
|
|
||||||
if (v === '' || v === undefined || v === null) continue;
|
|
||||||
out[f.key] = v;
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
};
|
|
||||||
|
|
||||||
const baseUrl = topologyId
|
const baseUrl = topologyId
|
||||||
? `/topologies/${encodeURIComponent(topologyId)}/deckies/${encodeURIComponent(deckyName)}/services/${encodeURIComponent(serviceSlug)}`
|
? `/topologies/${encodeURIComponent(topologyId)}/deckies/${encodeURIComponent(deckyName)}/services/${encodeURIComponent(serviceSlug)}`
|
||||||
: `/deckies/${encodeURIComponent(deckyName)}/services/${encodeURIComponent(serviceSlug)}`;
|
: `/deckies/${encodeURIComponent(deckyName)}/services/${encodeURIComponent(serviceSlug)}`;
|
||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
if (busy) return;
|
if (busy || !schema) return;
|
||||||
setBusy('save');
|
setBusy('save');
|
||||||
try {
|
try {
|
||||||
const { data } = await api.put<{ config: Record<string, unknown>; recreated: boolean }>(
|
const { data } = await api.put<{ config: Record<string, unknown>; recreated: boolean }>(
|
||||||
`${baseUrl}/config`, { config: buildPayload() },
|
`${baseUrl}/config`, { config: compactPayload(schema.fields, form) },
|
||||||
);
|
);
|
||||||
const next = buildInitial(schema!.fields, data.config);
|
const next = buildInitial(schema.fields, data.config);
|
||||||
setForm(next);
|
setForm(next);
|
||||||
setInitial(next);
|
setInitial(next);
|
||||||
onApplied?.(data.config, false);
|
onApplied?.(data.config, false);
|
||||||
push({ text: `${serviceSlug} config saved (no restart).`, tone: 'matrix' });
|
push({ text: `${serviceSlug} config saved (no restart).`, tone: 'matrix' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
push({ text: fmtError(err, 'Save failed.'), tone: 'alert' });
|
push({ text: fmtSchemaError(err, 'Save failed.'), tone: 'alert' });
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(null);
|
setBusy(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const apply = async () => {
|
const apply = async () => {
|
||||||
if (busy) return;
|
if (busy || !schema) return;
|
||||||
const ok = window.confirm(
|
const ok = window.confirm(
|
||||||
`Apply ${serviceSlug} config on ${deckyName}?\n\n` +
|
`Apply ${serviceSlug} config on ${deckyName}?\n\n` +
|
||||||
`This force-recreates the ${deckyName}-${serviceSlug} container so the new ` +
|
`This force-recreates the ${deckyName}-${serviceSlug} container so the new ` +
|
||||||
@@ -156,130 +85,51 @@ const ServiceConfigForm: React.FC<Props> = ({
|
|||||||
setBusy('apply');
|
setBusy('apply');
|
||||||
try {
|
try {
|
||||||
const { data } = await api.post<{ config: Record<string, unknown>; recreated: boolean }>(
|
const { data } = await api.post<{ config: Record<string, unknown>; recreated: boolean }>(
|
||||||
`${baseUrl}/apply`, { config: buildPayload() },
|
`${baseUrl}/apply`, { config: compactPayload(schema.fields, form) },
|
||||||
);
|
);
|
||||||
const next = buildInitial(schema!.fields, data.config);
|
const next = buildInitial(schema.fields, data.config);
|
||||||
setForm(next);
|
setForm(next);
|
||||||
setInitial(next);
|
setInitial(next);
|
||||||
onApplied?.(data.config, true);
|
onApplied?.(data.config, true);
|
||||||
push({ text: `${serviceSlug} applied — container recreated.`, tone: 'matrix' });
|
push({ text: `${serviceSlug} applied — container recreated.`, tone: 'matrix' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
push({ text: fmtError(err, 'Apply failed.'), tone: 'alert' });
|
push({ text: fmtSchemaError(err, 'Apply failed.'), tone: 'alert' });
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(null);
|
setBusy(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loadErr) {
|
|
||||||
return <div className="svc-cfg-status alert-text">{loadErr}</div>;
|
|
||||||
}
|
|
||||||
if (!schema) {
|
|
||||||
return <div className="svc-cfg-status">Loading schema…</div>;
|
|
||||||
}
|
|
||||||
if (schema.fields.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="svc-cfg-status">
|
|
||||||
No customizable fields for {schema.name}.
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="service-config-form">
|
<div className="service-config-form">
|
||||||
{schema.fields.map((f) => {
|
<ServiceConfigFields
|
||||||
const id = `svc-cfg-${deckyName}-${serviceSlug}-${f.key}`;
|
serviceSlug={serviceSlug}
|
||||||
const value = form[f.key];
|
value={form}
|
||||||
const setVal = (v: FormValue) => setForm((s) => ({ ...s, [f.key]: v }));
|
onChange={setForm}
|
||||||
const help = f.help ? <div className="dim svc-cfg-help">{f.help}</div> : null;
|
idScope={`${deckyName}-${serviceSlug}`}
|
||||||
return (
|
onSchema={setSchema}
|
||||||
<div key={f.key} className="svc-cfg-row">
|
/>
|
||||||
<label htmlFor={id} className="svc-cfg-label">
|
{schema && schema.fields.length > 0 && (
|
||||||
{f.label}
|
<div className="svc-cfg-actions">
|
||||||
{f.secret && <span className="dim svc-cfg-secret-tag"> · secret</span>}
|
{dirty && <span className="svc-cfg-dirty-tag">UNSAVED</span>}
|
||||||
</label>
|
<button
|
||||||
{f.type === 'bool' ? (
|
type="button"
|
||||||
<input
|
className="svc-cfg-btn"
|
||||||
id={id}
|
disabled={!dirty || !!busy}
|
||||||
type="checkbox"
|
onClick={save}
|
||||||
checked={Boolean(value)}
|
>
|
||||||
onChange={(e) => setVal(e.target.checked)}
|
{busy === 'save' ? 'SAVING…' : 'SAVE'}
|
||||||
/>
|
</button>
|
||||||
) : f.type === 'enum' ? (
|
<button
|
||||||
<select
|
type="button"
|
||||||
id={id}
|
className="svc-cfg-btn violet"
|
||||||
value={String(value ?? '')}
|
disabled={!!busy}
|
||||||
onChange={(e) => setVal(e.target.value)}
|
onClick={apply}
|
||||||
className="svc-cfg-input"
|
title="Persist + force-recreate the service container."
|
||||||
>
|
>
|
||||||
<option value="">—</option>
|
{busy === 'apply' ? 'APPLYING…' : 'APPLY'}
|
||||||
{(f.enum ?? []).map((opt) => (
|
</button>
|
||||||
<option key={opt} value={opt}>{opt}</option>
|
</div>
|
||||||
))}
|
)}
|
||||||
</select>
|
|
||||||
) : f.type === 'textarea' ? (
|
|
||||||
<textarea
|
|
||||||
id={id}
|
|
||||||
value={String(value ?? '')}
|
|
||||||
onChange={(e) => setVal(e.target.value)}
|
|
||||||
placeholder={f.placeholder ?? ''}
|
|
||||||
rows={3}
|
|
||||||
className="svc-cfg-input"
|
|
||||||
/>
|
|
||||||
) : f.type === 'password' ? (
|
|
||||||
<div className="svc-cfg-pw-wrap">
|
|
||||||
<input
|
|
||||||
id={id}
|
|
||||||
type={revealed[f.key] ? 'text' : 'password'}
|
|
||||||
value={String(value ?? '')}
|
|
||||||
onChange={(e) => setVal(e.target.value)}
|
|
||||||
placeholder={f.placeholder ?? ''}
|
|
||||||
className="svc-cfg-input"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="svc-cfg-pw-toggle"
|
|
||||||
onClick={() => setRevealed((s) => ({ ...s, [f.key]: !s[f.key] }))}
|
|
||||||
>
|
|
||||||
{revealed[f.key] ? 'HIDE' : 'SHOW'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<input
|
|
||||||
id={id}
|
|
||||||
type={f.type === 'int' ? 'number' : 'text'}
|
|
||||||
value={String(value ?? '')}
|
|
||||||
onChange={(e) =>
|
|
||||||
setVal(f.type === 'int' && e.target.value !== ''
|
|
||||||
? Number(e.target.value)
|
|
||||||
: e.target.value)}
|
|
||||||
placeholder={f.placeholder ?? ''}
|
|
||||||
className="svc-cfg-input"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{help}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user