import { h, Fragment } from 'preact'; import { useState, useEffect, useCallback, useRef } from 'preact/hooks'; import SignalPicker, { SignalOption } from './SignalPicker'; import { VersionTree, DiffViewer } from './VersionHistory'; import CueEditor from './CueEditor'; import { useAuth } from './lib/auth'; import { ScopeFilter, ScopePicker, filterByScope, normalizeScope, type Bucket } from './lib/scope'; // ── Types (mirror internal/confmgr) ────────────────────────────────────────── type ParamType = 'float64' | 'int64' | 'bool' | 'string' | 'enum' | 'float64[]'; 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; scope?: string; groups?: string[]; parameters: Parameter[]; } interface ConfigInstance { id: string; name: string; setId: string; setVersion?: number; version: number; tag?: string; owner?: string; scope?: string; groups?: string[]; values: Record; } interface Meta { id: string; name: string; version: number; setId?: string; enabled?: boolean; owner?: string; scope?: string; groups?: string[]; } // ConfigRule mirrors internal/confmgr.ConfigRule: CUE validation/transformation // logic bound to a set, run when instances of that set are saved. interface ConfigRule { id: string; name: string; setId: string; description?: string; enabled?: boolean; version: number; tag?: string; owner?: string; source: string; } // RuleViolation / RuleResult mirror the backend evaluation payload. interface RuleViolation { rule?: string; path?: string; message: string; } interface RuleResult { ok: boolean; violations?: RuleViolation[]; transformed?: Record; compileError?: string; } // Snapshot / preview payloads mirror the backend /config/rules/preview response. interface SnapshotEntry { key: string; ds: string; signal: string; value?: any; ok: boolean; error?: string; } interface SnapshotResult { setId: string; entries: SnapshotEntry[]; captured: number; failed: number; } interface RulePreview { snapshot: SnapshotResult; result: RuleResult; } // A rule with no explicit enabled flag (legacy) is treated as enabled. const ruleEnabled = (r: { enabled?: boolean }) => r.enabled !== false; 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 TYPE_LABELS: Record = { float64: 'Number', int64: 'Integer', bool: 'Boolean', string: 'String', enum: 'Enum', 'float64[]': 'Array', }; // SignalInfo mirrors the /api/v1/signals payload (internal/api signalInfo). interface SignalInfo { name: string; type: string; unit?: string; displayLow: number; displayHigh: number; writable: boolean; enumStrings?: string[]; description?: string; } // metaToParamType maps a signal's reported type string to a config ParamType. function metaToParamType(t: string): ParamType { switch (t) { case 'float64[]': return 'float64[]'; case 'int64': return 'int64'; case 'bool': return 'bool'; case 'enum': return 'enum'; case 'string': return 'string'; default: return 'float64'; } } // ── 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: SignalInfo[]) => setDsSignals(p => ({ ...p, [ds]: sigs }))) .catch(() => {}); return prev; }); }, []); function allOptions(): SignalOption[] { const out: SignalOption[] = []; for (const ds of dataSources) for (const s of dsSignals[ds] ?? []) out.push({ ds, name: s.name }); return out; } function openAll() { dataSources.forEach(loadSignals); } function signalMeta(ds: string, name: string): SignalInfo | undefined { return (dsSignals[ds] ?? []).find(s => s.name === name); } return { allOptions, openAll, signalMeta }; } 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) }; } // ── Top-level modal ─────────────────────────────────────────────────────────── interface Props { onClose: () => void; } // CloseGuard lets the active manager intercept a close/tab-switch request to // prompt about unsaved changes before the action proceeds. type CloseGuard = (proceed: () => void) => void; export default function ConfigManager({ onClose }: Props) { const [tab, setTab] = useState<'sets' | 'instances' | 'rules'>('sets'); const guardRef = useRef(null); const registerGuard = useCallback((fn: CloseGuard | null) => { guardRef.current = fn; }, []); // Route close/tab-switch through the active manager's guard so it can prompt // before discarding unsaved edits. const guarded = (proceed: () => void) => { if (guardRef.current) guardRef.current(proceed); else proceed(); }; const requestClose = () => guarded(onClose); const switchTab = (t: 'sets' | 'instances' | 'rules') => { if (t !== tab) guarded(() => setTab(t)); }; return (
{ if (e.target === e.currentTarget) requestClose(); }}>
Configuration manager Versioned configuration sets (schemas) and instances (values) that apply to signals.
{tab === 'sets' && } {tab === 'instances' && } {tab === 'rules' && }
); } // useCloseGuard registers a guard that prompts (via the returned dialog state) // when there are unsaved changes before a close/tab-switch proceeds. function useCloseGuard(registerGuard: (fn: CloseGuard | null) => void, dirty: boolean) { const [pending, setPending] = useState<{ proceed: () => void } | null>(null); useEffect(() => { registerGuard((proceed) => { if (dirty) setPending({ proceed }); else proceed(); }); return () => registerGuard(null); }, [dirty, registerGuard]); return { pending, setPending }; } // UnsavedDialog offers Save / Discard / Cancel when closing with pending edits. function UnsavedDialog({ busy, onSave, onDiscard, onCancel }: { busy: boolean; onSave: () => void; onDiscard: () => void; onCancel: () => void; }) { return (
{ if (e.target === e.currentTarget) onCancel(); }}>
Unsaved changes
This configuration has unsaved changes. What would you like to do?
); } // useUndo holds an undoable value (the working config). `load` replaces it // without history (selecting/saving/creating); `commit` records an edit so it // can be undone/redone. Snapshots are whole values, which is fine for the small // config documents edited here. function useUndo() { const [past, setPast] = useState([]); const [present, setPresent] = useState(null); const [future, setFuture] = useState([]); function load(v: T | null) { setPresent(v); setPast([]); setFuture([]); } function commit(next: T) { if (present != null) setPast(p => [...p, present]); setPresent(next); setFuture([]); } function undo() { if (past.length === 0) return; const prev = past[past.length - 1]; setPast(past.slice(0, -1)); if (present != null) setFuture([present, ...future]); setPresent(prev); } function redo() { if (future.length === 0) return; const next = future[0]; setFuture(future.slice(1)); if (present != null) setPast([...past, present]); setPresent(next); } return { present, load, commit, undo, redo, canUndo: past.length > 0, canRedo: future.length > 0 }; } // Bind Ctrl/Cmd+Z (undo) and Ctrl/Cmd+Shift+Z / Ctrl+Y (redo) while a manager is // active. Native text-edit undo is left alone when a field is focused. function useUndoKeys(undo: () => void, redo: () => void, canUndo: boolean, canRedo: boolean) { useEffect(() => { function onKey(e: KeyboardEvent) { const t = e.target as Element | null; if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT')) return; const mod = e.ctrlKey || e.metaKey; if (!mod) return; const k = e.key.toLowerCase(); if (k === 'z' && !e.shiftKey) { if (canUndo) { e.preventDefault(); e.stopImmediatePropagation(); undo(); } } else if ((k === 'y') || (k === 'z' && e.shiftKey)) { if (canRedo) { e.preventDefault(); e.stopImmediatePropagation(); redo(); } } } window.addEventListener('keydown', onKey, true); return () => window.removeEventListener('keydown', onKey, true); }, [undo, redo, canUndo, canRedo]); } // ── Sets manager ────────────────────────────────────────────────────────────── function SetsManager({ registerGuard }: { registerGuard: (fn: CloseGuard | null) => void }) { const me = useAuth(); const [sets, setSets] = useState([]); const [bucket, setBucket] = useState('mine'); const [scopeGroup, setScopeGroup] = useState(''); const [selectedId, setSelectedId] = useState(null); const hist = useUndo(); const working = hist.present; 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 [viewingVersion, setViewingVersion] = useState(null); const [verReload, setVerReload] = useState(0); const { allOptions, openAll, signalMeta } = useSignals(); const { pending, setPending } = useCloseGuard(registerGuard, dirty); const fileRef = useRef(null); function undo() { hist.undo(); setDirty(true); } function redo() { hist.redo(); setDirty(true); } useUndoKeys(undo, redo, hist.canUndo, hist.canRedo); 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); hist.load(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); hist.load(created); setDirty(false); } catch (err) { setError(`Create failed: ${msg(err)}`); } finally { setBusy(false); } } // Export the selected set's current schema as a downloadable JSON file. function exportSet() { if (!working) return; const payload = { name: working.name, description: working.description ?? '', parameters: working.parameters }; const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${(working.name || 'config-set').replace(/[^\w.-]+/g, '_')}.json`; a.click(); URL.revokeObjectURL(url); } // Import a JSON file as a new config set (its name/description/parameters). async function importSet(file: File) { setBusy(true); setError(null); try { const parsed = JSON.parse(await file.text()); if (!parsed || !Array.isArray(parsed.parameters)) throw new Error('not a valid config set (missing parameters array)'); const body = { name: parsed.name || file.name.replace(/\.json$/i, '') || 'Imported set', description: parsed.description || '', parameters: parsed.parameters }; const created = await apiJSON('/api/v1/config/sets', jsonPost(body)); await reload(); setSelectedId(created!.id); hist.load(created); setDirty(false); } catch (err) { setError(`Import failed: ${msg(err)}`); } finally { setBusy(false); } } async function save(): Promise { if (!working) return false; setBusy(true); setError(null); try { const saved = await apiJSON(`/api/v1/config/sets/${encodeURIComponent(working.id)}`, jsonPut(working)); hist.load(saved); setDirty(false); setViewingVersion(null); setVerReload(k => k + 1); await reload(); return true; } catch (err) { setError(`Save failed: ${msg(err)}`); return false; } 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); hist.load(null); setDirty(false); } await reload(); } catch (err) { setError(`Delete failed: ${msg(err)}`); } finally { setBusy(false); } } function patch(p: Partial) { if (!working) return; hist.commit({ ...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 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)}`); } } // Load a past revision into the editor (non-destructive; saving promotes it to // a new revision). Viewing alone is not a change, so the editor stays clean. async function viewVersion(version: number) { if (!working) return; if (dirty && !confirm('Discard unsaved changes to view this version?')) return; setError(null); try { const s = await apiJSON(`/api/v1/config/sets/${encodeURIComponent(working.id)}/versions/${version}`); hist.load(s); setViewingVersion(version); setDirty(false); } catch (err) { setError(`Load version failed: ${msg(err)}`); } } async function reloadCurrent() { if (!working) return; try { const s = await apiJSON(`/api/v1/config/sets/${encodeURIComponent(working.id)}`); hist.load(s); setViewingVersion(null); setDirty(false); setVerReload(k => k + 1); await reload(); } catch (err) { setError(msg(err)); } } const visibleSets = filterByScope(sets, me, bucket, scopeGroup); return (
Config sets
{ const t = e.target as HTMLInputElement; const f = t.files?.[0]; if (f) importSet(f); t.value = ''; }} />
{sets.length === 0 &&
No config sets yet.
} {sets.length > 0 && visibleSets.length === 0 &&
No config sets in this view.
} {visibleSets.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}