diff --git a/TODO.md b/TODO.md index db358c2..255245a 100644 --- a/TODO.md +++ b/TODO.md @@ -11,14 +11,16 @@ - user can fork an existing config instance to create a new one - user can compare two instances: show unified diff or side by side diff - etc... - - [ ] user can apply and save configuration instances from manager + - [x] user can apply and save configuration instances from manager - [ ] in logic editor and control loop add nodes to read/write/create/apply config instances - - [ ] support for all supported types (number, bools, enums, arrays, string etc) - - [ ] ux elements for config set - - the configuration editor should automatically get type and info from signal when possible - - a slick tree drag and drop editor to order elements and organize in group and sub-groups - - possibility to customise unit, max, min etc - - [ ] ux elements for config instances: + - [x] support for all supported types (number, bools, enums, arrays, string etc) + - [x] ux elements for config set + - [x] the configuration editor should automatically get type and info from signal when possible (type is read-only, auto-derived from the bound signal) + - [x] a slick tree drag and drop editor to order elements and organize in group and sub-groups + - [x] possibility to customise unit, max, min etc + - [x] export / import a config set as JSON + - [x] full-screen config window + - [x] ux elements for config instances: - automatically use the correct setting widget depending on the type (e.g. combo for enum, nubmer input for number): - for arrays: import csv option, display points as mini plot (+ open dialog plot with more info), manual enter points via table like interface - automatically fill with default or existing values (when forking an existing instance) diff --git a/internal/confmgr/apply.go b/internal/confmgr/apply.go index e0690bc..00648bb 100644 --- a/internal/confmgr/apply.go +++ b/internal/confmgr/apply.go @@ -41,6 +41,7 @@ func Apply(set ConfigSet, inst ConfigInstance, write WriteFunc) ApplyResult { res.Entries = append(res.Entries, e) continue } + v = p.normalize(v) e.Value = v if err := write(p.DS, p.Signal, v); err != nil { e.Error = err.Error() diff --git a/internal/confmgr/apply_test.go b/internal/confmgr/apply_test.go index 2de4be6..4d185ae 100644 --- a/internal/confmgr/apply_test.go +++ b/internal/confmgr/apply_test.go @@ -51,6 +51,37 @@ func TestApplyUsesDefaultWhenNoValue(t *testing.T) { } } +func TestApplyArrayNormalizes(t *testing.T) { + set := ConfigSet{ID: "s", Name: "s", Parameters: []Parameter{ + {Key: "wf", DS: "d", Signal: "WF", Type: TypeFloatArray}, + }} + // JSON decoding yields []any of float64 — apply must hand the datasource a []float64. + inst := ConfigInstance{ID: "i", SetID: "s", Values: map[string]any{"wf": []any{1.0, 2.0, 3.0}}} + var got any + res := Apply(set, inst, func(_, _ string, value any) error { got = value; return nil }) + if res.Applied != 1 { + t.Fatalf("applied=%d", res.Applied) + } + arr, ok := got.([]float64) + if !ok || len(arr) != 3 || arr[0] != 1 || arr[2] != 3 { + t.Fatalf("want []float64{1,2,3}, got %T %v", got, got) + } +} + +func TestArrayValidation(t *testing.T) { + lo := 0.0 + p := Parameter{Key: "wf", DS: "d", Signal: "S", Type: TypeFloatArray, Min: &lo} + if err := p.checkValue([]any{1.0, 2.0}); err != nil { + t.Fatalf("valid array rejected: %v", err) + } + if err := p.checkValue([]any{1.0, -5.0}); err == nil { + t.Fatal("expected out-of-range element to be rejected") + } + if err := p.checkValue("notarray"); err == nil { + t.Fatal("expected non-array value to be rejected") + } +} + func TestApplyRecordsFailures(t *testing.T) { set := ConfigSet{ ID: "set1", Name: "s", diff --git a/internal/confmgr/model.go b/internal/confmgr/model.go index c65267c..f66ec12 100644 --- a/internal/confmgr/model.go +++ b/internal/confmgr/model.go @@ -16,16 +16,17 @@ import ( type ParamType string const ( - TypeFloat ParamType = "float64" - TypeInt ParamType = "int64" - TypeBool ParamType = "bool" - TypeString ParamType = "string" - TypeEnum ParamType = "enum" + TypeFloat ParamType = "float64" + TypeInt ParamType = "int64" + TypeBool ParamType = "bool" + TypeString ParamType = "string" + TypeEnum ParamType = "enum" + TypeFloatArray ParamType = "float64[]" // waveform; value is a list of numbers ) func (t ParamType) valid() bool { switch t { - case TypeFloat, TypeInt, TypeBool, TypeString, TypeEnum: + case TypeFloat, TypeInt, TypeBool, TypeString, TypeEnum, TypeFloatArray: return true } return false @@ -197,10 +198,57 @@ func (p Parameter) checkValue(v any) error { if !slices.Contains(p.EnumValues, s) { return fmt.Errorf("value %q is not an allowed enum value", s) } + case TypeFloatArray: + arr, err := toFloatArray(v) + if err != nil { + return err + } + for i, f := range arr { + if p.Min != nil && f < *p.Min { + return fmt.Errorf("element %d value %v below minimum %v", i, f, *p.Min) + } + if p.Max != nil && f > *p.Max { + return fmt.Errorf("element %d value %v above maximum %v", i, f, *p.Max) + } + } } return nil } +// normalize coerces a JSON-decoded value into the canonical Go type the +// datasource write path expects. Array parameters become []float64 (JSON yields +// []any); scalar values are returned unchanged. +func (p Parameter) normalize(v any) any { + if p.Type == TypeFloatArray { + if arr, err := toFloatArray(v); err == nil { + return arr + } + } + return v +} + +// toFloatArray coerces a JSON-decoded array value to []float64. JSON +// unmarshalling yields []any of float64, but a native []float64 is also +// accepted. +func toFloatArray(v any) ([]float64, error) { + switch a := v.(type) { + case []float64: + return a, nil + case []any: + out := make([]float64, len(a)) + for i, e := range a { + f, err := toFloat(e) + if err != nil { + return nil, fmt.Errorf("element %d: %w", i, err) + } + out[i] = f + } + return out, nil + default: + return nil, fmt.Errorf("value %v is not an array", v) + } +} + // toFloat coerces a JSON-decoded numeric value to float64. JSON unmarshalling // yields float64 for numbers, but values may also arrive as int or string. func toFloat(v any) (float64, error) { diff --git a/web/src/ConfigManager.tsx b/web/src/ConfigManager.tsx index c3db27f..d713aa5 100644 --- a/web/src/ConfigManager.tsx +++ b/web/src/ConfigManager.tsx @@ -1,10 +1,10 @@ import { h, Fragment } from 'preact'; -import { useState, useEffect, useCallback } from 'preact/hooks'; +import { useState, useEffect, useCallback, useRef } from 'preact/hooks'; import SignalPicker, { SignalOption } from './SignalPicker'; // ── Types (mirror internal/confmgr) ────────────────────────────────────────── -type ParamType = 'float64' | 'int64' | 'bool' | 'string' | 'enum'; +type ParamType = 'float64' | 'int64' | 'bool' | 'string' | 'enum' | 'float64[]'; interface Parameter { key: string; @@ -54,7 +54,33 @@ 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']; +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 ─────────────────────────────────────────────────────────────── @@ -72,7 +98,7 @@ async function apiJSON(url: string, opts?: RequestInit): Promise([]); - const [dsSignals, setDsSignals] = useState>({}); + const [dsSignals, setDsSignals] = useState>({}); useEffect(() => { fetch('/api/v1/datasources') @@ -87,7 +113,7 @@ function useSignals() { 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) }))) + .then((sigs: SignalInfo[]) => setDsSignals(p => ({ ...p, [ds]: sigs }))) .catch(() => {}); return prev; }); @@ -95,12 +121,15 @@ function useSignals() { function allOptions(): SignalOption[] { const out: SignalOption[] = []; - for (const ds of dataSources) for (const name of dsSignals[ds] ?? []) out.push({ ds, name }); + 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 }; + return { allOptions, openAll, signalMeta }; } function splitRef(ref: string): { ds: string; signal: string } { @@ -120,42 +149,141 @@ function fmtTime(iso: string): string { 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'>('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') => { if (t !== tab) guarded(() => setTab(t)); }; return ( -
{ if (e.target === e.currentTarget) onClose(); }}> -
+
{ if (e.target === e.currentTarget) requestClose(); }}> +
Configuration manager Versioned configuration sets (schemas) and instances (values) that apply to signals.
- +
- - + +
- {tab === 'sets' ? : } + {tab === 'sets' ? : }
); } +// 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() { +function SetsManager({ registerGuard }: { registerGuard: (fn: CloseGuard | null) => void }) { const [sets, setSets] = useState([]); const [selectedId, setSelectedId] = useState(null); - const [working, setWorking] = 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 { allOptions, openAll } = useSignals(); + 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')) ?? []); } @@ -169,7 +297,7 @@ function SetsManager() { try { const s = await apiJSON(`/api/v1/config/sets/${encodeURIComponent(id)}`); setSelectedId(id); - setWorking(s); + hist.load(s); setDirty(false); } catch (err) { setError(msg(err)); } } @@ -181,21 +309,51 @@ function SetsManager() { const created = await apiJSON('/api/v1/config/sets', jsonPost(body)); await reload(); setSelectedId(created!.id); - setWorking(created); + hist.load(created); setDirty(false); } catch (err) { setError(`Create failed: ${msg(err)}`); } finally { setBusy(false); } } - async function save() { + // 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)); - setWorking(saved); + hist.load(saved); setDirty(false); await reload(); - } catch (err) { setError(`Save failed: ${msg(err)}`); } + return true; + } catch (err) { setError(`Save failed: ${msg(err)}`); return false; } finally { setBusy(false); } } @@ -204,7 +362,7 @@ function SetsManager() { setBusy(true); setError(null); try { await apiJSON(`/api/v1/config/sets/${encodeURIComponent(id)}`, { method: 'DELETE' }); - if (selectedId === id) { setSelectedId(null); setWorking(null); setDirty(false); } + if (selectedId === id) { setSelectedId(null); hist.load(null); setDirty(false); } await reload(); } catch (err) { setError(`Delete failed: ${msg(err)}`); } finally { setBusy(false); } @@ -212,7 +370,7 @@ function SetsManager() { function patch(p: Partial) { if (!working) return; - setWorking({ ...working, ...p }); + hist.commit({ ...working, ...p }); setDirty(true); } function patchParam(i: number, p: Partial) { @@ -220,11 +378,6 @@ function SetsManager() { 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) }); @@ -245,7 +398,12 @@ function SetsManager() {
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.map(s => ( @@ -267,22 +425,19 @@ function SetsManager() { onInput={(e) => patch({ name: (e.target as HTMLInputElement).value })} /> v{working.version} {dirty && unsaved} + + +
- patch({ description: (e.target as HTMLInputElement).value })} /> +