import { h, Fragment } from 'preact'; import { useState, useEffect, useCallback } from 'preact/hooks'; import SignalPicker, { SignalOption } from './SignalPicker'; // ── Types (mirror internal/confmgr) ────────────────────────────────────────── type ParamType = 'float64' | 'int64' | 'bool' | 'string' | 'enum'; interface Parameter { key: string; label?: string; group?: string; subgroup?: string; ds: string; signal: string; type: ParamType; default?: any; mandatory?: boolean; min?: number; max?: number; enumValues?: string[]; unit?: string; description?: string; } interface ConfigSet { id: string; name: string; description?: string; version: number; tag?: string; owner?: string; parameters: Parameter[]; } interface ConfigInstance { id: string; name: string; setId: string; setVersion?: number; version: number; tag?: string; owner?: string; values: Record; } interface Meta { id: string; name: string; version: number; } interface VersionMeta { version: number; name: string; tag?: string; current: boolean; savedAt: string; } interface ApplyEntry { key: string; ds: string; signal: string; value?: any; ok: boolean; skipped?: boolean; error?: string; } interface ApplyResult { instanceId: string; setId: string; entries: ApplyEntry[]; applied: number; failed: number; skipped: number; } type DiffStatus = 'added' | 'removed' | 'changed' | 'unchanged'; interface SetDiffEntry { key: string; status: DiffStatus; left?: Parameter; right?: Parameter; } interface InstanceDiffEntry { key: string; status: DiffStatus; left?: any; right?: any; } const PARAM_TYPES: ParamType[] = ['float64', 'int64', 'bool', 'string', 'enum']; // ── API helper ─────────────────────────────────────────────────────────────── async function apiJSON(url: string, opts?: RequestInit): Promise { const res = await fetch(url, opts); if (!res.ok && res.status !== 204) { let msg = `HTTP ${res.status}`; try { const e = await res.json(); if (e && e.error) msg = e.error; } catch { /* ignore */ } throw new Error(msg); } if (res.status === 204) return null; return res.json(); } // Lazily-loaded data-source + signal options powering the SignalPicker. function useSignals() { const [dataSources, setDataSources] = useState([]); const [dsSignals, setDsSignals] = useState>({}); useEffect(() => { fetch('/api/v1/datasources') .then(r => (r.ok ? r.json() : [])) .then((ds: { name: string }[]) => setDataSources(ds.map(d => d.name))) .catch(() => {}); }, []); const loadSignals = useCallback((ds: string) => { if (!ds) return; setDsSignals(prev => { if (prev[ds]) return prev; fetch(`/api/v1/signals?ds=${encodeURIComponent(ds)}`) .then(r => (r.ok ? r.json() : [])) .then((sigs: { name: string }[]) => setDsSignals(p => ({ ...p, [ds]: sigs.map(s => s.name) }))) .catch(() => {}); return prev; }); }, []); function allOptions(): SignalOption[] { const out: SignalOption[] = []; for (const ds of dataSources) for (const name of dsSignals[ds] ?? []) out.push({ ds, name }); return out; } function openAll() { dataSources.forEach(loadSignals); } return { allOptions, openAll }; } function splitRef(ref: string): { ds: string; signal: string } { const i = ref.indexOf(':'); if (i < 0) return { ds: '', signal: ref }; return { ds: ref.slice(0, i), signal: ref.slice(i + 1) }; } function fmtTime(iso: string): string { const d = new Date(iso); if (isNaN(d.getTime())) return iso; const pad = (n: number) => String(n).padStart(2, '0'); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; } // ── Top-level modal ─────────────────────────────────────────────────────────── interface Props { onClose: () => void; } export default function ConfigManager({ onClose }: Props) { const [tab, setTab] = useState<'sets' | 'instances'>('sets'); return (
{ if (e.target === e.currentTarget) onClose(); }}>
Configuration manager Versioned configuration sets (schemas) and instances (values) that apply to signals.
{tab === 'sets' ? : }
); } // ── Sets manager ────────────────────────────────────────────────────────────── function SetsManager() { const [sets, setSets] = useState([]); const [selectedId, setSelectedId] = useState(null); const [working, setWorking] = useState(null); const [dirty, setDirty] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [diff, setDiff] = useState<{ title: string; entries: SetDiffEntry[] } | null>(null); const { allOptions, openAll } = useSignals(); const reload = useCallback(async () => { try { setSets((await apiJSON('/api/v1/config/sets')) ?? []); } catch (err) { setError(`Failed to load sets: ${msg(err)}`); } }, []); useEffect(() => { reload(); }, [reload]); async function select(id: string) { if (dirty && !confirm('Discard unsaved changes?')) return; setError(null); try { const s = await apiJSON(`/api/v1/config/sets/${encodeURIComponent(id)}`); setSelectedId(id); setWorking(s); setDirty(false); } catch (err) { setError(msg(err)); } } async function createSet() { setBusy(true); setError(null); try { const body = { name: 'New config set', description: '', parameters: [] }; const created = await apiJSON('/api/v1/config/sets', jsonPost(body)); await reload(); setSelectedId(created!.id); setWorking(created); setDirty(false); } catch (err) { setError(`Create failed: ${msg(err)}`); } finally { setBusy(false); } } async function save() { if (!working) return; setBusy(true); setError(null); try { const saved = await apiJSON(`/api/v1/config/sets/${encodeURIComponent(working.id)}`, jsonPut(working)); setWorking(saved); setDirty(false); await reload(); } catch (err) { setError(`Save failed: ${msg(err)}`); } finally { setBusy(false); } } async function remove(id: string) { if (!confirm('Delete this config set? A server-side copy is kept in trash.')) return; setBusy(true); setError(null); try { await apiJSON(`/api/v1/config/sets/${encodeURIComponent(id)}`, { method: 'DELETE' }); if (selectedId === id) { setSelectedId(null); setWorking(null); setDirty(false); } await reload(); } catch (err) { setError(`Delete failed: ${msg(err)}`); } finally { setBusy(false); } } function patch(p: Partial) { if (!working) return; setWorking({ ...working, ...p }); setDirty(true); } function patchParam(i: number, p: Partial) { if (!working) return; const params = working.parameters.map((x, idx) => (idx === i ? { ...x, ...p } : x)); patch({ parameters: params }); } function addParam() { if (!working) return; const n = working.parameters.length + 1; patch({ parameters: [...working.parameters, { key: `param${n}`, ds: '', signal: '', type: 'float64' }] }); } function removeParam(i: number) { if (!working) return; patch({ parameters: working.parameters.filter((_, idx) => idx !== i) }); } async function showDiff(av: number, bv: number) { if (!working) return; setError(null); try { const q = new URLSearchParams({ a: working.id, av: String(av), b: working.id, bv: String(bv) }); const entries = (await apiJSON(`/api/v1/config/sets/diff?${q}`)) ?? []; setDiff({ title: `${working.name}: v${av} → v${bv}`, entries }); } catch (err) { setError(`Diff failed: ${msg(err)}`); } } return (
Config sets
{sets.length === 0 &&
No config sets yet.
} {sets.map(s => (
select(s.id)}> {s.name || '(unnamed)'} v{s.version}
))}
{error &&
{error}
} {!working &&
Select a config set on the left, or create a new one.
} {working && (
patch({ name: (e.target as HTMLInputElement).value })} /> v{working.version} {dirty && unsaved}
patch({ description: (e.target as HTMLInputElement).value })} />
Parameters
{working.parameters.length === 0 &&
No parameters. Each parameter binds a target signal with a default and constraints.
}
{working.parameters.map((p, i) => ( patchParam(i, patch2)} onRemove={() => removeParam(i)} /> ))}
{ reload(); select(id); }} onPromoted={() => select(working.id)} onCompare={showDiff} onError={setError} />
)}
{diff && setDiff(null)}> }
); } function ParamEditor({ param, allOptions, onOpenSignals, onChange, onRemove }: { param: Parameter; allOptions: SignalOption[]; onOpenSignals: () => void; onChange: (p: Partial) => void; onRemove: () => void; }) { const ref = param.ds || param.signal ? `${param.ds}:${param.signal}` : ''; const isNum = param.type === 'float64' || param.type === 'int64'; return (
onChange({ key: (e.target as HTMLInputElement).value })} />
onChange({ label: (e.target as HTMLInputElement).value })} />
{ const s = splitRef(r); onChange({ ds: s.ds, signal: s.signal }); }} />
{param.type === 'bool' ? ( ) : ( onChange({ default: coerceVal((e.target as HTMLInputElement).value, param.type) })} /> )}
onChange({ group: (e.target as HTMLInputElement).value })} />
onChange({ unit: (e.target as HTMLInputElement).value })} />
{isNum && (
onChange({ min: numOrUndef((e.target as HTMLInputElement).value) })} />
onChange({ max: numOrUndef((e.target as HTMLInputElement).value) })} />
)} {param.type === 'enum' && (
onChange({ enumValues: (e.target as HTMLInputElement).value.split(',').map(s => s.trim()).filter(Boolean) })} />
)}
); } // ── Instances manager ───────────────────────────────────────────────────────── function InstancesManager() { const [instances, setInstances] = useState([]); const [sets, setSets] = useState([]); const [selectedId, setSelectedId] = useState(null); const [working, setWorking] = useState(null); const [boundSet, setBoundSet] = useState(null); const [dirty, setDirty] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [applyResult, setApplyResult] = useState(null); const [diff, setDiff] = useState<{ title: string; entries: InstanceDiffEntry[] } | null>(null); const [showNew, setShowNew] = useState(false); const reload = useCallback(async () => { try { setInstances((await apiJSON('/api/v1/config/instances')) ?? []); setSets((await apiJSON('/api/v1/config/sets')) ?? []); } catch (err) { setError(`Failed to load: ${msg(err)}`); } }, []); useEffect(() => { reload(); }, [reload]); async function loadSet(setId: string, setVersion?: number): Promise { const path = setVersion && setVersion > 0 ? `/api/v1/config/sets/${encodeURIComponent(setId)}/versions/${setVersion}` : `/api/v1/config/sets/${encodeURIComponent(setId)}`; return apiJSON(path); } async function select(id: string) { if (dirty && !confirm('Discard unsaved changes?')) return; setError(null); setApplyResult(null); try { const inst = await apiJSON(`/api/v1/config/instances/${encodeURIComponent(id)}`); const set = await loadSet(inst!.setId, inst!.setVersion); setSelectedId(id); setWorking(inst); setBoundSet(set); setDirty(false); } catch (err) { setError(msg(err)); } } async function createInstance(name: string, setId: string) { setBusy(true); setError(null); try { const body = { name, setId, values: {} }; const created = await apiJSON('/api/v1/config/instances', jsonPost(body)); const set = await loadSet(created!.setId, created!.setVersion); await reload(); setSelectedId(created!.id); setWorking(created); setBoundSet(set); setDirty(false); setShowNew(false); } catch (err) { setError(`Create failed: ${msg(err)}`); } finally { setBusy(false); } } async function save() { if (!working) return; setBusy(true); setError(null); try { const saved = await apiJSON(`/api/v1/config/instances/${encodeURIComponent(working.id)}`, jsonPut(working)); setWorking(saved); setDirty(false); await reload(); } catch (err) { setError(`Save failed: ${msg(err)}`); } finally { setBusy(false); } } async function remove(id: string) { if (!confirm('Delete this config instance? A server-side copy is kept in trash.')) return; setBusy(true); setError(null); try { await apiJSON(`/api/v1/config/instances/${encodeURIComponent(id)}`, { method: 'DELETE' }); if (selectedId === id) { setSelectedId(null); setWorking(null); setBoundSet(null); setDirty(false); } await reload(); } catch (err) { setError(`Delete failed: ${msg(err)}`); } finally { setBusy(false); } } async function apply() { if (!working) return; if (!confirm('Apply this configuration? Each value is written to its target signal.')) return; setBusy(true); setError(null); setApplyResult(null); try { const res = await apiJSON(`/api/v1/config/instances/${encodeURIComponent(working.id)}/apply`, { method: 'POST' }); setApplyResult(res); } catch (err) { setError(`Apply failed: ${msg(err)}`); } finally { setBusy(false); } } function setValue(key: string, value: any) { if (!working) return; const values = { ...working.values }; if (value === undefined) delete values[key]; else values[key] = value; setWorking({ ...working, values }); setDirty(true); } async function showDiff(av: number, bv: number) { if (!working) return; setError(null); try { const q = new URLSearchParams({ a: working.id, av: String(av), b: working.id, bv: String(bv) }); const entries = (await apiJSON(`/api/v1/config/instances/diff?${q}`)) ?? []; setDiff({ title: `${working.name}: v${av} → v${bv}`, entries }); } catch (err) { setError(`Diff failed: ${msg(err)}`); } } const setName = (id: string) => sets.find(s => s.id === id)?.name ?? id; return (
Instances
{sets.length === 0 &&
Create a config set first.
} {sets.length > 0 && instances.length === 0 &&
No instances yet.
} {instances.map(s => (
select(s.id)}> {s.name || '(unnamed)'} v{s.version}
))}
{error &&
{error}
} {!working &&
Select an instance on the left, or create a new one.
} {working && (
{ setWorking({ ...working, name: (e.target as HTMLInputElement).value }); setDirty(true); }} /> v{working.version} {dirty && unsaved}
Based on set {setName(working.setId)}{working.setVersion ? ` (v${working.setVersion})` : ''}.
{!boundSet &&
Bound set could not be loaded.
} {boundSet && (
{boundSet.parameters.length === 0 &&
The bound set has no parameters.
} {boundSet.parameters.map(p => ( setValue(p.key, v)} /> ))}
)} {applyResult && } { reload(); select(id); }} onPromoted={() => select(working.id)} onCompare={showDiff} onError={setError} />
)}
{showNew && setShowNew(false)} onCreate={createInstance} />} {diff && setDiff(null)}> }
); } function ValueRow({ param, value, onChange }: { param: Parameter; value: any; onChange: (v: any) => void }) { const label = param.label || param.key; const isNum = param.type === 'float64' || param.type === 'int64'; const defText = param.default !== undefined && param.default !== null ? String(param.default) : ''; return (
{param.mandatory && *} {label} {param.unit && {param.unit}}
{param.type === 'bool' ? ( ) : param.type === 'enum' ? ( ) : ( { const raw = (e.target as HTMLInputElement).value; onChange(raw === '' ? undefined : coerceVal(raw, param.type)); }} /> )}
{param.ds}:{param.signal}
); } function NewInstanceDialog({ sets, busy, onCancel, onCreate }: { sets: Meta[]; busy: boolean; onCancel: () => void; onCreate: (name: string, setId: string) => void; }) { const [name, setName] = useState('New instance'); const [setId, setSetId] = useState(sets[0]?.id ?? ''); return (
{ if (e.target === e.currentTarget) onCancel(); }}>
New config instance
setName((e.target as HTMLInputElement).value)} />
); } function ApplyResultView({ result }: { result: ApplyResult }) { return (
Applied {result.applied}, failed {result.failed}, skipped {result.skipped}
{result.entries.map(e => ( ))}
ParameterTargetValueResult
{e.key} {e.ds}:{e.signal} {e.value !== undefined ? String(e.value) : '—'} {e.skipped ? skipped : e.ok ? ok : failed}
); } // ── Versions panel (shared) ─────────────────────────────────────────────────── function VersionPanel({ kind, id, onForked, onPromoted, onCompare, onError }: { kind: 'sets' | 'instances'; id: string; onReload: () => void; onForked: (newId: string) => void; onPromoted: () => void; onCompare: (av: number, bv: number) => void; onError: (m: string) => void; }) { const [versions, setVersions] = useState([]); const [open, setOpen] = useState(false); const [a, setA] = useState(null); const [b, setB] = useState(null); const base = `/api/v1/config/${kind}`; const load = useCallback(async () => { try { const v = (await apiJSON(`${base}/${encodeURIComponent(id)}/versions`)) ?? []; setVersions(v); if (v.length >= 2) { setA(v[1].version); setB(v[0].version); } else if (v.length === 1) { setA(v[0].version); setB(v[0].version); } } catch (err) { onError(`Versions failed: ${msg(err)}`); } }, [base, id, onError]); useEffect(() => { if (open) load(); }, [open, load]); // Reset when switching object. useEffect(() => { setOpen(false); setVersions([]); }, [id]); async function fork(version: number) { try { const obj = await apiJSON<{ id: string }>(`${base}/${encodeURIComponent(id)}/versions/${version}/fork`, { method: 'POST' }); onForked(obj!.id); } catch (err) { onError(`Fork failed: ${msg(err)}`); } } async function promote(version: number) { if (!confirm(`Promote v${version} to a new current version?`)) return; try { await apiJSON(`${base}/${encodeURIComponent(id)}/versions/${version}/promote`, { method: 'POST' }); onPromoted(); } catch (err) { onError(`Promote failed: ${msg(err)}`); } } return (
{open && (
{versions.length === 0 &&
No versions.
} {versions.map(v => (
v{v.version}{v.current && current} {v.tag || ''} {fmtTime(v.savedAt)} {!v.current && }
))} {versions.length >= 2 && (
Compare
)}
)}
); } // ── Diff views ──────────────────────────────────────────────────────────────── function DiffModal({ title, onClose, children }: { title: string; onClose: () => void; children: any }) { return (
{ if (e.target === e.currentTarget) onClose(); }}>
Diff — {title}
{children}
); } function paramSummary(p?: Parameter): string { if (!p) return '—'; const parts = [`${p.ds}:${p.signal}`, p.type]; if (p.default !== undefined) parts.push(`default=${p.default}`); if (p.mandatory) parts.push('mandatory'); if (p.min !== undefined) parts.push(`min=${p.min}`); if (p.max !== undefined) parts.push(`max=${p.max}`); return parts.join(', '); } function SetDiffTable({ entries }: { entries: SetDiffEntry[] }) { if (entries.length === 0) return
No parameters.
; return ( {entries.map(e => ( ))}
ParameterLeftRight
{e.key} {e.status} {paramSummary(e.left)} {paramSummary(e.right)}
); } function InstanceDiffTable({ entries }: { entries: InstanceDiffEntry[] }) { if (entries.length === 0) return
No values.
; return ( {entries.map(e => ( ))}
ParameterLeftRight
{e.key} {e.status} {e.left !== undefined ? String(e.left) : '—'} {e.right !== undefined ? String(e.right) : '—'}
); } // ── small helpers ───────────────────────────────────────────────────────────── function msg(err: unknown): string { return err instanceof Error ? err.message : String(err); } function jsonPost(body: any): RequestInit { return { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }; } function jsonPut(body: any): RequestInit { return { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }; } function numOrUndef(v: string): number | undefined { if (v.trim() === '') return undefined; const n = Number(v); return isNaN(n) ? undefined : n; } function coerceVal(v: string, type: ParamType): any { if (type === 'float64' || type === 'int64') { const n = Number(v); return isNaN(n) ? v : n; } return v; }