Done config

This commit is contained in:
Martino Ferrari
2026-06-21 17:40:04 +02:00
parent b0ac044035
commit 04d31a15c4
8 changed files with 897 additions and 80 deletions
+9 -7
View File
@@ -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)
+1
View File
@@ -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()
+31
View File
@@ -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",
+54 -6
View File
@@ -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) {
+587 -67
View File
@@ -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<ParamType, string> = {
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<T = any>(url: string, opts?: RequestInit): Promise<T | nu
// Lazily-loaded data-source + signal options powering the SignalPicker.
function useSignals() {
const [dataSources, setDataSources] = useState<string[]>([]);
const [dsSignals, setDsSignals] = useState<Record<string, string[]>>({});
const [dsSignals, setDsSignals] = useState<Record<string, SignalInfo[]>>({});
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<CloseGuard | null>(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 (
<div class="cl-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div class="cl-modal">
<div class="cl-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) requestClose(); }}>
<div class="cl-modal cl-modal-full">
<header class="cl-header">
<span class="cl-title">Configuration manager</span>
<span class="hint cl-subtitle">Versioned configuration sets (schemas) and instances (values) that apply to signals.</span>
<div class="cl-header-actions">
<button class="panel-btn" onClick={onClose}>Close</button>
<button class="panel-btn" onClick={requestClose}>Close</button>
</div>
</header>
<div class="cfg-tabs">
<button class={`cfg-tab${tab === 'sets' ? ' cfg-tab-active' : ''}`} onClick={() => setTab('sets')}>Sets</button>
<button class={`cfg-tab${tab === 'instances' ? ' cfg-tab-active' : ''}`} onClick={() => setTab('instances')}>Instances</button>
<button class={`cfg-tab${tab === 'sets' ? ' cfg-tab-active' : ''}`} onClick={() => switchTab('sets')}>Sets</button>
<button class={`cfg-tab${tab === 'instances' ? ' cfg-tab-active' : ''}`} onClick={() => switchTab('instances')}>Instances</button>
</div>
{tab === 'sets' ? <SetsManager /> : <InstancesManager />}
{tab === 'sets' ? <SetsManager registerGuard={registerGuard} /> : <InstancesManager registerGuard={registerGuard} />}
</div>
</div>
);
}
// 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 (
<div class="cl-confirm-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) onCancel(); }}>
<div class="cl-confirm">
<div class="cl-confirm-title">Unsaved changes</div>
<div class="hint">This configuration has unsaved changes. What would you like to do?</div>
<div class="cl-confirm-actions">
<button class="panel-btn" disabled={busy} onClick={onCancel}>Cancel</button>
<button class="panel-btn" disabled={busy} onClick={onDiscard}>Discard</button>
<button class="panel-btn panel-btn-primary" disabled={busy} onClick={onSave}>Save</button>
</div>
</div>
</div>
);
}
// 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<T>() {
const [past, setPast] = useState<T[]>([]);
const [present, setPresent] = useState<T | null>(null);
const [future, setFuture] = useState<T[]>([]);
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<Meta[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [working, setWorking] = useState<ConfigSet | null>(null);
const hist = useUndo<ConfigSet>();
const working = hist.present;
const [dirty, setDirty] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(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<HTMLInputElement>(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<Meta[]>('/api/v1/config/sets')) ?? []); }
@@ -169,7 +297,7 @@ function SetsManager() {
try {
const s = await apiJSON<ConfigSet>(`/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<ConfigSet>('/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<ConfigSet>('/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<boolean> {
if (!working) return false;
setBusy(true); setError(null);
try {
const saved = await apiJSON<ConfigSet>(`/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<ConfigSet>) {
if (!working) return;
setWorking({ ...working, ...p });
hist.commit({ ...working, ...p });
setDirty(true);
}
function patchParam(i: number, p: Partial<Parameter>) {
@@ -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() {
<div class="cl-list">
<div class="cl-list-head">
<span>Config sets</span>
<button class="panel-btn" disabled={busy} onClick={createSet}>+ New</button>
<div class="cfg-list-actions">
<button class="panel-btn" disabled={busy} title="Import a config set from a JSON file" onClick={() => fileRef.current?.click()}>Import</button>
<button class="panel-btn" disabled={busy} onClick={createSet}>+ New</button>
</div>
<input ref={fileRef} type="file" accept="application/json,.json" style={{ display: 'none' }}
onChange={(e) => { const t = e.target as HTMLInputElement; const f = t.files?.[0]; if (f) importSet(f); t.value = ''; }} />
</div>
{sets.length === 0 && <div class="hint cl-list-empty">No config sets yet.</div>}
{sets.map(s => (
@@ -267,22 +425,19 @@ function SetsManager() {
onInput={(e) => patch({ name: (e.target as HTMLInputElement).value })} />
<span class="cfg-badge">v{working.version}</span>
{dirty && <span class="cl-dirty">unsaved</span>}
<button class="panel-btn" disabled={!hist.canUndo} title="Undo (Ctrl+Z)" onClick={undo}></button>
<button class="panel-btn" disabled={!hist.canRedo} title="Redo (Ctrl+Shift+Z)" onClick={redo}></button>
<button class="panel-btn panel-btn-primary" disabled={busy || !dirty} onClick={save}>Save</button>
<button class="panel-btn" disabled={busy} title="Export this config set as JSON" onClick={exportSet}>Export</button>
</div>
<input class="prop-input" value={working.description ?? ''} placeholder="Description (optional)"
onInput={(e) => patch({ description: (e.target as HTMLInputElement).value })} />
<textarea class="prop-input cfg-desc" rows={2} value={working.description ?? ''} placeholder="Description (optional)"
onInput={(e) => patch({ description: (e.target as HTMLTextAreaElement).value })} />
<div class="cfg-section-head">
<span>Parameters</span>
<button class="panel-btn" onClick={addParam}>+ Add parameter</button>
</div>
{working.parameters.length === 0 && <div class="hint">No parameters. Each parameter binds a target signal with a default and constraints.</div>}
<div class="cfg-params">
{working.parameters.map((p, i) => (
<ParamEditor key={i} param={p} allOptions={allOptions()} onOpenSignals={openAll}
onChange={(patch2) => patchParam(i, patch2)} onRemove={() => removeParam(i)} />
))}
</div>
<ParamTree params={working.parameters} allOptions={allOptions()} onOpenSignals={openAll} signalMeta={signalMeta}
onReplace={(ps) => patch({ parameters: ps })} onPatchParam={patchParam} onRemoveParam={removeParam} />
<VersionPanel kind="sets" id={working.id} onReload={reload}
onForked={(id) => { reload(); select(id); }}
@@ -295,19 +450,221 @@ function SetsManager() {
{diff && <DiffModal title={diff.title} onClose={() => setDiff(null)}>
<SetDiffTable entries={diff.entries} />
</DiffModal>}
{pending && (
<UnsavedDialog busy={busy}
onCancel={() => setPending(null)}
onDiscard={() => { const p = pending.proceed; setDirty(false); setPending(null); p(); }}
onSave={async () => { const ok = await save(); if (ok) { const p = pending.proceed; setPending(null); p(); } }} />
)}
</div>
);
}
function ParamEditor({ param, allOptions, onOpenSignals, onChange, onRemove }: {
// ── Parameter tree (group / subgroup organiser) ────────────────────────────────
interface TreeSub { name: string; items: { p: Parameter; idx: number }[]; }
interface TreeGroup { name: string; subs: TreeSub[]; }
// buildParamTree clusters the flat parameter array by group then subgroup,
// preserving first-appearance order. The array order is the canonical order.
function buildParamTree(params: Parameter[]): TreeGroup[] {
const groups: TreeGroup[] = [];
const gmap = new Map<string, TreeGroup>();
params.forEach((p, idx) => {
const gn = p.group || '';
let g = gmap.get(gn);
if (!g) { g = { name: gn, subs: [] }; gmap.set(gn, g); groups.push(g); }
const sn = p.subgroup || '';
let s = g.subs.find(x => x.name === sn);
if (!s) { s = { name: sn, items: [] }; g.subs.push(s); }
s.items.push({ p, idx });
});
return groups;
}
// moveParam removes the dragged parameter and reinserts it under the destination
// group/subgroup — before a specific parameter when beforeKey is given, else at
// the end of that region.
function moveParam(params: Parameter[], draggedKey: string, dest: { group: string; subgroup: string; beforeKey?: string }): Parameter[] {
const dragged = params.find(p => p.key === draggedKey);
if (!dragged) return params;
const moved: Parameter = { ...dragged };
if (dest.group) moved.group = dest.group; else delete moved.group;
if (dest.subgroup) moved.subgroup = dest.subgroup; else delete moved.subgroup;
const rest = params.filter(p => p.key !== draggedKey);
let at: number;
if (dest.beforeKey) {
at = rest.findIndex(p => p.key === dest.beforeKey);
if (at < 0) at = rest.length;
} else {
let last = -1;
rest.forEach((p, i) => { if ((p.group || '') === dest.group && (p.subgroup || '') === dest.subgroup) last = i; });
at = last >= 0 ? last + 1 : rest.length;
}
rest.splice(at, 0, moved);
return rest;
}
function ParamTree({ params, allOptions, onOpenSignals, signalMeta, onReplace, onPatchParam, onRemoveParam }: {
params: Parameter[];
allOptions: SignalOption[];
onOpenSignals: () => void;
signalMeta: (ds: string, name: string) => SignalInfo | undefined;
onReplace: (params: Parameter[]) => void;
onPatchParam: (idx: number, p: Partial<Parameter>) => void;
onRemoveParam: (idx: number) => void;
}) {
const [expanded, setExpanded] = useState<Set<number>>(new Set());
const [dragKey, setDragKey] = useState<string | null>(null);
const [overId, setOverId] = useState<string | null>(null);
const tree = buildParamTree(params);
function toggle(idx: number) {
setExpanded(prev => { const next = new Set(prev); if (next.has(idx)) next.delete(idx); else next.add(idx); return next; });
}
function uniqueKey(): string {
const keys = new Set(params.map(p => p.key));
let n = params.length + 1; while (keys.has(`param${n}`)) n++;
return `param${n}`;
}
function uniqueGroup(): string {
const names = new Set(params.map(p => p.group || ''));
let n = 1; while (names.has(`Group ${n}`)) n++;
return `Group ${n}`;
}
function uniqueSub(group: string): string {
const names = new Set(params.filter(p => (p.group || '') === group).map(p => p.subgroup || ''));
let n = 1; while (names.has(`Subgroup ${n}`)) n++;
return `Subgroup ${n}`;
}
function addParam(group: string, subgroup: string) {
const key = uniqueKey();
const np: Parameter = { key, ds: '', signal: '', type: 'float64' };
if (group) np.group = group;
if (subgroup) np.subgroup = subgroup;
const newIdx = params.length;
onReplace([...params, np]);
setExpanded(prev => new Set(prev).add(newIdx));
}
function renameGroup(oldName: string, newName: string) {
onReplace(params.map(p => (p.group || '') === oldName ? { ...p, group: newName || undefined } : p));
}
function renameSub(group: string, oldSub: string, newSub: string) {
onReplace(params.map(p => ((p.group || '') === group && (p.subgroup || '') === oldSub) ? { ...p, subgroup: newSub || undefined } : p));
}
function drop(dest: { group: string; subgroup: string; beforeKey?: string }) {
if (dragKey) onReplace(moveParam(params, dragKey, dest));
setDragKey(null); setOverId(null);
}
function renderParam(group: string, subgroup: string, p: Parameter, idx: number) {
const open = expanded.has(idx);
return (
<div key={idx}
class={`cfg-tree-param${dragKey === p.key ? ' dragging' : ''}${overId === p.key ? ' drop-over' : ''}`}
onDragOver={(e) => { if (dragKey) { e.preventDefault(); setOverId(p.key); } }}
onDragLeave={() => setOverId(o => (o === p.key ? null : o))}
onDrop={(e) => { e.preventDefault(); e.stopPropagation(); drop({ group, subgroup, beforeKey: p.key }); }}>
<div class="cfg-tree-phead" draggable
onDragStart={() => setDragKey(p.key)} onDragEnd={() => { setDragKey(null); setOverId(null); }}>
<span class="cfg-drag-handle" title="Drag to reorder or move between groups"></span>
<button class="cfg-tree-toggle" onClick={() => toggle(idx)}>{open ? '▾' : '▸'}</button>
<span class="cfg-tree-pkey">{p.key}</span>
{p.label && <span class="cfg-tree-plabel">{p.label}</span>}
<span class="cfg-tree-ptype">{TYPE_LABELS[p.type]}</span>
<span class="cfg-tree-psig hint">{p.ds && p.signal ? `${p.ds}:${p.signal}` : 'no signal'}</span>
</div>
{open && (
<ParamEditor param={p} allOptions={allOptions} onOpenSignals={onOpenSignals} signalMeta={signalMeta}
onChange={(patch2) => onPatchParam(idx, patch2)} onRemove={() => onRemoveParam(idx)} />
)}
</div>
);
}
return (
<div class="cfg-tree">
<div class="cfg-tree-toolbar">
<button class="panel-btn" onClick={() => addParam('', '')}>+ Parameter</button>
<button class="panel-btn" onClick={() => addParam(uniqueGroup(), '')}>+ Group</button>
</div>
{params.length === 0 && <div class="hint">No parameters. Each parameter binds a target signal with a default and constraints. Drag to organise into groups.</div>}
{tree.map((g, gi) => (
<div key={gi} class="cfg-tree-group">
<div class={`cfg-tree-ghead${overId === `g:${g.name}` ? ' drop-over' : ''}`}
onDragOver={(e) => { if (dragKey) { e.preventDefault(); setOverId(`g:${g.name}`); } }}
onDragLeave={() => setOverId(o => (o === `g:${g.name}` ? null : o))}
onDrop={(e) => { e.preventDefault(); drop({ group: g.name, subgroup: '' }); }}>
{g.name === ''
? <span class="cfg-tree-glabel">Ungrouped</span>
: <input class="prop-input cfg-tree-name" value={g.name}
onInput={(e) => renameGroup(g.name, (e.target as HTMLInputElement).value)} />}
<div class="cfg-tree-ghead-actions">
<button class="cl-mini-btn" title="Add parameter to this group" onClick={() => addParam(g.name, '')}>+ Param</button>
<button class="cl-mini-btn" title="Add subgroup" onClick={() => addParam(g.name, uniqueSub(g.name))}>+ Subgroup</button>
</div>
</div>
{g.subs.map((s, si) => (
s.name === '' ? (
<div key={si} class="cfg-tree-items">
{s.items.map(it => renderParam(g.name, '', it.p, it.idx))}
</div>
) : (
<div key={si} class="cfg-tree-sub">
<div class={`cfg-tree-shead${overId === `s:${g.name}/${s.name}` ? ' drop-over' : ''}`}
onDragOver={(e) => { if (dragKey) { e.preventDefault(); setOverId(`s:${g.name}/${s.name}`); } }}
onDragLeave={() => setOverId(o => (o === `s:${g.name}/${s.name}` ? null : o))}
onDrop={(e) => { e.preventDefault(); drop({ group: g.name, subgroup: s.name }); }}>
<span class="cfg-tree-sicon"></span>
<input class="prop-input cfg-tree-name" value={s.name}
onInput={(e) => renameSub(g.name, s.name, (e.target as HTMLInputElement).value)} />
<button class="cl-mini-btn" title="Add parameter to this subgroup" onClick={() => addParam(g.name, s.name)}>+ Param</button>
</div>
<div class="cfg-tree-items">
{s.items.map(it => renderParam(g.name, s.name, it.p, it.idx))}
</div>
</div>
)
))}
</div>
))}
</div>
);
}
function ParamEditor({ param, allOptions, onOpenSignals, signalMeta, onChange, onRemove }: {
param: Parameter;
allOptions: SignalOption[];
onOpenSignals: () => void;
signalMeta: (ds: string, name: string) => SignalInfo | undefined;
onChange: (p: Partial<Parameter>) => void;
onRemove: () => void;
}) {
const ref = param.ds || param.signal ? `${param.ds}:${param.signal}` : '';
const isNum = param.type === 'float64' || param.type === 'int64';
const isArray = param.type === 'float64[]';
const showRange = isNum || isArray;
// Picking a target signal auto-fills type/unit/range/enum from the signal's
// reported metadata when available — the editor stays editable afterwards.
function pickTarget(r: string) {
const s = splitRef(r);
const p: Partial<Parameter> = { ds: s.ds, signal: s.signal };
const meta = signalMeta(s.ds, s.signal);
if (meta) {
p.type = metaToParamType(meta.type);
if (meta.unit) p.unit = meta.unit;
if (meta.enumStrings && meta.enumStrings.length) p.enumValues = meta.enumStrings;
if (meta.displayLow !== meta.displayHigh) { p.min = meta.displayLow; p.max = meta.displayHigh; }
}
onChange(p);
}
return (
<div class="cfg-param">
<div class="cfg-param-row">
@@ -322,9 +679,7 @@ function ParamEditor({ param, allOptions, onOpenSignals, onChange, onRemove }: {
</div>
<div class="cfg-field cfg-field-type">
<label>Type</label>
<select class="prop-select" value={param.type} onChange={(e) => onChange({ type: (e.target as HTMLSelectElement).value as ParamType })}>
{PARAM_TYPES.map(t => <option key={t} value={t}>{t}</option>)}
</select>
<div class="cfg-type-ro" title="Determined automatically from the bound signal">{TYPE_LABELS[param.type]}</div>
</div>
<button class="cl-mini-btn cfg-param-del" title="Remove parameter" onClick={onRemove}></button>
</div>
@@ -332,8 +687,7 @@ function ParamEditor({ param, allOptions, onOpenSignals, onChange, onRemove }: {
<div class="cfg-param-row">
<div class="cfg-field cfg-field-target">
<label>Target signal</label>
<SignalPicker value={ref} options={allOptions} onOpen={onOpenSignals} allowFreeform
onChange={(r) => { const s = splitRef(r); onChange({ ds: s.ds, signal: s.signal }); }} />
<SignalPicker value={ref} options={allOptions} onOpen={onOpenSignals} allowFreeform onChange={pickTarget} />
</div>
<div class="cfg-field cfg-field-default">
<label>Default</label>
@@ -344,6 +698,10 @@ function ParamEditor({ param, allOptions, onOpenSignals, onChange, onRemove }: {
<option value="true">true</option>
<option value="false">false</option>
</select>
) : isArray ? (
<input class="prop-input" type="text" placeholder="1, 2, 3"
value={Array.isArray(param.default) ? (param.default as number[]).join(', ') : ''}
onInput={(e) => onChange({ default: coerceVal((e.target as HTMLInputElement).value, param.type) })} />
) : (
<input class="prop-input" type={isNum ? 'number' : 'text'} value={param.default ?? ''}
onInput={(e) => onChange({ default: coerceVal((e.target as HTMLInputElement).value, param.type) })} />
@@ -356,20 +714,15 @@ function ParamEditor({ param, allOptions, onOpenSignals, onChange, onRemove }: {
</div>
<div class="cfg-param-row">
<div class="cfg-field">
<label>Group</label>
<input class="prop-input" value={param.group ?? ''} placeholder="(optional)"
onInput={(e) => onChange({ group: (e.target as HTMLInputElement).value })} />
</div>
<div class="cfg-field">
<label>Unit</label>
<input class="prop-input" value={param.unit ?? ''} placeholder="(optional)"
onInput={(e) => onChange({ unit: (e.target as HTMLInputElement).value })} />
</div>
{isNum && (
{showRange && (
<Fragment>
<div class="cfg-field cfg-field-num">
<label>Min</label>
<label>Min{isArray ? ' (per pt)' : ''}</label>
<input class="prop-input" type="number" value={param.min ?? ''}
onInput={(e) => onChange({ min: numOrUndef((e.target as HTMLInputElement).value) })} />
</div>
@@ -394,11 +747,12 @@ function ParamEditor({ param, allOptions, onOpenSignals, onChange, onRemove }: {
// ── Instances manager ─────────────────────────────────────────────────────────
function InstancesManager() {
function InstancesManager({ registerGuard }: { registerGuard: (fn: CloseGuard | null) => void }) {
const [instances, setInstances] = useState<Meta[]>([]);
const [sets, setSets] = useState<Meta[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [working, setWorking] = useState<ConfigInstance | null>(null);
const hist = useUndo<ConfigInstance>();
const working = hist.present;
const [boundSet, setBoundSet] = useState<ConfigSet | null>(null);
const [dirty, setDirty] = useState(false);
const [busy, setBusy] = useState(false);
@@ -406,6 +760,11 @@ function InstancesManager() {
const [applyResult, setApplyResult] = useState<ApplyResult | null>(null);
const [diff, setDiff] = useState<{ title: string; entries: InstanceDiffEntry[] } | null>(null);
const [showNew, setShowNew] = useState(false);
const { pending, setPending } = useCloseGuard(registerGuard, dirty);
function undo() { hist.undo(); setDirty(true); }
function redo() { hist.redo(); setDirty(true); }
useUndoKeys(undo, redo, hist.canUndo, hist.canRedo);
const reload = useCallback(async () => {
try {
@@ -429,7 +788,7 @@ function InstancesManager() {
const inst = await apiJSON<ConfigInstance>(`/api/v1/config/instances/${encodeURIComponent(id)}`);
const set = await loadSet(inst!.setId, inst!.setVersion);
setSelectedId(id);
setWorking(inst);
hist.load(inst);
setBoundSet(set);
setDirty(false);
} catch (err) { setError(msg(err)); }
@@ -443,7 +802,7 @@ function InstancesManager() {
const set = await loadSet(created!.setId, created!.setVersion);
await reload();
setSelectedId(created!.id);
setWorking(created);
hist.load(created);
setBoundSet(set);
setDirty(false);
setShowNew(false);
@@ -451,15 +810,16 @@ function InstancesManager() {
finally { setBusy(false); }
}
async function save() {
if (!working) return;
async function save(): Promise<boolean> {
if (!working) return false;
setBusy(true); setError(null);
try {
const saved = await apiJSON<ConfigInstance>(`/api/v1/config/instances/${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); }
}
@@ -468,7 +828,7 @@ function InstancesManager() {
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); }
if (selectedId === id) { setSelectedId(null); hist.load(null); setBoundSet(null); setDirty(false); }
await reload();
} catch (err) { setError(`Delete failed: ${msg(err)}`); }
finally { setBusy(false); }
@@ -490,7 +850,7 @@ function InstancesManager() {
const values = { ...working.values };
if (value === undefined) delete values[key];
else values[key] = value;
setWorking({ ...working, values });
hist.commit({ ...working, values });
setDirty(true);
}
@@ -531,9 +891,11 @@ function InstancesManager() {
<div class="cfg-editor">
<div class="cfg-edit-bar">
<input class="prop-input cl-name-input" value={working.name} placeholder="Instance name"
onInput={(e) => { setWorking({ ...working, name: (e.target as HTMLInputElement).value }); setDirty(true); }} />
onInput={(e) => { hist.commit({ ...working, name: (e.target as HTMLInputElement).value }); setDirty(true); }} />
<span class="cfg-badge">v{working.version}</span>
{dirty && <span class="cl-dirty">unsaved</span>}
<button class="panel-btn" disabled={!hist.canUndo} title="Undo (Ctrl+Z)" onClick={undo}></button>
<button class="panel-btn" disabled={!hist.canRedo} title="Redo (Ctrl+Shift+Z)" onClick={redo}></button>
<button class="panel-btn panel-btn-primary" disabled={busy || !dirty} onClick={save}>Save</button>
<button class="panel-btn" disabled={busy || dirty} title={dirty ? 'Save before applying' : 'Write values to signals'} onClick={apply}>Apply</button>
</div>
@@ -564,6 +926,13 @@ function InstancesManager() {
{diff && <DiffModal title={diff.title} onClose={() => setDiff(null)}>
<InstanceDiffTable entries={diff.entries} />
</DiffModal>}
{pending && (
<UnsavedDialog busy={busy}
onCancel={() => setPending(null)}
onDiscard={() => { const p = pending.proceed; setDirty(false); setPending(null); p(); }}
onSave={async () => { const ok = await save(); if (ok) { const p = pending.proceed; setPending(null); p(); } }} />
)}
</div>
);
}
@@ -571,15 +940,18 @@ function InstancesManager() {
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) : '';
const isArray = param.type === 'float64[]';
const defText = param.default !== undefined && param.default !== null && !Array.isArray(param.default) ? String(param.default) : '';
const err = validateValue(param, value);
return (
<div class="cfg-value-row">
<div class={`cfg-value-row${err ? ' cfg-value-invalid' : ''}`}>
<div class="cfg-value-label">
{param.mandatory && <span class="cfg-req" title="Mandatory">*</span>}
<span title={`${param.ds}:${param.signal}`}>{label}</span>
{param.unit && <span class="hint cfg-unit">{param.unit}</span>}
{err && <span class="cfg-value-err" title={err}></span>}
</div>
<div class="cfg-value-input">
<div class={`cfg-value-input${isArray ? ' cfg-value-input-array' : ''}`}>
{param.type === 'bool' ? (
<select class="prop-select" value={value === undefined ? '' : String(value)}
onChange={(e) => { const v = (e.target as HTMLSelectElement).value; onChange(v === '' ? undefined : v === 'true'); }}>
@@ -593,6 +965,8 @@ function ValueRow({ param, value, onChange }: { param: Parameter; value: any; on
<option value="">{defText ? `default (${defText})` : '(unset)'}</option>
{(param.enumValues ?? []).map(ev => <option key={ev} value={ev}>{ev}</option>)}
</select>
) : isArray ? (
<ArrayValueEditor param={param} value={value} onChange={onChange} />
) : (
<input class="prop-input" type={isNum ? 'number' : 'text'}
value={value === undefined ? '' : value}
@@ -605,6 +979,142 @@ function ValueRow({ param, value, onChange }: { param: Parameter; value: any; on
);
}
// validateValue mirrors the backend's checkValue so missing/out-of-range values
// surface immediately in the editor. Returns null when the value is acceptable.
function validateValue(p: Parameter, value: any): string | null {
const has = value !== undefined && value !== null;
if (!has) {
const hasDefault = p.default !== undefined && p.default !== null;
if (p.mandatory && !hasDefault) return 'Required: no value set and no default';
return null;
}
if (p.type === 'float64' || p.type === 'int64') {
const n = typeof value === 'number' ? value : Number(value);
if (isNaN(n)) return 'Not a number';
if (p.type === 'int64' && !Number.isInteger(n)) return 'Not an integer';
if (p.min !== undefined && n < p.min) return `Below minimum ${p.min}`;
if (p.max !== undefined && n > p.max) return `Above maximum ${p.max}`;
} else if (p.type === 'enum') {
if (!(p.enumValues ?? []).includes(String(value))) return 'Not an allowed enum value';
} else if (p.type === 'float64[]') {
if (!Array.isArray(value)) return 'Not an array';
for (let i = 0; i < value.length; i++) {
const n = value[i];
if (typeof n !== 'number' || isNaN(n)) return `Element ${i} is not a number`;
if (p.min !== undefined && n < p.min) return `Element ${i} below minimum ${p.min}`;
if (p.max !== undefined && n > p.max) return `Element ${i} above maximum ${p.max}`;
}
}
return null;
}
// ArrayValueEditor edits a waveform parameter value: a live sparkline plus
// table / CSV editing and a larger pop-out plot. An empty value falls back to
// the parameter default.
function ArrayValueEditor({ param, value, onChange }: { param: Parameter; value: any; onChange: (v: any) => void }) {
const arr: number[] = Array.isArray(value) ? value : [];
const usingDefault = !Array.isArray(value) && Array.isArray(param.default);
const shown: number[] = arr.length ? arr : (Array.isArray(param.default) ? (param.default as number[]) : []);
const [mode, setMode] = useState<'none' | 'table' | 'csv'>('none');
const [csvText, setCsvText] = useState('');
const [plot, setPlot] = useState(false);
// Empty array clears the value so the default applies again.
const commit = (next: number[]) => onChange(next.length ? next : undefined);
function importCsv() {
commit(parseArray(csvText) ?? []);
setMode('none');
}
return (
<div class="cfg-array">
<div class="cfg-array-bar">
<Sparkline points={shown} width={120} height={28} />
<span class="cfg-array-info hint">{shown.length ? `${shown.length} pts` : '(empty)'}{usingDefault ? ' · default' : ''}</span>
<button class="cl-mini-btn" title="Edit points in a table" onClick={() => setMode(m => m === 'table' ? 'none' : 'table')}>table</button>
<button class="cl-mini-btn" title="Import from CSV / paste" onClick={() => { setCsvText(shown.join(', ')); setMode(m => m === 'csv' ? 'none' : 'csv'); }}>csv</button>
<button class="cl-mini-btn" title="Open larger plot" disabled={!shown.length} onClick={() => setPlot(true)}>plot</button>
{arr.length > 0 && <button class="cl-mini-btn" title="Clear (revert to default)" onClick={() => commit([])}>clear</button>}
</div>
{mode === 'table' && (
<div class="cfg-array-table">
{arr.length === 0 && <div class="hint">No points. Add one below or import CSV.</div>}
{arr.map((n, i) => (
<div class="cfg-array-pt" key={i}>
<span class="cfg-array-idx hint">{i}</span>
<input class="prop-input" type="number" value={n}
onInput={(e) => { const next = arr.slice(); next[i] = Number((e.target as HTMLInputElement).value); commit(next); }} />
<button class="cl-mini-btn" title="Remove point" onClick={() => commit(arr.filter((_, idx) => idx !== i))}></button>
</div>
))}
<button class="panel-btn" onClick={() => commit([...arr, 0])}>+ Add point</button>
</div>
)}
{mode === 'csv' && (
<div class="cfg-array-csv">
<textarea class="prop-input cfg-array-textarea" rows={3} value={csvText}
placeholder="Comma, space or newline separated numbers"
onInput={(e) => setCsvText((e.target as HTMLTextAreaElement).value)} />
<div class="cl-confirm-actions">
<button class="panel-btn" onClick={() => setMode('none')}>Cancel</button>
<button class="panel-btn panel-btn-primary" onClick={importCsv}>Import</button>
</div>
</div>
)}
{plot && <ArrayPlotModal title={param.label || param.key} points={shown} unit={param.unit} onClose={() => setPlot(false)} />}
</div>
);
}
// Sparkline renders a number[] as a simple dependency-free SVG line.
function Sparkline({ points, width, height }: { points: number[]; width: number; height: number }) {
if (!points.length) return <svg class="cfg-spark" width={width} height={height} />;
const min = Math.min(...points), max = Math.max(...points);
const span = max - min || 1;
const n = points.length;
const pad = 2;
const innerH = height - pad * 2;
const path = points.map((v, i) => {
const x = n > 1 ? (i / (n - 1)) * width : width / 2;
const y = pad + innerH - ((v - min) / span) * innerH;
return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`;
}).join(' ');
return (
<svg class="cfg-spark" width={width} height={height} viewBox={`0 0 ${width} ${height}`}>
<path d={path} fill="none" stroke="#4a9eff" stroke-width="1.5" />
</svg>
);
}
function ArrayPlotModal({ title, points, unit, onClose }: { title: string; points: number[]; unit?: string; onClose: () => void }) {
const min = points.length ? Math.min(...points) : 0;
const max = points.length ? Math.max(...points) : 0;
const mean = points.length ? points.reduce((a, b) => a + b, 0) / points.length : 0;
return (
<div class="cl-confirm-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div class="cfg-diff-modal">
<div class="cfg-diff-head">
<span>{title} {points.length} points{unit ? ` (${unit})` : ''}</span>
<button class="cl-mini-btn" onClick={onClose}></button>
</div>
<div class="cfg-array-plot-body">
<Sparkline points={points} width={680} height={240} />
<div class="cfg-array-stats hint">
<span>min {fmtNum(min)}</span>
<span>max {fmtNum(max)}</span>
<span>mean {fmtNum(mean)}</span>
<span>count {points.length}</span>
</div>
</div>
</div>
</div>
);
}
function NewInstanceDialog({ sets, busy, onCancel, onCreate }: {
sets: Meta[]; busy: boolean; onCancel: () => void; onCreate: (name: string, setId: string) => void;
}) {
@@ -761,7 +1271,7 @@ function DiffModal({ title, onClose, children }: { title: string; onClose: () =>
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.default !== undefined) parts.push(`default=${Array.isArray(p.default) ? `[${p.default.length} pts]` : 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}`);
@@ -812,5 +1322,15 @@ function jsonPut(body: any): RequestInit { return { method: 'PUT', headers: { 'C
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; }
if (type === 'float64[]') return parseArray(v);
return v;
}
// parseArray turns a comma/space/semicolon/newline-separated string into a
// number[]. Returns undefined for an empty input (so it falls back to default).
function parseArray(v: string): number[] | undefined {
const nums = v.split(/[\s,;]+/).map(s => s.trim()).filter(Boolean).map(Number).filter(n => !isNaN(n));
return nums.length ? nums : undefined;
}
function fmtNum(n: number): string { return Number.isInteger(n) ? String(n) : n.toFixed(3); }
+214
View File
@@ -1084,10 +1084,14 @@ body {
}
.toolbar-btn {
display: inline-flex;
align-items: center;
justify-content: center;
background: #2d3748;
border: none;
color: #e2e8f0;
font-size: 0.8rem;
line-height: 1.2;
padding: 0.25rem 0.75rem;
border-radius: 4px;
cursor: pointer;
@@ -3495,6 +3499,13 @@ kbd {
overflow: hidden;
}
.cl-modal-full {
width: 98vw;
height: 96vh;
max-width: none;
max-height: none;
}
.cl-confirm-overlay {
position: fixed;
inset: 0;
@@ -3775,6 +3786,16 @@ kbd {
.cl-name-input { width: 240px; }
.cfg-desc {
width: 100%;
box-sizing: border-box;
resize: vertical;
min-height: 2.4rem;
max-height: 6.5rem;
font-family: inherit;
line-height: 1.4;
}
.cl-enable {
display: flex;
align-items: center;
@@ -3984,6 +4005,131 @@ kbd {
margin-left: 0.25rem;
}
.cfg-list-actions {
display: flex;
gap: 0.35rem;
flex: 0 0 auto;
}
/* Type is auto-derived from the bound signal — shown read-only. */
.cfg-type-ro {
display: flex;
align-items: center;
height: 1.9rem;
padding: 0 0.5rem;
border: 1px dashed #2d3748;
border-radius: 4px;
background: #11151f;
color: #94a3b8;
font-size: 0.8rem;
white-space: nowrap;
}
/* ── Parameter tree (group / subgroup organiser) ─────────────────────────── */
.cfg-tree {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.cfg-tree-toolbar {
display: flex;
gap: 0.4rem;
}
.cfg-tree-group {
border: 1px solid #232a3a;
border-radius: 6px;
background: #12161f;
}
.cfg-tree-ghead {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.3rem 0.5rem;
background: #1a2030;
border-bottom: 1px solid #232a3a;
border-radius: 6px 6px 0 0;
}
.cfg-tree-glabel {
font-size: 0.8rem;
font-weight: 600;
color: #94a3b8;
flex: 1;
}
.cfg-tree-name {
flex: 1;
min-width: 0;
font-weight: 600;
}
.cfg-tree-ghead-actions {
display: flex;
gap: 0.3rem;
flex: 0 0 auto;
}
.cfg-tree-sub {
margin: 0.3rem 0 0.3rem 0.75rem;
border-left: 2px solid #232a3a;
}
.cfg-tree-shead {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.25rem 0.5rem;
}
.cfg-tree-sicon { color: #475569; }
.cfg-tree-items {
display: flex;
flex-direction: column;
gap: 0.3rem;
padding: 0.3rem 0.5rem 0.4rem;
}
.cfg-tree-param {
border: 1px solid #232a3a;
border-radius: 5px;
background: #151a26;
}
.cfg-tree-param.dragging { opacity: 0.45; }
.cfg-tree-param.drop-over { border-top: 2px solid #8b5cf6; }
.cfg-tree-ghead.drop-over,
.cfg-tree-shead.drop-over { outline: 2px dashed #8b5cf6; outline-offset: -2px; }
.cfg-tree-phead {
display: flex;
align-items: center;
gap: 0.45rem;
padding: 0.35rem 0.5rem;
cursor: grab;
}
.cfg-tree-phead:active { cursor: grabbing; }
.cfg-drag-handle {
color: #475569;
cursor: grab;
font-size: 0.9rem;
user-select: none;
}
.cfg-tree-toggle {
background: transparent;
border: none;
color: #94a3b8;
cursor: pointer;
font-size: 0.8rem;
padding: 0 0.15rem;
}
.cfg-tree-pkey {
font-family: var(--mono, monospace);
font-size: 0.8rem;
color: #e2e8f0;
}
.cfg-tree-plabel { font-size: 0.78rem; color: #94a3b8; }
.cfg-tree-ptype {
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.03em;
color: #818cf8;
border: 1px solid #2d3748;
border-radius: 3px;
padding: 0 0.3rem;
}
.cfg-tree-psig { margin-left: auto; font-size: 0.72rem; }
/* ── Instance values ───────────────────────────────────────────────────── */
.cfg-values {
display: flex;
@@ -4140,3 +4286,71 @@ kbd {
.cfg-diff-changed { background: rgba(234, 179, 8, 0.08); }
.cfg-diff-changed .cfg-diff-status { color: #facc15; }
.cfg-diff-unchanged .cfg-diff-status { color: #64748b; }
/* ── Config manager: array value editor ────────────────────────────────── */
.cfg-value-input-array { width: auto; flex: 1; }
.cfg-array { display: flex; flex-direction: column; gap: 0.35rem; }
.cfg-array-bar {
display: flex;
align-items: center;
gap: 0.4rem;
}
.cfg-spark {
background: #0f1117;
border: 1px solid #232a3a;
border-radius: 4px;
flex: 0 0 auto;
}
.cfg-array-info { flex: 0 0 auto; min-width: 4rem; }
.cfg-array-table {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.4rem;
background: #0f1117;
border: 1px solid #232a3a;
border-radius: 5px;
max-height: 12rem;
overflow-y: auto;
}
.cfg-array-pt {
display: flex;
align-items: center;
gap: 0.4rem;
}
.cfg-array-idx { width: 2rem; text-align: right; flex: 0 0 auto; }
.cfg-array-pt .prop-input { width: 7rem; flex: 0 0 auto; }
.cfg-array-csv {
display: flex;
flex-direction: column;
gap: 0.4rem;
padding: 0.4rem;
background: #0f1117;
border: 1px solid #232a3a;
border-radius: 5px;
}
.cfg-array-textarea {
width: 100%;
resize: vertical;
font-family: ui-monospace, monospace;
flex: none;
}
.cfg-array-plot-body {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.6rem;
padding: 1rem;
}
.cfg-array-stats {
display: flex;
gap: 1.25rem;
font-size: 0.8rem;
}
/* Invalid value highlighting */
.cfg-value-invalid .cfg-value-label { color: #fca5a5; }
.cfg-value-err { color: #f87171; cursor: help; margin-left: 0.25rem; }
Binary file not shown.
+1
View File
@@ -1,3 +1,4 @@
Starting iocInit
iocRun: All initialization complete
CAS: CA beacon send to 192.168.1.255:5065 ERROR: Network is unreachable
CAS: CA beacon send to 192.168.1.255:5065 ok