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:
2026-04-29 11:41:43 -04:00
parent 75b1ce3a31
commit bd7f2dfaed
5 changed files with 374 additions and 2 deletions

View File

@@ -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">