feat(ui): schema-driven ServiceConfigForm in Fleet & MazeNET inspectors
ServiceConfigForm.tsx fetches /topologies/services/{slug}/schema and renders
typed inputs (string/password/int/bool/textarea/enum) with reveal toggles for
secrets. SAVE persists via PUT (no restart); APPLY persists + force-recreates
the service container after a confirm dialog (matches the forwards_l3 pattern).
Mounts:
- DeckyFleet DeckyCard: clicking a service tag toggles the form below the
EXPOSED row, gated on liveServicesEnabled (admin + non-swarm).
- MazeNET Inspector: renders the form above REMOVE SERVICE when a service
is selected on a non-observed decky.
UI test plan is manual — no jsdom test infra in decnet_web yet.
This commit is contained in:
@@ -127,6 +127,61 @@
|
||||
}
|
||||
.decky-hits { font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* Schema-driven per-service config form (shared with MazeNET Inspector). */
|
||||
.service-config-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
padding: 10px;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: 2px;
|
||||
}
|
||||
.svc-cfg-row { display: flex; flex-direction: column; gap: 4px; }
|
||||
.svc-cfg-label {
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.svc-cfg-secret-tag { font-size: 0.55rem; opacity: 0.6; letter-spacing: 1px; }
|
||||
.svc-cfg-input {
|
||||
flex: 1;
|
||||
font-size: 0.72rem;
|
||||
padding: 4px 6px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid var(--border);
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
}
|
||||
.svc-cfg-input:focus { outline: 1px solid var(--violet); }
|
||||
.svc-cfg-pw-wrap { display: flex; gap: 6px; align-items: stretch; }
|
||||
.svc-cfg-help { font-size: 0.62rem; opacity: 0.55; }
|
||||
.svc-cfg-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.svc-cfg-dirty-tag {
|
||||
font-size: 0.6rem;
|
||||
letter-spacing: 1px;
|
||||
color: var(--violet);
|
||||
margin-right: auto;
|
||||
}
|
||||
.svc-cfg-toggle-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 1px;
|
||||
opacity: 0.7;
|
||||
padding: 0;
|
||||
}
|
||||
.svc-cfg-toggle-btn:hover { opacity: 1; }
|
||||
|
||||
/* Status dots */
|
||||
.status-dot {
|
||||
display: inline-block;
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ARCHETYPES as FALLBACK_ARCHETYPES, DEFAULT_SERVICES } from './MazeNET/d
|
||||
import { useToast } from './Toasts/useToast';
|
||||
import Modal from './Modal/Modal';
|
||||
import { useServiceRegistry } from '../hooks/useServiceRegistry';
|
||||
import ServiceConfigForm from './ServiceConfigForm';
|
||||
import './DeckyFleet.css';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────
|
||||
@@ -157,6 +158,7 @@ const DeckyCard: React.FC<DeckyCardProps> = ({
|
||||
const [addSlug, setAddSlug] = useState('');
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [opError, setOpError] = useState<string | null>(null);
|
||||
const [openCfgSvc, setOpenCfgSvc] = useState<string | null>(null);
|
||||
|
||||
const removeService = async (slug: string) => {
|
||||
setOpError(null);
|
||||
@@ -269,7 +271,21 @@ const DeckyCard: React.FC<DeckyCardProps> = ({
|
||||
<div className="decky-services">
|
||||
{decky.services.map((s) => (
|
||||
<span key={s} className="service-tag" style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||
<span>{s}</span>
|
||||
{liveServicesEnabled ? (
|
||||
<button
|
||||
type="button"
|
||||
className="svc-cfg-toggle-btn"
|
||||
title={`Configure ${s}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setOpenCfgSvc((cur) => (cur === s ? null : s));
|
||||
}}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
) : (
|
||||
<span>{s}</span>
|
||||
)}
|
||||
{liveServicesEnabled && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -339,6 +355,16 @@ const DeckyCard: React.FC<DeckyCardProps> = ({
|
||||
{opError && (
|
||||
<div className="alert-text" style={{ fontSize: '0.7rem', marginTop: 6 }}>{opError}</div>
|
||||
)}
|
||||
{liveServicesEnabled && openCfgSvc && decky.services.includes(openCfgSvc) && (
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<ServiceConfigForm
|
||||
key={`${decky.name}:${openCfgSvc}`}
|
||||
deckyName={decky.name}
|
||||
serviceSlug={openCfgSvc}
|
||||
currentConfig={decky.service_config?.[openCfgSvc] ?? {}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="decky-footer">
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from '../../icons';
|
||||
import type { Net, MazeNode, Edge } from './types';
|
||||
import { DEFAULT_SERVICES } from './data';
|
||||
import ServiceConfigForm from '../ServiceConfigForm';
|
||||
|
||||
export type Selection =
|
||||
| { type: 'net'; id: string }
|
||||
@@ -18,6 +19,9 @@ interface Props {
|
||||
nets: Net[];
|
||||
nodes: MazeNode[];
|
||||
edges: Edge[];
|
||||
/** Topology ID (MazeNET-only) — required for the schema-driven service
|
||||
* config form to hit the per-topology REST path. Omit for fleet. */
|
||||
topologyId?: string;
|
||||
topologyStatus?: string;
|
||||
onClose?: () => void;
|
||||
onDeleteNet?: (id: string) => void;
|
||||
@@ -46,7 +50,7 @@ interface Props {
|
||||
}
|
||||
|
||||
const Inspector: React.FC<Props> = ({
|
||||
selection, nets, nodes, edges, topologyStatus, onClose,
|
||||
selection, nets, nodes, edges, topologyId, topologyStatus, onClose,
|
||||
onDeleteNet, onDeleteNode, onDeleteEdge, onRemoveService,
|
||||
onLiveAddService, onLiveRemoveService, availableServices = [],
|
||||
onToggleGateway,
|
||||
@@ -440,6 +444,18 @@ const Inspector: React.FC<Props> = ({
|
||||
<div className="k">SUBNET</div>
|
||||
<div className="v">{serviceParentNet?.label ?? '—'}</div>
|
||||
</div>
|
||||
{topologyId && serviceParent && serviceParent.kind !== 'observed' && (
|
||||
<ServiceConfigForm
|
||||
key={`${serviceParent.name}:${serviceSel.id}`}
|
||||
deckyName={serviceParent.name}
|
||||
serviceSlug={serviceSel.id}
|
||||
topologyId={topologyId}
|
||||
currentConfig={
|
||||
((serviceParent.decky_config as { service_config?: Record<string, Record<string, unknown>> } | undefined)
|
||||
?.service_config?.[serviceSel.id]) ?? {}
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{onRemoveService && serviceParent && serviceParent.kind !== 'observed' && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -892,6 +892,7 @@ const MazeNET: React.FC = () => {
|
||||
nets={nets}
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
topologyId={topologyId || undefined}
|
||||
topologyStatus={topoStatus}
|
||||
onClose={() => setInspectorOpen(false)}
|
||||
onDeleteNet={removeNet}
|
||||
|
||||
274
decnet_web/src/components/ServiceConfigForm.tsx
Normal file
274
decnet_web/src/components/ServiceConfigForm.tsx
Normal file
@@ -0,0 +1,274 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import api from '../utils/api';
|
||||
import { useToast } from './Toasts/useToast';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
interface SchemaResponse {
|
||||
name: string;
|
||||
ports: number[];
|
||||
fleet_singleton: boolean;
|
||||
fields: ServiceConfigFieldDTO[];
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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> = ({
|
||||
deckyName, serviceSlug, topologyId, currentConfig, onApplied,
|
||||
}) => {
|
||||
const { push } = useToast();
|
||||
const [schema, setSchema] = useState<SchemaResponse | null>(null);
|
||||
const [loadErr, setLoadErr] = useState<string | null>(null);
|
||||
const [form, setForm] = useState<FormState>({});
|
||||
const [initial, setInitial] = useState<FormState>({});
|
||||
const [busy, setBusy] = useState<'save' | 'apply' | 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);
|
||||
const init = buildInitial(data.fields, currentConfig ?? {});
|
||||
setForm(init);
|
||||
setInitial(init);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) return;
|
||||
setLoadErr(fmtError(err, 'Schema load failed.'));
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [serviceSlug, currentConfig]);
|
||||
|
||||
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 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
|
||||
? `/topologies/${encodeURIComponent(topologyId)}/deckies/${encodeURIComponent(deckyName)}/services/${encodeURIComponent(serviceSlug)}`
|
||||
: `/deckies/${encodeURIComponent(deckyName)}/services/${encodeURIComponent(serviceSlug)}`;
|
||||
|
||||
const save = async () => {
|
||||
if (busy) return;
|
||||
setBusy('save');
|
||||
try {
|
||||
const { data } = await api.put<{ config: Record<string, unknown>; recreated: boolean }>(
|
||||
`${baseUrl}/config`, { config: buildPayload() },
|
||||
);
|
||||
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: fmtError(err, 'Save failed.'), tone: 'alert' });
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const apply = async () => {
|
||||
if (busy) 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: buildPayload() },
|
||||
);
|
||||
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: fmtError(err, 'Apply failed.'), tone: 'alert' });
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (loadErr) {
|
||||
return <div className="alert-text" style={{ fontSize: '0.7rem' }}>{loadErr}</div>;
|
||||
}
|
||||
if (!schema) {
|
||||
return <div className="dim" style={{ fontSize: '0.7rem' }}>Loading schema…</div>;
|
||||
}
|
||||
if (schema.fields.length === 0) {
|
||||
return (
|
||||
<div className="dim" style={{ fontSize: '0.7rem', fontStyle: 'italic' }}>
|
||||
No customizable fields for {schema.name}.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="service-config-form">
|
||||
{schema.fields.map((f) => {
|
||||
const id = `svc-cfg-${deckyName}-${serviceSlug}-${f.key}`;
|
||||
const value = form[f.key];
|
||||
const setVal = (v: FormValue) => setForm((s) => ({ ...s, [f.key]: v }));
|
||||
const help = f.help ? <div className="dim 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="dim svc-cfg-secret-tag"> · secret</span>}
|
||||
</label>
|
||||
{f.type === 'bool' ? (
|
||||
<input
|
||||
id={id}
|
||||
type="checkbox"
|
||||
checked={Boolean(value)}
|
||||
onChange={(e) => setVal(e.target.checked)}
|
||||
/>
|
||||
) : f.type === 'enum' ? (
|
||||
<select
|
||||
id={id}
|
||||
value={String(value ?? '')}
|
||||
onChange={(e) => setVal(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(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="btn small"
|
||||
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="dim svc-cfg-dirty-tag">UNSAVED</span>}
|
||||
<button
|
||||
type="button"
|
||||
className="btn small"
|
||||
disabled={!dirty || !!busy}
|
||||
onClick={save}
|
||||
>
|
||||
{busy === 'save' ? 'SAVING…' : 'SAVE'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn violet small"
|
||||
disabled={!!busy}
|
||||
onClick={apply}
|
||||
title="Persist + force-recreate the service container."
|
||||
>
|
||||
{busy === 'apply' ? 'APPLYING…' : 'APPLY'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServiceConfigForm;
|
||||
Reference in New Issue
Block a user