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; 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, ): 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 { const out: Record = {}; 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 = ({ serviceSlug, value, onChange, idScope, onSchema, seedFromDefaults, }) => { const [schema, setSchema] = useState(null); const [loadErr, setLoadErr] = useState(null); const [revealed, setRevealed] = useState>({}); useEffect(() => { let cancelled = false; setSchema(null); setLoadErr(null); api.get(`/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
{loadErr}
; if (!schema) return
Loading schema…
; if (fields.length === 0) { return (
No customizable fields for {schema.name}.
); } 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 ?
{f.help}
: null; return (
{f.type === 'bool' ? ( setVal(f.key, e.target.checked)} /> ) : f.type === 'enum' ? ( ) : f.type === 'textarea' ? (