|
|
@@ -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); }
|
|
|
|