Done config
This commit is contained in:
1 parent
b0ac044035
commit
04d31a15c4
8 files changed
+891
-74
No files matched your search
@@ -11,14 +11,16 @@
|
|||||||
- user can fork an existing config instance to create a new one
|
- 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
|
- user can compare two instances: show unified diff or side by side diff
|
||||||
- etc...
|
- 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
|
- [ ] 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)
|
- [x] support for all supported types (number, bools, enums, arrays, string etc)
|
||||||
- [ ] ux elements for config set
|
- [x] ux elements for config set
|
||||||
- the configuration editor should automatically get type and info from signal when possible
|
- [x] the configuration editor should automatically get type and info from signal when possible (type is read-only, auto-derived from the bound signal)
|
||||||
- a slick tree drag and drop editor to order elements and organize in group and sub-groups
|
- [x] a slick tree drag and drop editor to order elements and organize in group and sub-groups
|
||||||
- possibility to customise unit, max, min etc
|
- [x] possibility to customise unit, max, min etc
|
||||||
- [ ] ux elements for config instances:
|
- [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):
|
- 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
|
- 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)
|
- automatically fill with default or existing values (when forking an existing instance)
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ func Apply(set ConfigSet, inst ConfigInstance, write WriteFunc) ApplyResult {
|
|||||||
res.Entries = append(res.Entries, e)
|
res.Entries = append(res.Entries, e)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
v = p.normalize(v)
|
||||||
e.Value = v
|
e.Value = v
|
||||||
if err := write(p.DS, p.Signal, v); err != nil {
|
if err := write(p.DS, p.Signal, v); err != nil {
|
||||||
e.Error = err.Error()
|
e.Error = err.Error()
|
||||||
|
|||||||
@@ -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) {
|
func TestApplyRecordsFailures(t *testing.T) {
|
||||||
set := ConfigSet{
|
set := ConfigSet{
|
||||||
ID: "set1", Name: "s",
|
ID: "set1", Name: "s",
|
||||||
|
|||||||
@@ -21,11 +21,12 @@ const (
|
|||||||
TypeBool ParamType = "bool"
|
TypeBool ParamType = "bool"
|
||||||
TypeString ParamType = "string"
|
TypeString ParamType = "string"
|
||||||
TypeEnum ParamType = "enum"
|
TypeEnum ParamType = "enum"
|
||||||
|
TypeFloatArray ParamType = "float64[]" // waveform; value is a list of numbers
|
||||||
)
|
)
|
||||||
|
|
||||||
func (t ParamType) valid() bool {
|
func (t ParamType) valid() bool {
|
||||||
switch t {
|
switch t {
|
||||||
case TypeFloat, TypeInt, TypeBool, TypeString, TypeEnum:
|
case TypeFloat, TypeInt, TypeBool, TypeString, TypeEnum, TypeFloatArray:
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
@@ -197,10 +198,57 @@ func (p Parameter) checkValue(v any) error {
|
|||||||
if !slices.Contains(p.EnumValues, s) {
|
if !slices.Contains(p.EnumValues, s) {
|
||||||
return fmt.Errorf("value %q is not an allowed enum value", 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
|
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
|
// toFloat coerces a JSON-decoded numeric value to float64. JSON unmarshalling
|
||||||
// yields float64 for numbers, but values may also arrive as int or string.
|
// yields float64 for numbers, but values may also arrive as int or string.
|
||||||
func toFloat(v any) (float64, error) {
|
func toFloat(v any) (float64, error) {
|
||||||
|
|||||||
+586
-66
@@ -1,10 +1,10 @@
|
|||||||
import { h, Fragment } from 'preact';
|
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';
|
import SignalPicker, { SignalOption } from './SignalPicker';
|
||||||
|
|
||||||
// ── Types (mirror internal/confmgr) ──────────────────────────────────────────
|
// ── Types (mirror internal/confmgr) ──────────────────────────────────────────
|
||||||
|
|
||||||
type ParamType = 'float64' | 'int64' | 'bool' | 'string' | 'enum';
|
type ParamType = 'float64' | 'int64' | 'bool' | 'string' | 'enum' | 'float64[]';
|
||||||
|
|
||||||
interface Parameter {
|
interface Parameter {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -54,7 +54,33 @@ type DiffStatus = 'added' | 'removed' | 'changed' | 'unchanged';
|
|||||||
interface SetDiffEntry { key: string; status: DiffStatus; left?: Parameter; right?: Parameter; }
|
interface SetDiffEntry { key: string; status: DiffStatus; left?: Parameter; right?: Parameter; }
|
||||||
interface InstanceDiffEntry { key: string; status: DiffStatus; left?: any; right?: any; }
|
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 ───────────────────────────────────────────────────────────────
|
// ── 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.
|
// Lazily-loaded data-source + signal options powering the SignalPicker.
|
||||||
function useSignals() {
|
function useSignals() {
|
||||||
const [dataSources, setDataSources] = useState<string[]>([]);
|
const [dataSources, setDataSources] = useState<string[]>([]);
|
||||||
const [dsSignals, setDsSignals] = useState<Record<string, string[]>>({});
|
const [dsSignals, setDsSignals] = useState<Record<string, SignalInfo[]>>({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/api/v1/datasources')
|
fetch('/api/v1/datasources')
|
||||||
@@ -87,7 +113,7 @@ function useSignals() {
|
|||||||
if (prev[ds]) return prev;
|
if (prev[ds]) return prev;
|
||||||
fetch(`/api/v1/signals?ds=${encodeURIComponent(ds)}`)
|
fetch(`/api/v1/signals?ds=${encodeURIComponent(ds)}`)
|
||||||
.then(r => (r.ok ? r.json() : []))
|
.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(() => {});
|
.catch(() => {});
|
||||||
return prev;
|
return prev;
|
||||||
});
|
});
|
||||||
@@ -95,12 +121,15 @@ function useSignals() {
|
|||||||
|
|
||||||
function allOptions(): SignalOption[] {
|
function allOptions(): SignalOption[] {
|
||||||
const out: 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;
|
return out;
|
||||||
}
|
}
|
||||||
function openAll() { dataSources.forEach(loadSignals); }
|
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 } {
|
function splitRef(ref: string): { ds: string; signal: string } {
|
||||||
@@ -120,42 +149,141 @@ function fmtTime(iso: string): string {
|
|||||||
|
|
||||||
interface Props { onClose: () => void; }
|
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) {
|
export default function ConfigManager({ onClose }: Props) {
|
||||||
const [tab, setTab] = useState<'sets' | 'instances'>('sets');
|
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 (
|
return (
|
||||||
<div class="cl-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
<div class="cl-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) requestClose(); }}>
|
||||||
<div class="cl-modal">
|
<div class="cl-modal cl-modal-full">
|
||||||
<header class="cl-header">
|
<header class="cl-header">
|
||||||
<span class="cl-title">Configuration manager</span>
|
<span class="cl-title">Configuration manager</span>
|
||||||
<span class="hint cl-subtitle">Versioned configuration sets (schemas) and instances (values) that apply to signals.</span>
|
<span class="hint cl-subtitle">Versioned configuration sets (schemas) and instances (values) that apply to signals.</span>
|
||||||
<div class="cl-header-actions">
|
<div class="cl-header-actions">
|
||||||
<button class="panel-btn" onClick={onClose}>Close</button>
|
<button class="panel-btn" onClick={requestClose}>Close</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="cfg-tabs">
|
<div class="cfg-tabs">
|
||||||
<button class={`cfg-tab${tab === 'sets' ? ' cfg-tab-active' : ''}`} onClick={() => setTab('sets')}>Sets</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={() => setTab('instances')}>Instances</button>
|
<button class={`cfg-tab${tab === 'instances' ? ' cfg-tab-active' : ''}`} onClick={() => switchTab('instances')}>Instances</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tab === 'sets' ? <SetsManager /> : <InstancesManager />}
|
{tab === 'sets' ? <SetsManager registerGuard={registerGuard} /> : <InstancesManager registerGuard={registerGuard} />}
|
||||||
</div>
|
</div>
|
||||||
</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 ──────────────────────────────────────────────────────────────
|
// ── Sets manager ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function SetsManager() {
|
function SetsManager({ registerGuard }: { registerGuard: (fn: CloseGuard | null) => void }) {
|
||||||
const [sets, setSets] = useState<Meta[]>([]);
|
const [sets, setSets] = useState<Meta[]>([]);
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
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 [dirty, setDirty] = useState(false);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [diff, setDiff] = useState<{ title: string; entries: SetDiffEntry[] } | 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 () => {
|
const reload = useCallback(async () => {
|
||||||
try { setSets((await apiJSON<Meta[]>('/api/v1/config/sets')) ?? []); }
|
try { setSets((await apiJSON<Meta[]>('/api/v1/config/sets')) ?? []); }
|
||||||
@@ -169,7 +297,7 @@ function SetsManager() {
|
|||||||
try {
|
try {
|
||||||
const s = await apiJSON<ConfigSet>(`/api/v1/config/sets/${encodeURIComponent(id)}`);
|
const s = await apiJSON<ConfigSet>(`/api/v1/config/sets/${encodeURIComponent(id)}`);
|
||||||
setSelectedId(id);
|
setSelectedId(id);
|
||||||
setWorking(s);
|
hist.load(s);
|
||||||
setDirty(false);
|
setDirty(false);
|
||||||
} catch (err) { setError(msg(err)); }
|
} catch (err) { setError(msg(err)); }
|
||||||
}
|
}
|
||||||
@@ -181,21 +309,51 @@ function SetsManager() {
|
|||||||
const created = await apiJSON<ConfigSet>('/api/v1/config/sets', jsonPost(body));
|
const created = await apiJSON<ConfigSet>('/api/v1/config/sets', jsonPost(body));
|
||||||
await reload();
|
await reload();
|
||||||
setSelectedId(created!.id);
|
setSelectedId(created!.id);
|
||||||
setWorking(created);
|
hist.load(created);
|
||||||
setDirty(false);
|
setDirty(false);
|
||||||
} catch (err) { setError(`Create failed: ${msg(err)}`); }
|
} catch (err) { setError(`Create failed: ${msg(err)}`); }
|
||||||
finally { setBusy(false); }
|
finally { setBusy(false); }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function save() {
|
// Export the selected set's current schema as a downloadable JSON file.
|
||||||
|
function exportSet() {
|
||||||
if (!working) return;
|
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);
|
setBusy(true); setError(null);
|
||||||
try {
|
try {
|
||||||
const saved = await apiJSON<ConfigSet>(`/api/v1/config/sets/${encodeURIComponent(working.id)}`, jsonPut(working));
|
const saved = await apiJSON<ConfigSet>(`/api/v1/config/sets/${encodeURIComponent(working.id)}`, jsonPut(working));
|
||||||
setWorking(saved);
|
hist.load(saved);
|
||||||
setDirty(false);
|
setDirty(false);
|
||||||
await reload();
|
await reload();
|
||||||
} catch (err) { setError(`Save failed: ${msg(err)}`); }
|
return true;
|
||||||
|
} catch (err) { setError(`Save failed: ${msg(err)}`); return false; }
|
||||||
finally { setBusy(false); }
|
finally { setBusy(false); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,7 +362,7 @@ function SetsManager() {
|
|||||||
setBusy(true); setError(null);
|
setBusy(true); setError(null);
|
||||||
try {
|
try {
|
||||||
await apiJSON(`/api/v1/config/sets/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
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();
|
await reload();
|
||||||
} catch (err) { setError(`Delete failed: ${msg(err)}`); }
|
} catch (err) { setError(`Delete failed: ${msg(err)}`); }
|
||||||
finally { setBusy(false); }
|
finally { setBusy(false); }
|
||||||
@@ -212,7 +370,7 @@ function SetsManager() {
|
|||||||
|
|
||||||
function patch(p: Partial<ConfigSet>) {
|
function patch(p: Partial<ConfigSet>) {
|
||||||
if (!working) return;
|
if (!working) return;
|
||||||
setWorking({ ...working, ...p });
|
hist.commit({ ...working, ...p });
|
||||||
setDirty(true);
|
setDirty(true);
|
||||||
}
|
}
|
||||||
function patchParam(i: number, p: Partial<Parameter>) {
|
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));
|
const params = working.parameters.map((x, idx) => (idx === i ? { ...x, ...p } : x));
|
||||||
patch({ parameters: params });
|
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) {
|
function removeParam(i: number) {
|
||||||
if (!working) return;
|
if (!working) return;
|
||||||
patch({ parameters: working.parameters.filter((_, idx) => idx !== i) });
|
patch({ parameters: working.parameters.filter((_, idx) => idx !== i) });
|
||||||
@@ -245,8 +398,13 @@ function SetsManager() {
|
|||||||
<div class="cl-list">
|
<div class="cl-list">
|
||||||
<div class="cl-list-head">
|
<div class="cl-list-head">
|
||||||
<span>Config sets</span>
|
<span>Config sets</span>
|
||||||
|
<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>
|
<button class="panel-btn" disabled={busy} onClick={createSet}>+ New</button>
|
||||||
</div>
|
</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.length === 0 && <div class="hint cl-list-empty">No config sets yet.</div>}
|
||||||
{sets.map(s => (
|
{sets.map(s => (
|
||||||
<div key={s.id} class={`cl-list-item${selectedId === s.id ? ' cl-list-item-active' : ''}`} onClick={() => select(s.id)}>
|
<div key={s.id} class={`cl-list-item${selectedId === s.id ? ' cl-list-item-active' : ''}`} onClick={() => select(s.id)}>
|
||||||
@@ -267,22 +425,19 @@ function SetsManager() {
|
|||||||
onInput={(e) => patch({ name: (e.target as HTMLInputElement).value })} />
|
onInput={(e) => patch({ name: (e.target as HTMLInputElement).value })} />
|
||||||
<span class="cfg-badge">v{working.version}</span>
|
<span class="cfg-badge">v{working.version}</span>
|
||||||
{dirty && <span class="cl-dirty">unsaved</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 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>
|
</div>
|
||||||
<input class="prop-input" value={working.description ?? ''} placeholder="Description (optional)"
|
<textarea class="prop-input cfg-desc" rows={2} value={working.description ?? ''} placeholder="Description (optional)"
|
||||||
onInput={(e) => patch({ description: (e.target as HTMLInputElement).value })} />
|
onInput={(e) => patch({ description: (e.target as HTMLTextAreaElement).value })} />
|
||||||
|
|
||||||
<div class="cfg-section-head">
|
<div class="cfg-section-head">
|
||||||
<span>Parameters</span>
|
<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>
|
</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}
|
<VersionPanel kind="sets" id={working.id} onReload={reload}
|
||||||
onForked={(id) => { reload(); select(id); }}
|
onForked={(id) => { reload(); select(id); }}
|
||||||
@@ -295,19 +450,221 @@ function SetsManager() {
|
|||||||
{diff && <DiffModal title={diff.title} onClose={() => setDiff(null)}>
|
{diff && <DiffModal title={diff.title} onClose={() => setDiff(null)}>
|
||||||
<SetDiffTable entries={diff.entries} />
|
<SetDiffTable entries={diff.entries} />
|
||||||
</DiffModal>}
|
</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>
|
</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;
|
param: Parameter;
|
||||||
allOptions: SignalOption[];
|
allOptions: SignalOption[];
|
||||||
onOpenSignals: () => void;
|
onOpenSignals: () => void;
|
||||||
|
signalMeta: (ds: string, name: string) => SignalInfo | undefined;
|
||||||
onChange: (p: Partial<Parameter>) => void;
|
onChange: (p: Partial<Parameter>) => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
}) {
|
}) {
|
||||||
const ref = param.ds || param.signal ? `${param.ds}:${param.signal}` : '';
|
const ref = param.ds || param.signal ? `${param.ds}:${param.signal}` : '';
|
||||||
const isNum = param.type === 'float64' || param.type === 'int64';
|
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 (
|
return (
|
||||||
<div class="cfg-param">
|
<div class="cfg-param">
|
||||||
<div class="cfg-param-row">
|
<div class="cfg-param-row">
|
||||||
@@ -322,9 +679,7 @@ function ParamEditor({ param, allOptions, onOpenSignals, onChange, onRemove }: {
|
|||||||
</div>
|
</div>
|
||||||
<div class="cfg-field cfg-field-type">
|
<div class="cfg-field cfg-field-type">
|
||||||
<label>Type</label>
|
<label>Type</label>
|
||||||
<select class="prop-select" value={param.type} onChange={(e) => onChange({ type: (e.target as HTMLSelectElement).value as ParamType })}>
|
<div class="cfg-type-ro" title="Determined automatically from the bound signal">{TYPE_LABELS[param.type]}</div>
|
||||||
{PARAM_TYPES.map(t => <option key={t} value={t}>{t}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<button class="cl-mini-btn cfg-param-del" title="Remove parameter" onClick={onRemove}>✕</button>
|
<button class="cl-mini-btn cfg-param-del" title="Remove parameter" onClick={onRemove}>✕</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -332,8 +687,7 @@ function ParamEditor({ param, allOptions, onOpenSignals, onChange, onRemove }: {
|
|||||||
<div class="cfg-param-row">
|
<div class="cfg-param-row">
|
||||||
<div class="cfg-field cfg-field-target">
|
<div class="cfg-field cfg-field-target">
|
||||||
<label>Target signal</label>
|
<label>Target signal</label>
|
||||||
<SignalPicker value={ref} options={allOptions} onOpen={onOpenSignals} allowFreeform
|
<SignalPicker value={ref} options={allOptions} onOpen={onOpenSignals} allowFreeform onChange={pickTarget} />
|
||||||
onChange={(r) => { const s = splitRef(r); onChange({ ds: s.ds, signal: s.signal }); }} />
|
|
||||||
</div>
|
</div>
|
||||||
<div class="cfg-field cfg-field-default">
|
<div class="cfg-field cfg-field-default">
|
||||||
<label>Default</label>
|
<label>Default</label>
|
||||||
@@ -344,6 +698,10 @@ function ParamEditor({ param, allOptions, onOpenSignals, onChange, onRemove }: {
|
|||||||
<option value="true">true</option>
|
<option value="true">true</option>
|
||||||
<option value="false">false</option>
|
<option value="false">false</option>
|
||||||
</select>
|
</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 ?? ''}
|
<input class="prop-input" type={isNum ? 'number' : 'text'} value={param.default ?? ''}
|
||||||
onInput={(e) => onChange({ default: coerceVal((e.target as HTMLInputElement).value, param.type) })} />
|
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>
|
||||||
|
|
||||||
<div class="cfg-param-row">
|
<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">
|
<div class="cfg-field">
|
||||||
<label>Unit</label>
|
<label>Unit</label>
|
||||||
<input class="prop-input" value={param.unit ?? ''} placeholder="(optional)"
|
<input class="prop-input" value={param.unit ?? ''} placeholder="(optional)"
|
||||||
onInput={(e) => onChange({ unit: (e.target as HTMLInputElement).value })} />
|
onInput={(e) => onChange({ unit: (e.target as HTMLInputElement).value })} />
|
||||||
</div>
|
</div>
|
||||||
{isNum && (
|
{showRange && (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
<div class="cfg-field cfg-field-num">
|
<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 ?? ''}
|
<input class="prop-input" type="number" value={param.min ?? ''}
|
||||||
onInput={(e) => onChange({ min: numOrUndef((e.target as HTMLInputElement).value) })} />
|
onInput={(e) => onChange({ min: numOrUndef((e.target as HTMLInputElement).value) })} />
|
||||||
</div>
|
</div>
|
||||||
@@ -394,11 +747,12 @@ function ParamEditor({ param, allOptions, onOpenSignals, onChange, onRemove }: {
|
|||||||
|
|
||||||
// ── Instances manager ─────────────────────────────────────────────────────────
|
// ── Instances manager ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function InstancesManager() {
|
function InstancesManager({ registerGuard }: { registerGuard: (fn: CloseGuard | null) => void }) {
|
||||||
const [instances, setInstances] = useState<Meta[]>([]);
|
const [instances, setInstances] = useState<Meta[]>([]);
|
||||||
const [sets, setSets] = useState<Meta[]>([]);
|
const [sets, setSets] = useState<Meta[]>([]);
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
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 [boundSet, setBoundSet] = useState<ConfigSet | null>(null);
|
||||||
const [dirty, setDirty] = useState(false);
|
const [dirty, setDirty] = useState(false);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -406,6 +760,11 @@ function InstancesManager() {
|
|||||||
const [applyResult, setApplyResult] = useState<ApplyResult | null>(null);
|
const [applyResult, setApplyResult] = useState<ApplyResult | null>(null);
|
||||||
const [diff, setDiff] = useState<{ title: string; entries: InstanceDiffEntry[] } | null>(null);
|
const [diff, setDiff] = useState<{ title: string; entries: InstanceDiffEntry[] } | null>(null);
|
||||||
const [showNew, setShowNew] = useState(false);
|
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 () => {
|
const reload = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -429,7 +788,7 @@ function InstancesManager() {
|
|||||||
const inst = await apiJSON<ConfigInstance>(`/api/v1/config/instances/${encodeURIComponent(id)}`);
|
const inst = await apiJSON<ConfigInstance>(`/api/v1/config/instances/${encodeURIComponent(id)}`);
|
||||||
const set = await loadSet(inst!.setId, inst!.setVersion);
|
const set = await loadSet(inst!.setId, inst!.setVersion);
|
||||||
setSelectedId(id);
|
setSelectedId(id);
|
||||||
setWorking(inst);
|
hist.load(inst);
|
||||||
setBoundSet(set);
|
setBoundSet(set);
|
||||||
setDirty(false);
|
setDirty(false);
|
||||||
} catch (err) { setError(msg(err)); }
|
} catch (err) { setError(msg(err)); }
|
||||||
@@ -443,7 +802,7 @@ function InstancesManager() {
|
|||||||
const set = await loadSet(created!.setId, created!.setVersion);
|
const set = await loadSet(created!.setId, created!.setVersion);
|
||||||
await reload();
|
await reload();
|
||||||
setSelectedId(created!.id);
|
setSelectedId(created!.id);
|
||||||
setWorking(created);
|
hist.load(created);
|
||||||
setBoundSet(set);
|
setBoundSet(set);
|
||||||
setDirty(false);
|
setDirty(false);
|
||||||
setShowNew(false);
|
setShowNew(false);
|
||||||
@@ -451,15 +810,16 @@ function InstancesManager() {
|
|||||||
finally { setBusy(false); }
|
finally { setBusy(false); }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function save() {
|
async function save(): Promise<boolean> {
|
||||||
if (!working) return;
|
if (!working) return false;
|
||||||
setBusy(true); setError(null);
|
setBusy(true); setError(null);
|
||||||
try {
|
try {
|
||||||
const saved = await apiJSON<ConfigInstance>(`/api/v1/config/instances/${encodeURIComponent(working.id)}`, jsonPut(working));
|
const saved = await apiJSON<ConfigInstance>(`/api/v1/config/instances/${encodeURIComponent(working.id)}`, jsonPut(working));
|
||||||
setWorking(saved);
|
hist.load(saved);
|
||||||
setDirty(false);
|
setDirty(false);
|
||||||
await reload();
|
await reload();
|
||||||
} catch (err) { setError(`Save failed: ${msg(err)}`); }
|
return true;
|
||||||
|
} catch (err) { setError(`Save failed: ${msg(err)}`); return false; }
|
||||||
finally { setBusy(false); }
|
finally { setBusy(false); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -468,7 +828,7 @@ function InstancesManager() {
|
|||||||
setBusy(true); setError(null);
|
setBusy(true); setError(null);
|
||||||
try {
|
try {
|
||||||
await apiJSON(`/api/v1/config/instances/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
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();
|
await reload();
|
||||||
} catch (err) { setError(`Delete failed: ${msg(err)}`); }
|
} catch (err) { setError(`Delete failed: ${msg(err)}`); }
|
||||||
finally { setBusy(false); }
|
finally { setBusy(false); }
|
||||||
@@ -490,7 +850,7 @@ function InstancesManager() {
|
|||||||
const values = { ...working.values };
|
const values = { ...working.values };
|
||||||
if (value === undefined) delete values[key];
|
if (value === undefined) delete values[key];
|
||||||
else values[key] = value;
|
else values[key] = value;
|
||||||
setWorking({ ...working, values });
|
hist.commit({ ...working, values });
|
||||||
setDirty(true);
|
setDirty(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -531,9 +891,11 @@ function InstancesManager() {
|
|||||||
<div class="cfg-editor">
|
<div class="cfg-editor">
|
||||||
<div class="cfg-edit-bar">
|
<div class="cfg-edit-bar">
|
||||||
<input class="prop-input cl-name-input" value={working.name} placeholder="Instance name"
|
<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>
|
<span class="cfg-badge">v{working.version}</span>
|
||||||
{dirty && <span class="cl-dirty">unsaved</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 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>
|
<button class="panel-btn" disabled={busy || dirty} title={dirty ? 'Save before applying' : 'Write values to signals'} onClick={apply}>Apply</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -564,6 +926,13 @@ function InstancesManager() {
|
|||||||
{diff && <DiffModal title={diff.title} onClose={() => setDiff(null)}>
|
{diff && <DiffModal title={diff.title} onClose={() => setDiff(null)}>
|
||||||
<InstanceDiffTable entries={diff.entries} />
|
<InstanceDiffTable entries={diff.entries} />
|
||||||
</DiffModal>}
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -571,15 +940,18 @@ function InstancesManager() {
|
|||||||
function ValueRow({ param, value, onChange }: { param: Parameter; value: any; onChange: (v: any) => void }) {
|
function ValueRow({ param, value, onChange }: { param: Parameter; value: any; onChange: (v: any) => void }) {
|
||||||
const label = param.label || param.key;
|
const label = param.label || param.key;
|
||||||
const isNum = param.type === 'float64' || param.type === 'int64';
|
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 (
|
return (
|
||||||
<div class="cfg-value-row">
|
<div class={`cfg-value-row${err ? ' cfg-value-invalid' : ''}`}>
|
||||||
<div class="cfg-value-label">
|
<div class="cfg-value-label">
|
||||||
{param.mandatory && <span class="cfg-req" title="Mandatory">*</span>}
|
{param.mandatory && <span class="cfg-req" title="Mandatory">*</span>}
|
||||||
<span title={`${param.ds}:${param.signal}`}>{label}</span>
|
<span title={`${param.ds}:${param.signal}`}>{label}</span>
|
||||||
{param.unit && <span class="hint cfg-unit">{param.unit}</span>}
|
{param.unit && <span class="hint cfg-unit">{param.unit}</span>}
|
||||||
|
{err && <span class="cfg-value-err" title={err}>⚠</span>}
|
||||||
</div>
|
</div>
|
||||||
<div class="cfg-value-input">
|
<div class={`cfg-value-input${isArray ? ' cfg-value-input-array' : ''}`}>
|
||||||
{param.type === 'bool' ? (
|
{param.type === 'bool' ? (
|
||||||
<select class="prop-select" value={value === undefined ? '' : String(value)}
|
<select class="prop-select" value={value === undefined ? '' : String(value)}
|
||||||
onChange={(e) => { const v = (e.target as HTMLSelectElement).value; onChange(v === '' ? undefined : v === 'true'); }}>
|
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>
|
<option value="">{defText ? `default (${defText})` : '(unset)'}</option>
|
||||||
{(param.enumValues ?? []).map(ev => <option key={ev} value={ev}>{ev}</option>)}
|
{(param.enumValues ?? []).map(ev => <option key={ev} value={ev}>{ev}</option>)}
|
||||||
</select>
|
</select>
|
||||||
|
) : isArray ? (
|
||||||
|
<ArrayValueEditor param={param} value={value} onChange={onChange} />
|
||||||
) : (
|
) : (
|
||||||
<input class="prop-input" type={isNum ? 'number' : 'text'}
|
<input class="prop-input" type={isNum ? 'number' : 'text'}
|
||||||
value={value === undefined ? '' : value}
|
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 }: {
|
function NewInstanceDialog({ sets, busy, onCancel, onCreate }: {
|
||||||
sets: Meta[]; busy: boolean; onCancel: () => void; onCreate: (name: string, setId: string) => void;
|
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 {
|
function paramSummary(p?: Parameter): string {
|
||||||
if (!p) return '—';
|
if (!p) return '—';
|
||||||
const parts = [`${p.ds}:${p.signal}`, p.type];
|
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.mandatory) parts.push('mandatory');
|
||||||
if (p.min !== undefined) parts.push(`min=${p.min}`);
|
if (p.min !== undefined) parts.push(`min=${p.min}`);
|
||||||
if (p.max !== undefined) parts.push(`max=${p.max}`);
|
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 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 {
|
function coerceVal(v: string, type: ParamType): any {
|
||||||
if (type === 'float64' || type === 'int64') { const n = Number(v); return isNaN(n) ? v : n; }
|
if (type === 'float64' || type === 'int64') { const n = Number(v); return isNaN(n) ? v : n; }
|
||||||
|
if (type === 'float64[]') return parseArray(v);
|
||||||
return 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); }
|
||||||
@@ -1084,10 +1084,14 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.toolbar-btn {
|
.toolbar-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
background: #2d3748;
|
background: #2d3748;
|
||||||
border: none;
|
border: none;
|
||||||
color: #e2e8f0;
|
color: #e2e8f0;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.2;
|
||||||
padding: 0.25rem 0.75rem;
|
padding: 0.25rem 0.75rem;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -3495,6 +3499,13 @@ kbd {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cl-modal-full {
|
||||||
|
width: 98vw;
|
||||||
|
height: 96vh;
|
||||||
|
max-width: none;
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
|
|
||||||
.cl-confirm-overlay {
|
.cl-confirm-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -3775,6 +3786,16 @@ kbd {
|
|||||||
|
|
||||||
.cl-name-input { width: 240px; }
|
.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 {
|
.cl-enable {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -3984,6 +4005,131 @@ kbd {
|
|||||||
margin-left: 0.25rem;
|
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 ───────────────────────────────────────────────────── */
|
/* ── Instance values ───────────────────────────────────────────────────── */
|
||||||
.cfg-values {
|
.cfg-values {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -4140,3 +4286,71 @@ kbd {
|
|||||||
.cfg-diff-changed { background: rgba(234, 179, 8, 0.08); }
|
.cfg-diff-changed { background: rgba(234, 179, 8, 0.08); }
|
||||||
.cfg-diff-changed .cfg-diff-status { color: #facc15; }
|
.cfg-diff-changed .cfg-diff-status { color: #facc15; }
|
||||||
.cfg-diff-unchanged .cfg-diff-status { color: #64748b; }
|
.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,3 +1,4 @@
|
|||||||
Starting iocInit
|
Starting iocInit
|
||||||
iocRun: All initialization complete
|
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 ERROR: Network is unreachable
|
||||||
|
CAS: CA beacon send to 192.168.1.255:5065 ok
|
||||||
Reference in new issue
Block a user