Implemented confi snapshots
This commit is contained in:
@@ -16,6 +16,7 @@ import Button from './widgets/Button';
|
||||
import PlotWidget from './widgets/PlotWidget';
|
||||
import ImageWidget from './widgets/ImageWidget';
|
||||
import LinkWidget from './widgets/LinkWidget';
|
||||
import ConfigSelect from './widgets/ConfigSelect';
|
||||
import ContextMenu from './ContextMenu';
|
||||
import InfoPanel from './InfoPanel';
|
||||
import SplitLayout from './SplitLayout';
|
||||
@@ -34,6 +35,7 @@ const COMPONENTS: Record<string, any> = {
|
||||
plot: PlotWidget,
|
||||
image: ImageWidget,
|
||||
link: LinkWidget,
|
||||
configselect: ConfigSelect,
|
||||
};
|
||||
|
||||
interface CtxState {
|
||||
|
||||
+487
-75
@@ -1,6 +1,8 @@
|
||||
import { h, Fragment } from 'preact';
|
||||
import { useState, useEffect, useCallback, useRef } from 'preact/hooks';
|
||||
import SignalPicker, { SignalOption } from './SignalPicker';
|
||||
import { VersionTree, DiffViewer } from './VersionHistory';
|
||||
import CueEditor from './CueEditor';
|
||||
|
||||
// ── Types (mirror internal/confmgr) ──────────────────────────────────────────
|
||||
|
||||
@@ -44,8 +46,24 @@ interface ConfigInstance {
|
||||
values: Record<string, any>;
|
||||
}
|
||||
|
||||
interface Meta { id: string; name: string; version: number; }
|
||||
interface VersionMeta { version: number; name: string; tag?: string; current: boolean; savedAt: string; }
|
||||
interface Meta { id: string; name: string; version: number; setId?: string; }
|
||||
|
||||
// ConfigRule mirrors internal/confmgr.ConfigRule: CUE validation/transformation
|
||||
// logic bound to a set, run when instances of that set are saved.
|
||||
interface ConfigRule {
|
||||
id: string;
|
||||
name: string;
|
||||
setId: string;
|
||||
description?: string;
|
||||
version: number;
|
||||
tag?: string;
|
||||
owner?: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
// RuleViolation / RuleResult mirror the backend evaluation payload.
|
||||
interface RuleViolation { rule?: string; path?: string; message: string; }
|
||||
interface RuleResult { ok: boolean; violations?: RuleViolation[]; transformed?: Record<string, any>; compileError?: string; }
|
||||
|
||||
interface ApplyEntry { key: string; ds: string; signal: string; value?: any; ok: boolean; skipped?: boolean; error?: string; }
|
||||
interface ApplyResult { instanceId: string; setId: string; entries: ApplyEntry[]; applied: number; failed: number; skipped: number; }
|
||||
@@ -138,13 +156,6 @@ function splitRef(ref: string): { ds: string; signal: string } {
|
||||
return { ds: ref.slice(0, i), signal: ref.slice(i + 1) };
|
||||
}
|
||||
|
||||
function fmtTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return iso;
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
// ── Top-level modal ───────────────────────────────────────────────────────────
|
||||
|
||||
interface Props { onClose: () => void; }
|
||||
@@ -154,7 +165,7 @@ interface Props { onClose: () => void; }
|
||||
type CloseGuard = (proceed: () => void) => void;
|
||||
|
||||
export default function ConfigManager({ onClose }: Props) {
|
||||
const [tab, setTab] = useState<'sets' | 'instances'>('sets');
|
||||
const [tab, setTab] = useState<'sets' | 'instances' | 'rules'>('sets');
|
||||
const guardRef = useRef<CloseGuard | null>(null);
|
||||
const registerGuard = useCallback((fn: CloseGuard | null) => { guardRef.current = fn; }, []);
|
||||
|
||||
@@ -162,7 +173,7 @@ export default function ConfigManager({ onClose }: Props) {
|
||||
// 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)); };
|
||||
const switchTab = (t: 'sets' | 'instances' | 'rules') => { if (t !== tab) guarded(() => setTab(t)); };
|
||||
|
||||
return (
|
||||
<div class="cl-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) requestClose(); }}>
|
||||
@@ -178,9 +189,12 @@ export default function ConfigManager({ onClose }: Props) {
|
||||
<div class="cfg-tabs">
|
||||
<button class={`cfg-tab${tab === 'sets' ? ' cfg-tab-active' : ''}`} onClick={() => switchTab('sets')}>Sets</button>
|
||||
<button class={`cfg-tab${tab === 'instances' ? ' cfg-tab-active' : ''}`} onClick={() => switchTab('instances')}>Instances</button>
|
||||
<button class={`cfg-tab${tab === 'rules' ? ' cfg-tab-active' : ''}`} onClick={() => switchTab('rules')}>Rules</button>
|
||||
</div>
|
||||
|
||||
{tab === 'sets' ? <SetsManager registerGuard={registerGuard} /> : <InstancesManager registerGuard={registerGuard} />}
|
||||
{tab === 'sets' && <SetsManager registerGuard={registerGuard} />}
|
||||
{tab === 'instances' && <InstancesManager registerGuard={registerGuard} />}
|
||||
{tab === 'rules' && <RulesManager registerGuard={registerGuard} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -277,6 +291,8 @@ function SetsManager({ registerGuard }: { registerGuard: (fn: CloseGuard | null)
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [diff, setDiff] = useState<{ title: string; entries: SetDiffEntry[] } | null>(null);
|
||||
const [viewingVersion, setViewingVersion] = useState<number | null>(null);
|
||||
const [verReload, setVerReload] = useState(0);
|
||||
const { allOptions, openAll, signalMeta } = useSignals();
|
||||
const { pending, setPending } = useCloseGuard(registerGuard, dirty);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
@@ -351,6 +367,8 @@ function SetsManager({ registerGuard }: { registerGuard: (fn: CloseGuard | null)
|
||||
const saved = await apiJSON<ConfigSet>(`/api/v1/config/sets/${encodeURIComponent(working.id)}`, jsonPut(working));
|
||||
hist.load(saved);
|
||||
setDirty(false);
|
||||
setViewingVersion(null);
|
||||
setVerReload(k => k + 1);
|
||||
await reload();
|
||||
return true;
|
||||
} catch (err) { setError(`Save failed: ${msg(err)}`); return false; }
|
||||
@@ -393,6 +411,31 @@ function SetsManager({ registerGuard }: { registerGuard: (fn: CloseGuard | null)
|
||||
} catch (err) { setError(`Diff failed: ${msg(err)}`); }
|
||||
}
|
||||
|
||||
// Load a past revision into the editor (non-destructive; saving promotes it to
|
||||
// a new revision). Viewing alone is not a change, so the editor stays clean.
|
||||
async function viewVersion(version: number) {
|
||||
if (!working) return;
|
||||
if (dirty && !confirm('Discard unsaved changes to view this version?')) return;
|
||||
setError(null);
|
||||
try {
|
||||
const s = await apiJSON<ConfigSet>(`/api/v1/config/sets/${encodeURIComponent(working.id)}/versions/${version}`);
|
||||
hist.load(s);
|
||||
setViewingVersion(version);
|
||||
setDirty(false);
|
||||
} catch (err) { setError(`Load version failed: ${msg(err)}`); }
|
||||
}
|
||||
async function reloadCurrent() {
|
||||
if (!working) return;
|
||||
try {
|
||||
const s = await apiJSON<ConfigSet>(`/api/v1/config/sets/${encodeURIComponent(working.id)}`);
|
||||
hist.load(s);
|
||||
setViewingVersion(null);
|
||||
setDirty(false);
|
||||
setVerReload(k => k + 1);
|
||||
await reload();
|
||||
} catch (err) { setError(msg(err)); }
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="cl-body">
|
||||
<div class="cl-list">
|
||||
@@ -439,9 +482,11 @@ function SetsManager({ registerGuard }: { registerGuard: (fn: CloseGuard | null)
|
||||
<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}
|
||||
viewing={viewingVersion} dirty={dirty} reloadKey={verReload}
|
||||
onView={viewVersion}
|
||||
onForked={(id) => { reload(); select(id); }}
|
||||
onPromoted={() => select(working.id)}
|
||||
onPromoted={reloadCurrent}
|
||||
onCompare={showDiff} onError={setError} />
|
||||
</div>
|
||||
)}
|
||||
@@ -760,6 +805,9 @@ function InstancesManager({ registerGuard }: { registerGuard: (fn: CloseGuard |
|
||||
const [applyResult, setApplyResult] = useState<ApplyResult | null>(null);
|
||||
const [diff, setDiff] = useState<{ title: string; entries: InstanceDiffEntry[] } | null>(null);
|
||||
const [showNew, setShowNew] = useState(false);
|
||||
const [showSnap, setShowSnap] = useState(false);
|
||||
const [viewingVersion, setViewingVersion] = useState<number | null>(null);
|
||||
const [verReload, setVerReload] = useState(0);
|
||||
const { pending, setPending } = useCloseGuard(registerGuard, dirty);
|
||||
|
||||
function undo() { hist.undo(); setDirty(true); }
|
||||
@@ -817,6 +865,8 @@ function InstancesManager({ registerGuard }: { registerGuard: (fn: CloseGuard |
|
||||
const saved = await apiJSON<ConfigInstance>(`/api/v1/config/instances/${encodeURIComponent(working.id)}`, jsonPut(working));
|
||||
hist.load(saved);
|
||||
setDirty(false);
|
||||
setViewingVersion(null);
|
||||
setVerReload(k => k + 1);
|
||||
await reload();
|
||||
return true;
|
||||
} catch (err) { setError(`Save failed: ${msg(err)}`); return false; }
|
||||
@@ -864,6 +914,65 @@ function InstancesManager({ registerGuard }: { registerGuard: (fn: CloseGuard |
|
||||
} catch (err) { setError(`Diff failed: ${msg(err)}`); }
|
||||
}
|
||||
|
||||
// Compare the stored instance values against the current live signal values.
|
||||
async function liveDiff() {
|
||||
if (!working) return;
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
const entries = (await apiJSON<InstanceDiffEntry[]>(`/api/v1/config/instances/${encodeURIComponent(working.id)}/livediff`)) ?? [];
|
||||
setDiff({ title: `${working.name}: stored → current`, entries });
|
||||
} catch (err) { setError(`Live diff failed: ${msg(err)}`); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
// Snapshot a set's current live signal values into a new instance.
|
||||
async function snapshot(setId: string, name: string) {
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
const body = name ? { name } : {};
|
||||
const res = await apiJSON<{ instance: ConfigInstance; snapshot: { captured: number; failed: number } }>(
|
||||
`/api/v1/config/sets/${encodeURIComponent(setId)}/snapshot`, jsonPost(body));
|
||||
await reload();
|
||||
if (res) {
|
||||
await select(res.instance.id);
|
||||
if (res.snapshot.failed > 0) {
|
||||
setError(`Snapshot captured ${res.snapshot.captured}, ${res.snapshot.failed} signal(s) could not be read.`);
|
||||
}
|
||||
}
|
||||
setShowSnap(false);
|
||||
} catch (err) { setError(`Snapshot failed: ${msg(err)}`); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
// Load a past revision into the editor (non-destructive; saving promotes it to
|
||||
// a new revision). Viewing alone is not a change, so the editor stays clean.
|
||||
async function viewVersion(version: number) {
|
||||
if (!working) return;
|
||||
if (dirty && !confirm('Discard unsaved changes to view this version?')) return;
|
||||
setError(null); setApplyResult(null);
|
||||
try {
|
||||
const inst = await apiJSON<ConfigInstance>(`/api/v1/config/instances/${encodeURIComponent(working.id)}/versions/${version}`);
|
||||
const set = await loadSet(inst!.setId, inst!.setVersion);
|
||||
hist.load(inst);
|
||||
setBoundSet(set);
|
||||
setViewingVersion(version);
|
||||
setDirty(false);
|
||||
} catch (err) { setError(`Load version failed: ${msg(err)}`); }
|
||||
}
|
||||
async function reloadCurrent() {
|
||||
if (!working) return;
|
||||
try {
|
||||
const inst = await apiJSON<ConfigInstance>(`/api/v1/config/instances/${encodeURIComponent(working.id)}`);
|
||||
const set = await loadSet(inst!.setId, inst!.setVersion);
|
||||
hist.load(inst);
|
||||
setBoundSet(set);
|
||||
setViewingVersion(null);
|
||||
setDirty(false);
|
||||
setVerReload(k => k + 1);
|
||||
await reload();
|
||||
} catch (err) { setError(msg(err)); }
|
||||
}
|
||||
|
||||
const setName = (id: string) => sets.find(s => s.id === id)?.name ?? id;
|
||||
|
||||
return (
|
||||
@@ -871,6 +980,7 @@ function InstancesManager({ registerGuard }: { registerGuard: (fn: CloseGuard |
|
||||
<div class="cl-list">
|
||||
<div class="cl-list-head">
|
||||
<span>Instances</span>
|
||||
<button class="panel-btn" disabled={busy || sets.length === 0} title="Snapshot a set's current live signal values into a new instance" onClick={() => setShowSnap(true)}>⎙ Snapshot</button>
|
||||
<button class="panel-btn" disabled={busy || sets.length === 0} onClick={() => setShowNew(true)}>+ New</button>
|
||||
</div>
|
||||
{sets.length === 0 && <div class="hint cl-list-empty">Create a config set first.</div>}
|
||||
@@ -898,6 +1008,7 @@ function InstancesManager({ registerGuard }: { registerGuard: (fn: CloseGuard |
|
||||
<button class="panel-btn" disabled={!hist.canRedo} title="Redo (Ctrl+Shift+Z)" onClick={redo}>↪</button>
|
||||
<button class="panel-btn panel-btn-primary" disabled={busy || !dirty} onClick={save}>Save</button>
|
||||
<button class="panel-btn" disabled={busy || dirty} title={dirty ? 'Save before applying' : 'Write values to signals'} onClick={apply}>Apply</button>
|
||||
<button class="panel-btn" disabled={busy} title="Compare stored values against the current live signal values" onClick={liveDiff}>Diff vs current</button>
|
||||
</div>
|
||||
<div class="hint">Based on set <b>{setName(working.setId)}</b>{working.setVersion ? ` (v${working.setVersion})` : ''}.</div>
|
||||
|
||||
@@ -913,9 +1024,11 @@ function InstancesManager({ registerGuard }: { registerGuard: (fn: CloseGuard |
|
||||
|
||||
{applyResult && <ApplyResultView result={applyResult} />}
|
||||
|
||||
<VersionPanel kind="instances" id={working.id} onReload={reload}
|
||||
<VersionPanel kind="instances" id={working.id}
|
||||
viewing={viewingVersion} dirty={dirty} reloadKey={verReload}
|
||||
onView={viewVersion}
|
||||
onForked={(id) => { reload(); select(id); }}
|
||||
onPromoted={() => select(working.id)}
|
||||
onPromoted={reloadCurrent}
|
||||
onCompare={showDiff} onError={setError} />
|
||||
</div>
|
||||
)}
|
||||
@@ -923,6 +1036,8 @@ function InstancesManager({ registerGuard }: { registerGuard: (fn: CloseGuard |
|
||||
|
||||
{showNew && <NewInstanceDialog sets={sets} busy={busy} onCancel={() => setShowNew(false)} onCreate={createInstance} />}
|
||||
|
||||
{showSnap && <SnapshotDialog sets={sets} busy={busy} onCancel={() => setShowSnap(false)} onSnapshot={snapshot} />}
|
||||
|
||||
{diff && <DiffModal title={diff.title} onClose={() => setDiff(null)}>
|
||||
<InstanceDiffTable entries={diff.entries} />
|
||||
</DiffModal>}
|
||||
@@ -1144,6 +1259,38 @@ function NewInstanceDialog({ sets, busy, onCancel, onCreate }: {
|
||||
);
|
||||
}
|
||||
|
||||
function SnapshotDialog({ sets, busy, onCancel, onSnapshot }: {
|
||||
sets: Meta[]; busy: boolean; onCancel: () => void; onSnapshot: (setId: string, name: string) => void;
|
||||
}) {
|
||||
const [name, setName] = useState('');
|
||||
const [setId, setSetId] = useState(sets[0]?.id ?? '');
|
||||
return (
|
||||
<div class="cl-confirm-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) onCancel(); }}>
|
||||
<div class="cl-confirm">
|
||||
<div class="cl-confirm-title">Snapshot config set</div>
|
||||
<div class="cfg-field">
|
||||
<label>Config set</label>
|
||||
<select class="prop-select" value={setId} onChange={(e) => setSetId((e.target as HTMLSelectElement).value)}>
|
||||
{sets.map(s => <option key={s.id} value={s.id}>{s.name} (v{s.version})</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div class="cfg-field">
|
||||
<label>New instance name <span class="hint">(optional)</span></label>
|
||||
<input class="prop-input" value={name} autoFocus placeholder="(auto: set name + timestamp)"
|
||||
onInput={(e) => setName((e.target as HTMLInputElement).value)} />
|
||||
</div>
|
||||
<p class="hint">Reads the current live value of every target signal of the chosen set and stores
|
||||
them as a new config instance.</p>
|
||||
<div class="cl-confirm-actions">
|
||||
<button class="panel-btn" onClick={onCancel}>Cancel</button>
|
||||
<button class="panel-btn panel-btn-primary" disabled={busy || !setId}
|
||||
onClick={() => onSnapshot(setId, name.trim())}>Snapshot</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ApplyResultView({ result }: { result: ApplyResult }) {
|
||||
return (
|
||||
<div class="cfg-apply-result">
|
||||
@@ -1173,48 +1320,22 @@ function ApplyResultView({ result }: { result: ApplyResult }) {
|
||||
|
||||
// ── Versions panel (shared) ───────────────────────────────────────────────────
|
||||
|
||||
function VersionPanel({ kind, id, onForked, onPromoted, onCompare, onError }: {
|
||||
kind: 'sets' | 'instances';
|
||||
function VersionPanel({ kind, id, viewing, dirty, reloadKey, onView, onForked, onPromoted, onCompare, onError }: {
|
||||
kind: 'sets' | 'instances' | 'rules';
|
||||
id: string;
|
||||
onReload: () => void;
|
||||
viewing: number | null;
|
||||
dirty: boolean;
|
||||
reloadKey: number;
|
||||
onView: (version: number) => void;
|
||||
onForked: (newId: string) => void;
|
||||
onPromoted: () => void;
|
||||
onPromoted: (version: number) => void;
|
||||
onCompare: (av: number, bv: number) => void;
|
||||
onError: (m: string) => void;
|
||||
}) {
|
||||
const [versions, setVersions] = useState<VersionMeta[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [a, setA] = useState<number | null>(null);
|
||||
const [b, setB] = useState<number | null>(null);
|
||||
|
||||
const base = `/api/v1/config/${kind}`;
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const v = (await apiJSON<VersionMeta[]>(`${base}/${encodeURIComponent(id)}/versions`)) ?? [];
|
||||
setVersions(v);
|
||||
if (v.length >= 2) { setA(v[1].version); setB(v[0].version); }
|
||||
else if (v.length === 1) { setA(v[0].version); setB(v[0].version); }
|
||||
} catch (err) { onError(`Versions failed: ${msg(err)}`); }
|
||||
}, [base, id, onError]);
|
||||
|
||||
useEffect(() => { if (open) load(); }, [open, load]);
|
||||
// Reset when switching object.
|
||||
useEffect(() => { setOpen(false); setVersions([]); }, [id]);
|
||||
|
||||
async function fork(version: number) {
|
||||
try {
|
||||
const obj = await apiJSON<{ id: string }>(`${base}/${encodeURIComponent(id)}/versions/${version}/fork`, { method: 'POST' });
|
||||
onForked(obj!.id);
|
||||
} catch (err) { onError(`Fork failed: ${msg(err)}`); }
|
||||
}
|
||||
async function promote(version: number) {
|
||||
if (!confirm(`Promote v${version} to a new current version?`)) return;
|
||||
try {
|
||||
await apiJSON(`${base}/${encodeURIComponent(id)}/versions/${version}/promote`, { method: 'POST' });
|
||||
onPromoted();
|
||||
} catch (err) { onError(`Promote failed: ${msg(err)}`); }
|
||||
}
|
||||
const base = `/api/v1/config/${kind}/${encodeURIComponent(id)}`;
|
||||
// Collapse when switching object.
|
||||
useEffect(() => { setOpen(false); }, [id]);
|
||||
|
||||
return (
|
||||
<div class="cfg-versions">
|
||||
@@ -1223,29 +1344,16 @@ function VersionPanel({ kind, id, onForked, onPromoted, onCompare, onError }: {
|
||||
</button>
|
||||
{open && (
|
||||
<div class="cfg-versions-body">
|
||||
{versions.length === 0 && <div class="hint">No versions.</div>}
|
||||
{versions.map(v => (
|
||||
<div key={v.version} class={`cfg-version-row${v.current ? ' cfg-version-current' : ''}`}>
|
||||
<span class="cfg-version-num">v{v.version}{v.current && <span class="cfg-badge">current</span>}</span>
|
||||
<span class="cfg-version-tag hint" title={v.tag}>{v.tag || ''}</span>
|
||||
<span class="cfg-version-time hint">{fmtTime(v.savedAt)}</span>
|
||||
<button class="cl-mini-btn" title="Fork into a new object" onClick={() => fork(v.version)}>fork</button>
|
||||
{!v.current && <button class="cl-mini-btn" title="Promote to current" onClick={() => promote(v.version)}>promote</button>}
|
||||
</div>
|
||||
))}
|
||||
{versions.length >= 2 && (
|
||||
<div class="cfg-compare">
|
||||
<span class="hint">Compare</span>
|
||||
<select class="prop-select" value={String(a ?? '')} onChange={(e) => setA(Number((e.target as HTMLSelectElement).value))}>
|
||||
{versions.map(v => <option key={v.version} value={v.version}>v{v.version}</option>)}
|
||||
</select>
|
||||
<span>→</span>
|
||||
<select class="prop-select" value={String(b ?? '')} onChange={(e) => setB(Number((e.target as HTMLSelectElement).value))}>
|
||||
{versions.map(v => <option key={v.version} value={v.version}>v{v.version}</option>)}
|
||||
</select>
|
||||
<button class="panel-btn" disabled={a === null || b === null} onClick={() => onCompare(a!, b!)}>Diff</button>
|
||||
</div>
|
||||
)}
|
||||
<VersionTree
|
||||
base={base}
|
||||
viewing={viewing}
|
||||
dirty={dirty}
|
||||
reloadKey={reloadKey}
|
||||
onView={onView}
|
||||
onForked={onForked}
|
||||
onPromoted={onPromoted}
|
||||
onCompare={onCompare}
|
||||
onError={onError} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1314,6 +1422,310 @@ function InstanceDiffTable({ entries }: { entries: InstanceDiffEntry[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Rules manager (CUE validation/transformation) ──────────────────────────────
|
||||
|
||||
// sampleValues builds a representative value map from a set's parameter
|
||||
// defaults, used to live-evaluate a rule's constraints while editing.
|
||||
function sampleValues(set: ConfigSet | null): Record<string, any> {
|
||||
const out: Record<string, any> = {};
|
||||
if (!set) return out;
|
||||
for (const p of set.parameters) {
|
||||
if (p.default !== undefined && p.default !== null) out[p.key] = p.default;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function RulesManager({ registerGuard }: { registerGuard: (fn: CloseGuard | null) => void }) {
|
||||
const [rules, setRules] = useState<Meta[]>([]);
|
||||
const [sets, setSets] = useState<Meta[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const hist = useUndo<ConfigRule>();
|
||||
const working = hist.present;
|
||||
const [boundSet, setBoundSet] = useState<ConfigSet | null>(null);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [check, setCheck] = useState<RuleResult | null>(null);
|
||||
const [showNew, setShowNew] = useState(false);
|
||||
const [viewingVersion, setViewingVersion] = useState<number | null>(null);
|
||||
const [verReload, setVerReload] = useState(0);
|
||||
const [diff, setDiff] = useState<{ av: number; bv: number } | null>(null);
|
||||
const { pending, setPending } = useCloseGuard(registerGuard, dirty);
|
||||
|
||||
function undo() { hist.undo(); setDirty(true); }
|
||||
function redo() { hist.redo(); setDirty(true); }
|
||||
useUndoKeys(undo, redo, hist.canUndo, hist.canRedo);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
try {
|
||||
setRules((await apiJSON<Meta[]>('/api/v1/config/rules')) ?? []);
|
||||
setSets((await apiJSON<Meta[]>('/api/v1/config/sets')) ?? []);
|
||||
} catch (err) { setError(`Failed to load: ${msg(err)}`); }
|
||||
}, []);
|
||||
useEffect(() => { reload(); }, [reload]);
|
||||
|
||||
async function loadSet(setId: string): Promise<ConfigSet | null> {
|
||||
if (!setId) return null;
|
||||
return apiJSON<ConfigSet>(`/api/v1/config/sets/${encodeURIComponent(setId)}`);
|
||||
}
|
||||
|
||||
async function select(id: string) {
|
||||
if (dirty && !confirm('Discard unsaved changes?')) return;
|
||||
setError(null); setCheck(null);
|
||||
try {
|
||||
const rule = await apiJSON<ConfigRule>(`/api/v1/config/rules/${encodeURIComponent(id)}`);
|
||||
const set = await loadSet(rule!.setId);
|
||||
setSelectedId(id);
|
||||
hist.load(rule);
|
||||
setBoundSet(set);
|
||||
setDirty(false);
|
||||
} catch (err) { setError(msg(err)); }
|
||||
}
|
||||
|
||||
async function createRule(name: string, setId: string) {
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
const body = { name, setId, source: '// CUE rule: constrain or derive parameter values.\n' };
|
||||
const created = await apiJSON<ConfigRule>('/api/v1/config/rules', jsonPost(body));
|
||||
const set = await loadSet(created!.setId);
|
||||
await reload();
|
||||
setSelectedId(created!.id);
|
||||
hist.load(created);
|
||||
setBoundSet(set);
|
||||
setDirty(false);
|
||||
setShowNew(false);
|
||||
} catch (err) { setError(`Create failed: ${msg(err)}`); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
async function save(): Promise<boolean> {
|
||||
if (!working) return false;
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
const saved = await apiJSON<ConfigRule>(`/api/v1/config/rules/${encodeURIComponent(working.id)}`, jsonPut(working));
|
||||
hist.load(saved);
|
||||
setDirty(false);
|
||||
setViewingVersion(null);
|
||||
setVerReload(k => k + 1);
|
||||
await reload();
|
||||
return true;
|
||||
} catch (err) { setError(`Save failed: ${msg(err)}`); return false; }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
async function remove(id: string) {
|
||||
if (!confirm('Delete this rule? A server-side copy is kept in trash.')) return;
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
await apiJSON(`/api/v1/config/rules/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||
if (selectedId === id) { setSelectedId(null); hist.load(null); setBoundSet(null); setDirty(false); }
|
||||
await reload();
|
||||
} catch (err) { setError(`Delete failed: ${msg(err)}`); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
function patch(p: Partial<ConfigRule>) {
|
||||
if (!working) return;
|
||||
hist.commit({ ...working, ...p });
|
||||
setDirty(true);
|
||||
}
|
||||
|
||||
// Live-validate the working source against the bound set's sample values. The
|
||||
// /check endpoint compiles the (unsaved) source and reports compile errors and
|
||||
// constraint violations, debounced so each keystroke does not hit the server.
|
||||
useEffect(() => {
|
||||
if (!working) { setCheck(null); return; }
|
||||
const handle = setTimeout(async () => {
|
||||
try {
|
||||
const res = await apiJSON<RuleResult>('/api/v1/config/rules/check',
|
||||
jsonPost({ source: working.source, values: sampleValues(boundSet) }));
|
||||
setCheck(res);
|
||||
} catch { /* transient; ignore */ }
|
||||
}, 400);
|
||||
return () => clearTimeout(handle);
|
||||
}, [working?.source, boundSet]);
|
||||
|
||||
async function viewVersion(version: number) {
|
||||
if (!working) return;
|
||||
if (dirty && !confirm('Discard unsaved changes to view this version?')) return;
|
||||
setError(null);
|
||||
try {
|
||||
const rule = await apiJSON<ConfigRule>(`/api/v1/config/rules/${encodeURIComponent(working.id)}/versions/${version}`);
|
||||
hist.load(rule);
|
||||
setViewingVersion(version);
|
||||
setDirty(false);
|
||||
} catch (err) { setError(`Load version failed: ${msg(err)}`); }
|
||||
}
|
||||
async function reloadCurrent() {
|
||||
if (!working) return;
|
||||
try {
|
||||
const rule = await apiJSON<ConfigRule>(`/api/v1/config/rules/${encodeURIComponent(working.id)}`);
|
||||
hist.load(rule);
|
||||
setViewingVersion(null);
|
||||
setDirty(false);
|
||||
setVerReload(k => k + 1);
|
||||
await reload();
|
||||
} catch (err) { setError(msg(err)); }
|
||||
}
|
||||
|
||||
const setName = (id: string) => sets.find(s => s.id === id)?.name ?? id;
|
||||
// Parameter keys + target signals offered as CUE completions.
|
||||
const completions = boundSet
|
||||
? boundSet.parameters.flatMap(p => [p.key, `${p.ds}:${p.signal}`]).filter(Boolean)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div class="cl-body">
|
||||
<div class="cl-list">
|
||||
<div class="cl-list-head">
|
||||
<span>Rules</span>
|
||||
<button class="panel-btn" disabled={busy || sets.length === 0} onClick={() => setShowNew(true)}>+ New</button>
|
||||
</div>
|
||||
{sets.length === 0 && <div class="hint cl-list-empty">Create a config set first.</div>}
|
||||
{sets.length > 0 && rules.length === 0 && <div class="hint cl-list-empty">No rules yet.</div>}
|
||||
{rules.map(s => (
|
||||
<div key={s.id} class={`cl-list-item${selectedId === s.id ? ' cl-list-item-active' : ''}`} onClick={() => select(s.id)}>
|
||||
<span class="cl-list-name" title={s.name}>{s.name || '(unnamed)'}</span>
|
||||
{s.setId && <span class="hint cfg-rule-set" title="Bound set">{setName(s.setId)}</span>}
|
||||
<span class="cfg-badge">v{s.version}</span>
|
||||
<button class="cl-mini-btn" title="Delete" onClick={(e) => { e.stopPropagation(); remove(s.id); }}>✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div class="cl-editor-wrap">
|
||||
{error && <div class="cl-error">{error}</div>}
|
||||
{!working && <div class="cl-empty hint">Select a rule on the left, or create a new one. Rules run when instances of the bound set are saved.</div>}
|
||||
{working && (
|
||||
<div class="cfg-editor">
|
||||
<div class="cfg-edit-bar">
|
||||
<input class="prop-input cl-name-input" value={working.name} placeholder="Rule name"
|
||||
onInput={(e) => patch({ name: (e.target as HTMLInputElement).value })} />
|
||||
<span class="cfg-badge">v{working.version}</span>
|
||||
{dirty && <span class="cl-dirty">unsaved</span>}
|
||||
<button class="panel-btn" disabled={!hist.canUndo} title="Undo (Ctrl+Z)" onClick={undo}>↩</button>
|
||||
<button class="panel-btn" disabled={!hist.canRedo} title="Redo (Ctrl+Shift+Z)" onClick={redo}>↪</button>
|
||||
<button class="panel-btn panel-btn-primary" disabled={busy || !dirty} onClick={save}>Save</button>
|
||||
</div>
|
||||
|
||||
<div class="cfg-param-row">
|
||||
<div class="cfg-field cfg-field-target">
|
||||
<label>Bound set</label>
|
||||
<select class="prop-select" value={working.setId}
|
||||
onChange={async (e) => { const id = (e.target as HTMLSelectElement).value; patch({ setId: id }); setBoundSet(await loadSet(id)); }}>
|
||||
{sets.map(s => <option key={s.id} value={s.id}>{s.name} (v{s.version})</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div class="cfg-field cfg-field-label">
|
||||
<label>Description</label>
|
||||
<input class="prop-input" value={working.description ?? ''} placeholder="(optional)"
|
||||
onInput={(e) => patch({ description: (e.target as HTMLInputElement).value })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cfg-section-head"><span>CUE source</span></div>
|
||||
<div class="hint cfg-rule-help">
|
||||
Regular fields whose key matches a parameter are unified with the instance value — constraints
|
||||
(e.g. <code>voltage: >=0 & <=24</code>) validate it, and concrete derivations
|
||||
(e.g. <code>power: voltage * current</code>) transform it. Use <code>_helpers</code> and
|
||||
<code>#Defs</code> for intermediate values. Ctrl+Space for completions.
|
||||
</div>
|
||||
<CueEditor value={working.source} rows={14} completions={completions}
|
||||
onChange={(v) => patch({ source: v })} />
|
||||
|
||||
<RuleCheckView result={check} />
|
||||
|
||||
<VersionPanel kind="rules" id={working.id}
|
||||
viewing={viewingVersion} dirty={dirty} reloadKey={verReload}
|
||||
onView={viewVersion}
|
||||
onForked={(id) => { reload(); select(id); }}
|
||||
onPromoted={reloadCurrent}
|
||||
onCompare={(av, bv) => setDiff({ av, bv })} onError={setError} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showNew && <NewRuleDialog sets={sets} busy={busy} onCancel={() => setShowNew(false)} onCreate={createRule} />}
|
||||
|
||||
{diff && working && (
|
||||
<DiffViewer base={`/api/v1/config/rules/${encodeURIComponent(working.id)}`}
|
||||
av={diff.av} bv={diff.bv} title={`${working.name}: v${diff.av} → v${diff.bv}`}
|
||||
onClose={() => setDiff(null)} />
|
||||
)}
|
||||
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
// RuleCheckView renders the live evaluation outcome: compile errors, constraint
|
||||
// violations, or a pass with any transformed sample values.
|
||||
function RuleCheckView({ result }: { result: RuleResult | null }) {
|
||||
if (!result) return null;
|
||||
if (result.compileError) {
|
||||
return <div class="cfg-rule-check cfg-rule-check-err">
|
||||
<b>Compile error:</b> {result.compileError}
|
||||
</div>;
|
||||
}
|
||||
if (!result.ok) {
|
||||
return (
|
||||
<div class="cfg-rule-check cfg-rule-check-err">
|
||||
<b>{(result.violations ?? []).length} violation(s)</b> against sample (default) values:
|
||||
<ul class="cfg-rule-violations">
|
||||
{(result.violations ?? []).map((v, i) => (
|
||||
<li key={i}>{v.path ? <code>{v.path}</code> : null} {v.message}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const transformed = result.transformed ? Object.entries(result.transformed) : [];
|
||||
return (
|
||||
<div class="cfg-rule-check cfg-rule-check-ok">
|
||||
<b>✓ Valid</b> — sample (default) values pass.
|
||||
{transformed.length > 0 && (
|
||||
<Fragment>
|
||||
{' '}Derived: {transformed.map(([k, v]) => <span key={k} class="cfg-rule-derived"><code>{k}</code>={String(v)}</span>)}
|
||||
</Fragment>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NewRuleDialog({ sets, busy, onCancel, onCreate }: {
|
||||
sets: Meta[]; busy: boolean; onCancel: () => void; onCreate: (name: string, setId: string) => void;
|
||||
}) {
|
||||
const [name, setName] = useState('New rule');
|
||||
const [setId, setSetId] = useState(sets[0]?.id ?? '');
|
||||
return (
|
||||
<div class="cl-confirm-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) onCancel(); }}>
|
||||
<div class="cl-confirm">
|
||||
<div class="cl-confirm-title">New config rule</div>
|
||||
<div class="cfg-field">
|
||||
<label>Name</label>
|
||||
<input class="prop-input" value={name} autoFocus onInput={(e) => setName((e.target as HTMLInputElement).value)} />
|
||||
</div>
|
||||
<div class="cfg-field">
|
||||
<label>Config set</label>
|
||||
<select class="prop-select" value={setId} onChange={(e) => setSetId((e.target as HTMLSelectElement).value)}>
|
||||
{sets.map(s => <option key={s.id} value={s.id}>{s.name} (v{s.version})</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div class="cl-confirm-actions">
|
||||
<button class="panel-btn" onClick={onCancel}>Cancel</button>
|
||||
<button class="panel-btn panel-btn-primary" disabled={busy || !setId || !name.trim()}
|
||||
onClick={() => onCreate(name.trim(), setId)}>Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── small helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
function msg(err: unknown): string { return err instanceof Error ? err.message : String(err); }
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useState, useEffect, useRef } from 'preact/hooks';
|
||||
import SignalPicker, { SignalOption } from './SignalPicker';
|
||||
import LuaEditor from './LuaEditor';
|
||||
import { checkExpr } from './lib/expr';
|
||||
import { VersionTree, DiffViewer } from './VersionHistory';
|
||||
|
||||
// ── Types (mirror internal/controllogic/model.go) ────────────────────────────
|
||||
|
||||
@@ -19,7 +20,12 @@ type CLNodeKind =
|
||||
| 'action.delay'
|
||||
| 'action.log'
|
||||
| 'action.lua'
|
||||
| 'action.dialog';
|
||||
| 'action.dialog'
|
||||
| 'action.config.apply'
|
||||
| 'action.config.read'
|
||||
| 'action.config.write'
|
||||
| 'action.config.create'
|
||||
| 'action.config.snapshot';
|
||||
|
||||
interface CLNode {
|
||||
id: string;
|
||||
@@ -78,6 +84,11 @@ const PALETTE: PaletteEntry[] = [
|
||||
{ kind: 'action.log', label: 'Log', params: { expr: '', label: '' } },
|
||||
{ kind: 'action.lua', label: 'Lua script', params: { script: '-- get("ds:name") reads, set("ds:name", v) writes,\n-- log("msg") logs to the server.\n' } },
|
||||
{ kind: 'action.dialog', label: 'Dialog', params: { kind: 'info', title: '', message: '', users: '', groups: '', target: '' } },
|
||||
{ kind: 'action.config.apply', label: 'Apply config', params: { instance: '' } },
|
||||
{ kind: 'action.config.read', label: 'Read config', params: { instance: '', key: '', target: '' } },
|
||||
{ kind: 'action.config.write', label: 'Write config', params: { instance: '', key: '', expr: '' } },
|
||||
{ kind: 'action.config.create', label: 'Create config', params: { set: '', name: 'auto', from: '' } },
|
||||
{ kind: 'action.config.snapshot', label: 'Snapshot config', params: { set: '', name: '' } },
|
||||
];
|
||||
|
||||
const KIND_LABEL: Record<CLNodeKind, string> = {
|
||||
@@ -94,6 +105,11 @@ const KIND_LABEL: Record<CLNodeKind, string> = {
|
||||
'action.log': 'Log',
|
||||
'action.lua': 'Lua script',
|
||||
'action.dialog': 'Dialog',
|
||||
'action.config.apply': 'Apply config',
|
||||
'action.config.read': 'Read config',
|
||||
'action.config.write': 'Write config',
|
||||
'action.config.create': 'Create config',
|
||||
'action.config.snapshot': 'Snapshot config',
|
||||
};
|
||||
|
||||
const THRESHOLD_OPS = ['>', '<', '>=', '<=', '==', '!='] as const;
|
||||
@@ -148,6 +164,10 @@ export default function ControlLogicEditor({ onClose }: Props) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showCloseConfirm, setShowCloseConfirm] = useState(false);
|
||||
const [showVersions, setShowVersions] = useState(false);
|
||||
const [viewingVersion, setViewingVersion] = useState<number | null>(null);
|
||||
const [diff, setDiff] = useState<{ av: number; bv: number } | null>(null);
|
||||
const [verReload, setVerReload] = useState(0);
|
||||
|
||||
// Closing with unsaved changes prompts instead of silently discarding them.
|
||||
function requestClose() {
|
||||
@@ -259,15 +279,34 @@ export default function ControlLogicEditor({ onClose }: Props) {
|
||||
setDirty(true);
|
||||
}
|
||||
|
||||
// Load a past revision's content into the editor as unsaved edits; saving it
|
||||
// promotes it to a new current version (non-destructive).
|
||||
async function viewVersion(version: number) {
|
||||
if (!graph) return;
|
||||
if (dirty && !confirm('Discard unsaved changes to load this version?')) return;
|
||||
try {
|
||||
const res = await fetch(`/api/v1/controllogic/${encodeURIComponent(graph.id)}/versions/${version}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const g: CLGraph = await res.json();
|
||||
setGraph(g);
|
||||
setViewingVersion(version);
|
||||
setDirty(false);
|
||||
} catch (err) {
|
||||
setError(`Load version failed: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<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">
|
||||
<span class="cl-title">Control logic</span>
|
||||
<span class="hint cl-subtitle">Server-side flows — run continuously, independent of any panel.</span>
|
||||
<div class="cl-header-actions">
|
||||
{graph && dirty && <span class="cl-dirty">unsaved</span>}
|
||||
{graph && <button class="panel-btn" disabled={busy || !dirty} onClick={saveGraph}>Save</button>}
|
||||
{graph && <button class={`panel-btn${showVersions ? ' panel-btn-primary' : ''}`}
|
||||
onClick={() => setShowVersions(v => !v)}>History</button>}
|
||||
<button class="panel-btn" onClick={requestClose}>Close</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -313,13 +352,36 @@ export default function ControlLogicEditor({ onClose }: Props) {
|
||||
</label>
|
||||
<span class="hint">Saving a disabled graph stops it; enabling + saving starts it.</span>
|
||||
</div>
|
||||
<FlowEditor graph={graph}
|
||||
onChange={(g) => { setGraph({ ...graph, nodes: g.nodes, wires: g.wires }); setDirty(true); }} />
|
||||
<div class="cl-editor-main">
|
||||
<FlowEditor graph={graph}
|
||||
onChange={(g) => { setGraph({ ...graph, nodes: g.nodes, wires: g.wires }); setDirty(true); }} />
|
||||
{showVersions && (
|
||||
<div class="ver-pane">
|
||||
<div class="ver-pane-head">Version history</div>
|
||||
<VersionTree
|
||||
base={`/api/v1/controllogic/${encodeURIComponent(graph.id)}`}
|
||||
viewing={viewingVersion}
|
||||
dirty={dirty}
|
||||
reloadKey={verReload}
|
||||
onView={viewVersion}
|
||||
onForked={async (newId) => { await reload(); setSelectedId(newId); const r = await fetch(`/api/v1/controllogic/${encodeURIComponent(newId)}`); if (r.ok) { setGraph(await r.json()); setViewingVersion(null); setDirty(false); } }}
|
||||
onPromoted={async () => { await reload(); const r = await fetch(`/api/v1/controllogic/${encodeURIComponent(graph.id)}`); if (r.ok) { setGraph(await r.json()); setViewingVersion(null); setDirty(false); } setVerReload(k => k + 1); }}
|
||||
onCompare={(av, bv) => setDiff({ av, bv })}
|
||||
onError={setError} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Fragment>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{diff && graph && (
|
||||
<DiffViewer base={`/api/v1/controllogic/${encodeURIComponent(graph.id)}`}
|
||||
av={diff.av} bv={diff.bv} title={`${graph.name} — v${diff.av} → v${diff.bv}`}
|
||||
onClose={() => setDiff(null)} />
|
||||
)}
|
||||
|
||||
{showCloseConfirm && (
|
||||
<div class="cl-confirm-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) setShowCloseConfirm(false); }}>
|
||||
<div class="cl-confirm">
|
||||
@@ -352,6 +414,8 @@ function FlowEditor({ graph, onChange }: {
|
||||
const [selectedWire, setSelectedWire] = useState<number | null>(null);
|
||||
const [dataSources, setDataSources] = useState<string[]>([]);
|
||||
const [dsSignals, setDsSignals] = useState<Record<string, string[]>>({});
|
||||
const [configInstances, setConfigInstances] = useState<{ id: string; name: string }[]>([]);
|
||||
const [configSets, setConfigSets] = useState<{ id: string; name: string }[]>([]);
|
||||
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
const dragNode = useRef<{ id: string; dx: number; dy: number } | null>(null);
|
||||
@@ -364,6 +428,14 @@ function FlowEditor({ graph, onChange }: {
|
||||
.then(r => r.ok ? r.json() : [])
|
||||
.then((ds: DataSource[]) => setDataSources(ds.map(d => d.name)))
|
||||
.catch(() => {});
|
||||
fetch('/api/v1/config/instances')
|
||||
.then(r => r.ok ? r.json() : [])
|
||||
.then((list: { id: string; name: string }[]) => setConfigInstances(list ?? []))
|
||||
.catch(() => {});
|
||||
fetch('/api/v1/config/sets')
|
||||
.then(r => r.ok ? r.json() : [])
|
||||
.then((list: { id: string; name: string }[]) => setConfigSets(list ?? []))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
async function loadSignals(ds: string) {
|
||||
@@ -836,6 +908,124 @@ function FlowEditor({ graph, onChange }: {
|
||||
</Fragment>
|
||||
)}
|
||||
|
||||
{selected.kind === 'action.config.apply' && (
|
||||
<div class="wizard-field">
|
||||
<label>Config instance</label>
|
||||
<select class="prop-select" value={selected.params.instance ?? ''}
|
||||
onChange={(e) => patchParams(selected.id, { instance: (e.target as HTMLSelectElement).value })}>
|
||||
<option value="">choose…</option>
|
||||
{configInstances.map(ci => <option key={ci.id} value={ci.id}>{ci.name}</option>)}
|
||||
</select>
|
||||
<p class="hint">Applies the instance's current values to every bound signal (like the
|
||||
Apply button in the config manager). Each write is audited.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selected.kind === 'action.config.read' && (
|
||||
<Fragment>
|
||||
<div class="wizard-field">
|
||||
<label>Config instance</label>
|
||||
<select class="prop-select" value={selected.params.instance ?? ''}
|
||||
onChange={(e) => patchParams(selected.id, { instance: (e.target as HTMLSelectElement).value })}>
|
||||
<option value="">choose…</option>
|
||||
{configInstances.map(ci => <option key={ci.id} value={ci.id}>{ci.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div class="wizard-field">
|
||||
<label>Parameter key</label>
|
||||
<input class="prop-input" value={selected.params.key ?? ''}
|
||||
placeholder="parameter key"
|
||||
onInput={(e) => patchParams(selected.id, { key: (e.target as HTMLInputElement).value })} />
|
||||
</div>
|
||||
<div class="wizard-field">
|
||||
<label>Target signal / variable</label>
|
||||
<SignalPicker value={selected.params.target ?? ''} options={allSignalOptions()}
|
||||
onOpen={openAllSignals} allowFreeform
|
||||
onChange={(ref) => patchParams(selected.id, { target: ref })} />
|
||||
<p class="hint">Reads the named parameter's value and writes it to this target. Only
|
||||
numeric values are supported.</p>
|
||||
</div>
|
||||
</Fragment>
|
||||
)}
|
||||
|
||||
{selected.kind === 'action.config.write' && (
|
||||
<Fragment>
|
||||
<div class="wizard-field">
|
||||
<label>Config instance</label>
|
||||
<select class="prop-select" value={selected.params.instance ?? ''}
|
||||
onChange={(e) => patchParams(selected.id, { instance: (e.target as HTMLSelectElement).value })}>
|
||||
<option value="">choose…</option>
|
||||
{configInstances.map(ci => <option key={ci.id} value={ci.id}>{ci.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div class="wizard-field">
|
||||
<label>Parameter key</label>
|
||||
<input class="prop-input" value={selected.params.key ?? ''}
|
||||
placeholder="parameter key"
|
||||
onInput={(e) => patchParams(selected.id, { key: (e.target as HTMLInputElement).value })} />
|
||||
</div>
|
||||
<div class="wizard-field">
|
||||
<label>Value expression</label>
|
||||
<input class="prop-input" value={selected.params.expr ?? ''}
|
||||
placeholder="e.g. {ds:signal} * 2"
|
||||
onInput={(e) => patchParams(selected.id, { expr: (e.target as HTMLInputElement).value })} />
|
||||
<p class="hint">Evaluates the expression and stores it into the named parameter, creating a
|
||||
new instance revision. Only numeric values are supported; the value is coerced to the
|
||||
parameter's declared type.</p>
|
||||
</div>
|
||||
</Fragment>
|
||||
)}
|
||||
|
||||
{selected.kind === 'action.config.create' && (
|
||||
<Fragment>
|
||||
<div class="wizard-field">
|
||||
<label>Config set</label>
|
||||
<select class="prop-select" value={selected.params.set ?? ''}
|
||||
onChange={(e) => patchParams(selected.id, { set: (e.target as HTMLSelectElement).value })}>
|
||||
<option value="">choose…</option>
|
||||
{configSets.map(cs => <option key={cs.id} value={cs.id}>{cs.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div class="wizard-field">
|
||||
<label>New instance name</label>
|
||||
<input class="prop-input" value={selected.params.name ?? ''}
|
||||
placeholder="auto"
|
||||
onInput={(e) => patchParams(selected.id, { name: (e.target as HTMLInputElement).value })} />
|
||||
</div>
|
||||
<div class="wizard-field">
|
||||
<label>Copy values from</label>
|
||||
<select class="prop-select" value={selected.params.from ?? ''}
|
||||
onChange={(e) => patchParams(selected.id, { from: (e.target as HTMLSelectElement).value })}>
|
||||
<option value="">defaults (empty)</option>
|
||||
{configInstances.map(ci => <option key={ci.id} value={ci.id}>{ci.name}</option>)}
|
||||
</select>
|
||||
<p class="hint">Creates a new instance for the chosen set, optionally seeded from an existing
|
||||
instance's values. The new id is logged to the audit trail.</p>
|
||||
</div>
|
||||
</Fragment>
|
||||
)}
|
||||
|
||||
{selected.kind === 'action.config.snapshot' && (
|
||||
<Fragment>
|
||||
<div class="wizard-field">
|
||||
<label>Config set</label>
|
||||
<select class="prop-select" value={selected.params.set ?? ''}
|
||||
onChange={(e) => patchParams(selected.id, { set: (e.target as HTMLSelectElement).value })}>
|
||||
<option value="">choose…</option>
|
||||
{configSets.map(cs => <option key={cs.id} value={cs.id}>{cs.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div class="wizard-field">
|
||||
<label>New instance name</label>
|
||||
<input class="prop-input" value={selected.params.name ?? ''}
|
||||
placeholder="(auto)"
|
||||
onInput={(e) => patchParams(selected.id, { name: (e.target as HTMLInputElement).value })} />
|
||||
<p class="hint">Reads the current live value of every target signal of the set and stores
|
||||
them as a new instance. The new id is logged to the audit trail.</p>
|
||||
</div>
|
||||
</Fragment>
|
||||
)}
|
||||
|
||||
<button class="panel-btn" style="margin-top:1rem;" onClick={() => deleteNode(selected.id)}>Delete node</button>
|
||||
</Fragment>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
import { h } from 'preact';
|
||||
import { useRef, useState } from 'preact/hooks';
|
||||
|
||||
// ── Tokenizer ─────────────────────────────────────────────────────────────────
|
||||
|
||||
type TT = 'kw' | 'type' | 'str' | 'cmt' | 'num' | 'attr' | 'text';
|
||||
|
||||
const CUE_KEYWORDS = new Set([
|
||||
'package', 'import', 'for', 'in', 'if', 'let', 'true', 'false', 'null',
|
||||
]);
|
||||
const CUE_TYPES = new Set([
|
||||
'number', 'int', 'float', 'string', 'bytes', 'bool', 'uint', 'rune',
|
||||
]);
|
||||
|
||||
// Identifiers that make sensible always-available completions.
|
||||
const CUE_COMPLETION_WORDS = [...CUE_KEYWORDS, ...CUE_TYPES];
|
||||
|
||||
interface Tok { t: TT; s: string; }
|
||||
|
||||
function isAlpha(c: string) { return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c === '_' || c === '#'; }
|
||||
function isAlNum(c: string) { return isAlpha(c) || (c >= '0' && c <= '9'); }
|
||||
function isDigit(c: string) { return c >= '0' && c <= '9'; }
|
||||
|
||||
function tokenize(src: string): Tok[] {
|
||||
const out: Tok[] = [];
|
||||
let i = 0;
|
||||
|
||||
while (i < src.length) {
|
||||
const c = src[i];
|
||||
|
||||
// Line comment
|
||||
if (c === '/' && src[i + 1] === '/') {
|
||||
const end = src.indexOf('\n', i);
|
||||
const stop = end < 0 ? src.length : end;
|
||||
out.push({ t: 'cmt', s: src.slice(i, stop) });
|
||||
i = stop;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Multiline string """...""" or '''...'''
|
||||
if ((c === '"' || c === "'") && src[i + 1] === c && src[i + 2] === c) {
|
||||
const q = c + c + c;
|
||||
const end = src.indexOf(q, i + 3);
|
||||
const stop = end < 0 ? src.length : end + 3;
|
||||
out.push({ t: 'str', s: src.slice(i, stop) });
|
||||
i = stop;
|
||||
continue;
|
||||
}
|
||||
|
||||
// String "..." or '...'
|
||||
if (c === '"' || c === "'") {
|
||||
let j = i + 1;
|
||||
while (j < src.length) {
|
||||
if (src[j] === '\\') { j += 2; continue; }
|
||||
if (src[j] === c || src[j] === '\n') { j++; break; }
|
||||
j++;
|
||||
}
|
||||
out.push({ t: 'str', s: src.slice(i, j) });
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Number
|
||||
if (isDigit(c) || (c === '.' && isDigit(src[i + 1] ?? ''))) {
|
||||
let j = i;
|
||||
while (j < src.length && (isDigit(src[j]) || src[j] === '.' || src[j] === '_')) j++;
|
||||
if (j < src.length && (src[j] === 'e' || src[j] === 'E')) {
|
||||
j++;
|
||||
if (src[j] === '+' || src[j] === '-') j++;
|
||||
while (j < src.length && isDigit(src[j])) j++;
|
||||
}
|
||||
out.push({ t: 'num', s: src.slice(i, j) });
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Identifier / keyword / field label
|
||||
if (isAlpha(c)) {
|
||||
let j = i;
|
||||
while (j < src.length && isAlNum(src[j])) j++;
|
||||
const word = src.slice(i, j);
|
||||
// Peek past spaces for a ':' => this identifier is a field label.
|
||||
let k = j;
|
||||
while (k < src.length && (src[k] === ' ' || src[k] === '\t')) k++;
|
||||
let t: TT = 'text';
|
||||
if (CUE_KEYWORDS.has(word)) t = 'kw';
|
||||
else if (CUE_TYPES.has(word)) t = 'type';
|
||||
else if (src[k] === ':') t = 'attr';
|
||||
out.push({ t, s: word });
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
|
||||
out.push({ t: 'text', s: c });
|
||||
i++;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderTokens(src: string) {
|
||||
return tokenize(src).map((tok, idx) =>
|
||||
tok.t === 'text'
|
||||
? <span key={idx}>{tok.s}</span>
|
||||
: <span key={idx} class={`cue-${tok.t}`}>{tok.s}</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Caret coordinates (mirror technique) ──────────────────────────────────────
|
||||
|
||||
function caretXY(ta: HTMLTextAreaElement): { left: number; top: number; lineHeight: number } {
|
||||
const style = getComputedStyle(ta);
|
||||
const div = document.createElement('div');
|
||||
const props = [
|
||||
'boxSizing', 'width', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft',
|
||||
'borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth',
|
||||
'fontFamily', 'fontSize', 'fontWeight', 'lineHeight', 'letterSpacing', 'tabSize',
|
||||
] as const;
|
||||
for (const p of props) (div.style as any)[p] = (style as any)[p];
|
||||
div.style.position = 'absolute';
|
||||
div.style.visibility = 'hidden';
|
||||
div.style.whiteSpace = 'pre-wrap';
|
||||
div.style.wordWrap = 'break-word';
|
||||
div.style.overflow = 'hidden';
|
||||
|
||||
div.textContent = ta.value.slice(0, ta.selectionStart);
|
||||
const marker = document.createElement('span');
|
||||
marker.textContent = ta.value.slice(ta.selectionStart) || '.';
|
||||
div.appendChild(marker);
|
||||
document.body.appendChild(div);
|
||||
const lineHeight = parseFloat(style.lineHeight) || (parseFloat(style.fontSize) * 1.4);
|
||||
const top = marker.offsetTop - ta.scrollTop;
|
||||
const left = marker.offsetLeft - ta.scrollLeft;
|
||||
document.body.removeChild(div);
|
||||
return { left, top, lineHeight };
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
rows?: number;
|
||||
/** Parameter/signal names offered alongside the CUE vocabulary. */
|
||||
completions?: string[];
|
||||
}
|
||||
|
||||
interface AC { items: string[]; index: number; from: number; left: number; top: number; }
|
||||
|
||||
export default function CueEditor({ value, onChange, rows = 14, completions = [] }: Props) {
|
||||
const preRef = useRef<HTMLPreElement>(null);
|
||||
const taRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [ac, setAc] = useState<AC | null>(null);
|
||||
|
||||
const vocab = [...new Set([...completions, ...CUE_COMPLETION_WORDS])];
|
||||
|
||||
function syncScroll(e: Event) {
|
||||
const ta = e.target as HTMLTextAreaElement;
|
||||
if (preRef.current) {
|
||||
preRef.current.scrollTop = ta.scrollTop;
|
||||
preRef.current.scrollLeft = ta.scrollLeft;
|
||||
}
|
||||
}
|
||||
|
||||
function wordPrefix(ta: HTMLTextAreaElement): { word: string; from: number } {
|
||||
const caret = ta.selectionStart;
|
||||
let from = caret;
|
||||
while (from > 0 && /[A-Za-z0-9_#]/.test(ta.value[from - 1])) from--;
|
||||
return { word: ta.value.slice(from, caret), from };
|
||||
}
|
||||
|
||||
function refreshAC(ta: HTMLTextAreaElement) {
|
||||
const { word, from } = wordPrefix(ta);
|
||||
if (word.length < 1) { setAc(null); return; }
|
||||
const lower = word.toLowerCase();
|
||||
const items = vocab.filter((w) => w.toLowerCase().startsWith(lower) && w !== word).slice(0, 8);
|
||||
if (items.length === 0) { setAc(null); return; }
|
||||
const { left, top, lineHeight } = caretXY(ta);
|
||||
const rect = ta.getBoundingClientRect();
|
||||
setAc({ items, index: 0, from, left: rect.left + left, top: rect.top + top + lineHeight });
|
||||
}
|
||||
|
||||
function accept(item: string) {
|
||||
const ta = taRef.current;
|
||||
if (!ta || !ac) return;
|
||||
const caret = ta.selectionStart;
|
||||
const next = value.slice(0, ac.from) + item + value.slice(caret);
|
||||
onChange(next);
|
||||
setAc(null);
|
||||
const pos = ac.from + item.length;
|
||||
requestAnimationFrame(() => {
|
||||
ta.focus();
|
||||
ta.setSelectionRange(pos, pos);
|
||||
});
|
||||
}
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (!ac) {
|
||||
// Ctrl+Space forces the completion popup open.
|
||||
if (e.key === ' ' && e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
refreshAC(e.target as HTMLTextAreaElement);
|
||||
}
|
||||
return;
|
||||
}
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
setAc({ ...ac, index: (ac.index + 1) % ac.items.length });
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
setAc({ ...ac, index: (ac.index - 1 + ac.items.length) % ac.items.length });
|
||||
break;
|
||||
case 'Enter':
|
||||
case 'Tab':
|
||||
e.preventDefault();
|
||||
accept(ac.items[ac.index]);
|
||||
break;
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
setAc(null);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="cue-editor" style={`height:${rows * 1.5}em;`}>
|
||||
<pre ref={preRef} class="cue-pre" aria-hidden="true">
|
||||
{renderTokens(value)}
|
||||
{'\n'}
|
||||
</pre>
|
||||
<textarea
|
||||
ref={taRef}
|
||||
class="cue-textarea"
|
||||
value={value}
|
||||
spellcheck={false}
|
||||
autocomplete="off"
|
||||
onInput={(e) => { onChange((e.target as HTMLTextAreaElement).value); refreshAC(e.target as HTMLTextAreaElement); }}
|
||||
onKeyDown={onKeyDown}
|
||||
onScroll={syncScroll}
|
||||
onBlur={() => setTimeout(() => setAc(null), 120)}
|
||||
/>
|
||||
{ac && (
|
||||
<ul class="cue-ac" style={`left:${ac.left}px;top:${ac.top}px;`}>
|
||||
{ac.items.map((it, i) => (
|
||||
<li
|
||||
key={it}
|
||||
class={i === ac.index ? 'cue-ac-item active' : 'cue-ac-item'}
|
||||
onMouseDown={(e) => { e.preventDefault(); accept(it); }}
|
||||
>
|
||||
{it}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -35,6 +35,7 @@ export const DEFAULT_SIZES: Record<string, [number, number]> = {
|
||||
plot: [400, 200],
|
||||
image: [200, 150],
|
||||
link: [140, 50],
|
||||
configselect: [220, 50],
|
||||
};
|
||||
|
||||
// Widget types that support multiple signals (signal can be appended)
|
||||
|
||||
+41
-111
@@ -11,20 +11,13 @@ import PropertiesPane from './PropertiesPane';
|
||||
import HelpModal from './HelpModal';
|
||||
import ZoomControl from './ZoomControl';
|
||||
import { useAuth, canWrite } from './lib/auth';
|
||||
import { VersionTree, DiffViewer } from './VersionHistory';
|
||||
|
||||
interface Props {
|
||||
initial: Interface | null;
|
||||
onDone: (iface: Interface | null) => void;
|
||||
}
|
||||
|
||||
interface VersionMeta {
|
||||
version: number;
|
||||
name: string;
|
||||
tag?: string;
|
||||
current: boolean;
|
||||
savedAt: string;
|
||||
}
|
||||
|
||||
function blankInterface(): Interface {
|
||||
return { id: '', name: 'New Interface', version: 1, w: 1200, h: 800, widgets: [] };
|
||||
}
|
||||
@@ -83,6 +76,7 @@ const STATIC_WIDGET_TYPES = [
|
||||
{ type: 'button', label: 'Action Button' },
|
||||
{ type: 'image', label: 'Image' },
|
||||
{ type: 'link', label: 'Link' },
|
||||
{ type: 'configselect', label: 'Config Selector' },
|
||||
];
|
||||
|
||||
export default function EditMode({ initial, onDone }: Props) {
|
||||
@@ -105,8 +99,9 @@ export default function EditMode({ initial, onDone }: Props) {
|
||||
|
||||
// History panel
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
const [versions, setVersions] = useState<VersionMeta[]>([]);
|
||||
const [historyLoading, setHistoryLoading] = useState(false);
|
||||
const [viewingVersion, setViewingVersion] = useState<number | null>(null);
|
||||
const [diff, setDiff] = useState<{ av: number; bv: number } | null>(null);
|
||||
const [verReload, setVerReload] = useState(0);
|
||||
const [tag, setTag] = useState('');
|
||||
// Bumped whenever the editor content is replaced wholesale (version load /
|
||||
// promote) to force a full canvas remount, so no stale widget state lingers.
|
||||
@@ -328,6 +323,7 @@ export default function EditMode({ initial, onDone }: Props) {
|
||||
options:
|
||||
type === 'textlabel' ? { label: 'Label' } :
|
||||
type === 'button' ? { label: 'Button', mode: 'oneshot' } :
|
||||
type === 'configselect' ? { label: 'Config', set: '', output: '', instances: '' } :
|
||||
{},
|
||||
};
|
||||
handleWidgetsChange([...(iface.widgets || []), newWidget]);
|
||||
@@ -359,7 +355,8 @@ export default function EditMode({ initial, onDone }: Props) {
|
||||
setDirty(false);
|
||||
setTag('');
|
||||
window.dispatchEvent(new CustomEvent('uopi:refresh-interfaces'));
|
||||
if (showHistory) loadVersions();
|
||||
setViewingVersion(null);
|
||||
setVerReload(k => k + 1);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
@@ -370,26 +367,12 @@ export default function EditMode({ initial, onDone }: Props) {
|
||||
|
||||
// ── Version history ────────────────────────────────────────────────────────
|
||||
|
||||
const loadVersions = useCallback(async () => {
|
||||
if (!iface.id) { setVersions([]); return; }
|
||||
setHistoryLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/v1/interfaces/${encodeURIComponent(iface.id)}/versions`);
|
||||
if (!res.ok) throw new Error(`Load versions failed (${res.status})`);
|
||||
setVersions(await res.json());
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setHistoryLoading(false);
|
||||
}
|
||||
}, [iface.id]);
|
||||
|
||||
const toggleHistory = useCallback(() => {
|
||||
setShowHistory(s => {
|
||||
if (!s) loadVersions();
|
||||
if (!s) setVerReload(k => k + 1);
|
||||
return !s;
|
||||
});
|
||||
}, [loadVersions]);
|
||||
}, []);
|
||||
|
||||
// Load a past revision into the editor non-destructively: the current id is
|
||||
// preserved, so saving the restored content creates a new revision on top.
|
||||
@@ -403,45 +386,26 @@ export default function EditMode({ initial, onDone }: Props) {
|
||||
restored.id = iface.id; // keep editing the same interface
|
||||
setIface(restored);
|
||||
setSelectedIds([]);
|
||||
setDirty(true);
|
||||
// Viewing a past revision is not itself a change — only subsequent edits
|
||||
// dirty the editor (saving then promotes it to a new revision).
|
||||
setDirty(false);
|
||||
setCanvasNonce(n => n + 1);
|
||||
undoStack.current = [];
|
||||
redoStack.current = [];
|
||||
setHistoryLen(0);
|
||||
setViewingVersion(version);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}, [iface.id, dirty]);
|
||||
|
||||
// Edit (or clear) the label of an existing revision in place.
|
||||
const editVersionTag = useCallback(async (version: number, currentTag: string) => {
|
||||
// Reload the now-current interface content into the editor (after a promote,
|
||||
// which the VersionTree performs server-side).
|
||||
const reloadCurrentIface = useCallback(async () => {
|
||||
if (!iface.id) return;
|
||||
const next = prompt('Label for this version (leave empty to clear):', currentTag ?? '');
|
||||
if (next === null) return;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/v1/interfaces/${encodeURIComponent(iface.id)}/versions/${version}/tag?tag=${encodeURIComponent(next)}`,
|
||||
{ method: 'PUT' },
|
||||
);
|
||||
if (!res.ok) throw new Error(`Tag update failed (${res.status})`);
|
||||
loadVersions();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}, [iface.id, loadVersions]);
|
||||
|
||||
// Make a past revision the current one (saved as a new revision on top).
|
||||
const promoteVersion = useCallback(async (version: number) => {
|
||||
if (!iface.id) return;
|
||||
if (dirty && !confirm('You have unsaved changes that will be discarded. Set this version as current?')) return;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/v1/interfaces/${encodeURIComponent(iface.id)}/versions/${version}/promote`,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
if (!res.ok) throw new Error(`Promote failed (${res.status})`);
|
||||
// Reload the now-current interface content into the editor.
|
||||
const cur = await fetch(`/api/v1/interfaces/${encodeURIComponent(iface.id)}`);
|
||||
if (!cur.ok) throw new Error(`Reload failed (${cur.status})`);
|
||||
const reloaded = parseInterface(await cur.text());
|
||||
setIface(reloaded);
|
||||
setSelectedIds([]);
|
||||
@@ -450,25 +414,9 @@ export default function EditMode({ initial, onDone }: Props) {
|
||||
undoStack.current = [];
|
||||
redoStack.current = [];
|
||||
setHistoryLen(0);
|
||||
setViewingVersion(null);
|
||||
window.dispatchEvent(new CustomEvent('uopi:refresh-interfaces'));
|
||||
loadVersions();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}, [iface.id, dirty, loadVersions]);
|
||||
|
||||
// Fork a revision into a brand-new interface.
|
||||
const forkVersion = useCallback(async (version: number) => {
|
||||
if (!iface.id) return;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/v1/interfaces/${encodeURIComponent(iface.id)}/versions/${version}/fork`,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
if (!res.ok) throw new Error(`Fork failed (${res.status})`);
|
||||
const json = await res.json();
|
||||
window.dispatchEvent(new CustomEvent('uopi:refresh-interfaces'));
|
||||
alert(`Forked into new interface "${json.id}". Open it from the interface list.`);
|
||||
setVerReload(k => k + 1);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
@@ -733,49 +681,31 @@ export default function EditMode({ initial, onDone }: Props) {
|
||||
Save snapshot
|
||||
</button>
|
||||
</div>
|
||||
<div class="history-list">
|
||||
{historyLoading && <div class="history-empty">Loading…</div>}
|
||||
{!historyLoading && versions.length === 0 && (
|
||||
<div class="history-empty">No saved versions yet.</div>
|
||||
)}
|
||||
{!historyLoading && versions.map(v => (
|
||||
<div
|
||||
key={v.version}
|
||||
class={`history-item${v.current ? ' history-item-current' : ''}`}
|
||||
>
|
||||
<div class="history-item-main">
|
||||
<span class="history-version">v{v.version}</span>
|
||||
{v.tag && <span class="history-tag">{v.tag}</span>}
|
||||
{v.current && <span class="history-current-badge">current</span>}
|
||||
</div>
|
||||
<div class="history-item-meta">
|
||||
{new Date(v.savedAt).toLocaleString()}
|
||||
</div>
|
||||
<div class="history-item-actions">
|
||||
{!v.current && (
|
||||
<button class="history-action-btn" onClick={() => loadVersion(v.version)} title="Load this version into the editor (does not change history)">
|
||||
Load
|
||||
</button>
|
||||
)}
|
||||
{!v.current && (
|
||||
<button class="history-action-btn" onClick={() => promoteVersion(v.version)} title="Make this version the current one">
|
||||
Set current
|
||||
</button>
|
||||
)}
|
||||
<button class="history-action-btn" onClick={() => forkVersion(v.version)} title="Create a new interface from this version">
|
||||
Fork
|
||||
</button>
|
||||
<button class="history-action-btn" onClick={() => editVersionTag(v.version, v.tag ?? '')} title="Edit this version's label">
|
||||
Label
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{!iface.id ? (
|
||||
<div class="history-empty">Save the interface first to enable history.</div>
|
||||
) : (
|
||||
<VersionTree
|
||||
base={`/api/v1/interfaces/${encodeURIComponent(iface.id)}`}
|
||||
viewing={viewingVersion}
|
||||
dirty={dirty}
|
||||
canWrite={writable}
|
||||
reloadKey={verReload}
|
||||
onView={loadVersion}
|
||||
onForked={() => window.dispatchEvent(new CustomEvent('uopi:refresh-interfaces'))}
|
||||
onPromoted={reloadCurrentIface}
|
||||
onCompare={(av, bv) => setDiff({ av, bv })}
|
||||
onError={setError} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{diff && iface.id && (
|
||||
<DiffViewer base={`/api/v1/interfaces/${encodeURIComponent(iface.id)}`}
|
||||
av={diff.av} bv={diff.bv} title={`${iface.name} — v${diff.av} → v${diff.bv}`}
|
||||
onClose={() => setDiff(null)} />
|
||||
)}
|
||||
|
||||
{showHelp && (
|
||||
<HelpModal initialSection={helpSection} onClose={() => setShowHelp(false)} />
|
||||
)}
|
||||
|
||||
@@ -70,6 +70,11 @@ const PALETTE: PaletteEntry[] = [
|
||||
{ kind: 'action.dialog.info', label: 'Info dialog', params: { title: 'Info', message: '' } },
|
||||
{ kind: 'action.dialog.error', label: 'Error dialog', params: { title: 'Error', message: '' } },
|
||||
{ kind: 'action.dialog.setpoint', label: 'Set-point dialog', params: { title: 'Set value', message: '', fields: '[{"type":"input","label":"","target":"","default":""}]' } },
|
||||
{ kind: 'action.config.apply', label: 'Apply config', params: { instance: '', instanceSource: 'fixed', instanceVar: '' } },
|
||||
{ kind: 'action.config.read', label: 'Read config', params: { instance: '', instanceSource: 'fixed', instanceVar: '', key: '', target: '' } },
|
||||
{ kind: 'action.config.write', label: 'Write config', params: { instance: '', instanceSource: 'fixed', instanceVar: '', key: '', expr: '' } },
|
||||
{ kind: 'action.config.create', label: 'Create config', params: { set: '', name: 'auto', from: '', target: '' } },
|
||||
{ kind: 'action.config.snapshot', label: 'Snapshot config', params: { set: '', name: '', target: '' } },
|
||||
];
|
||||
|
||||
const KIND_LABEL: Record<LogicNodeKind, string> = {
|
||||
@@ -93,6 +98,11 @@ const KIND_LABEL: Record<LogicNodeKind, string> = {
|
||||
'action.dialog.info': 'Info dialog',
|
||||
'action.dialog.error': 'Error dialog',
|
||||
'action.dialog.setpoint': 'Set-point dialog',
|
||||
'action.config.apply': 'Apply config',
|
||||
'action.config.read': 'Read config',
|
||||
'action.config.write': 'Write config',
|
||||
'action.config.create': 'Create config',
|
||||
'action.config.snapshot': 'Snapshot config',
|
||||
};
|
||||
|
||||
const THRESHOLD_OPS = ['>', '<', '>=', '<=', '==', '!='] as const;
|
||||
@@ -140,6 +150,8 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
|
||||
|
||||
const [dataSources, setDataSources] = useState<string[]>([]);
|
||||
const [dsSignals, setDsSignals] = useState<Record<string, string[]>>({});
|
||||
const [configInstances, setConfigInstances] = useState<{ id: string; name: string }[]>([]);
|
||||
const [configSets, setConfigSets] = useState<{ id: string; name: string }[]>([]);
|
||||
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
const dragNode = useRef<{ id: string; dx: number; dy: number; pushed: boolean } | null>(null);
|
||||
@@ -165,6 +177,14 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
|
||||
.then(r => r.ok ? r.json() : [])
|
||||
.then((ds: DataSource[]) => setDataSources(ds.map(d => d.name)))
|
||||
.catch(() => {});
|
||||
fetch('/api/v1/config/instances')
|
||||
.then(r => r.ok ? r.json() : [])
|
||||
.then((list: { id: string; name: string }[]) => setConfigInstances(list ?? []))
|
||||
.catch(() => {});
|
||||
fetch('/api/v1/config/sets')
|
||||
.then(r => r.ok ? r.json() : [])
|
||||
.then((list: { id: string; name: string }[]) => setConfigSets(list ?? []))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
async function loadSignals(ds: string) {
|
||||
@@ -194,6 +214,45 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
|
||||
}
|
||||
function openAllSignals() { dsOptions.forEach(ds => loadSignals(ds)); }
|
||||
|
||||
// The config-instance selector shared by the apply/read/write nodes. The
|
||||
// instance can be fixed (chosen here at design time) or read at run time from
|
||||
// a panel variable (instanceSource='var') — this is how a config-selector
|
||||
// widget feeds these nodes the operator's current choice.
|
||||
function instanceField(node: LogicNode) {
|
||||
const src = node.params.instanceSource ?? 'fixed';
|
||||
return (
|
||||
<Fragment>
|
||||
<div class="wizard-field">
|
||||
<label>Instance source</label>
|
||||
<select class="prop-select" value={src}
|
||||
onChange={(e) => patchParams(node.id, { instanceSource: (e.target as HTMLSelectElement).value })}>
|
||||
<option value="fixed">Fixed instance</option>
|
||||
<option value="var">From variable</option>
|
||||
</select>
|
||||
</div>
|
||||
{src === 'var' ? (
|
||||
<div class="wizard-field">
|
||||
<label>Instance-id variable</label>
|
||||
<SignalPicker value={node.params.instanceVar ?? ''} options={allSignalOptions()}
|
||||
onOpen={openAllSignals} allowFreeform
|
||||
onChange={(ref) => patchParams(node.id, { instanceVar: ref })} />
|
||||
<p class="hint">Reads the instance id (a string) from this panel variable — e.g. the
|
||||
output of a Config selector widget.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div class="wizard-field">
|
||||
<label>Config instance</label>
|
||||
<select class="prop-select" value={node.params.instance ?? ''}
|
||||
onChange={(e) => patchParams(node.id, { instance: (e.target as HTMLSelectElement).value })}>
|
||||
<option value="">choose…</option>
|
||||
{configInstances.map(ci => <option key={ci.id} value={ci.id}>{ci.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
const selected = nodes.find(n => n.id === selectedNode) ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -637,6 +696,114 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selected.kind === 'action.config.apply' && (
|
||||
<Fragment>
|
||||
{instanceField(selected)}
|
||||
<p class="hint">Applies the instance's current values to every bound signal (like the
|
||||
Apply button in the config manager).</p>
|
||||
</Fragment>
|
||||
)}
|
||||
|
||||
{selected.kind === 'action.config.read' && (
|
||||
<Fragment>
|
||||
{instanceField(selected)}
|
||||
<div class="wizard-field">
|
||||
<label>Parameter key</label>
|
||||
<input class="prop-input" value={selected.params.key ?? ''}
|
||||
placeholder="parameter key"
|
||||
onInput={(e) => patchParams(selected.id, { key: (e.target as HTMLInputElement).value })} />
|
||||
</div>
|
||||
<div class="wizard-field">
|
||||
<label>Target signal / variable</label>
|
||||
<SignalPicker value={selected.params.target ?? ''} options={allSignalOptions()}
|
||||
onOpen={openAllSignals} allowFreeform
|
||||
onChange={(ref) => patchParams(selected.id, { target: ref })} />
|
||||
<p class="hint">Reads the named parameter's value and writes it to this target
|
||||
(numeric values only).</p>
|
||||
</div>
|
||||
</Fragment>
|
||||
)}
|
||||
|
||||
{selected.kind === 'action.config.write' && (
|
||||
<Fragment>
|
||||
{instanceField(selected)}
|
||||
<div class="wizard-field">
|
||||
<label>Parameter key</label>
|
||||
<input class="prop-input" value={selected.params.key ?? ''}
|
||||
placeholder="parameter key"
|
||||
onInput={(e) => patchParams(selected.id, { key: (e.target as HTMLInputElement).value })} />
|
||||
</div>
|
||||
<ExprField label="Value (expression)" value={selected.params.expr ?? ''}
|
||||
onChange={(v) => patchParams(selected.id, { expr: v })}
|
||||
onInsert={(ds2, sig) => insertRef(selected.id, 'expr', ds2, sig)}
|
||||
allSignals={allSignalOptions()} onOpenSignals={openAllSignals}
|
||||
hint="Stores the value into the named parameter, creating a new instance revision (numeric values only)." />
|
||||
</Fragment>
|
||||
)}
|
||||
|
||||
{selected.kind === 'action.config.create' && (
|
||||
<Fragment>
|
||||
<div class="wizard-field">
|
||||
<label>Config set</label>
|
||||
<select class="prop-select" value={selected.params.set ?? ''}
|
||||
onChange={(e) => patchParams(selected.id, { set: (e.target as HTMLSelectElement).value })}>
|
||||
<option value="">choose…</option>
|
||||
{configSets.map(cs => <option key={cs.id} value={cs.id}>{cs.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div class="wizard-field">
|
||||
<label>New instance name</label>
|
||||
<input class="prop-input" value={selected.params.name ?? ''}
|
||||
placeholder="auto"
|
||||
onInput={(e) => patchParams(selected.id, { name: (e.target as HTMLInputElement).value })} />
|
||||
</div>
|
||||
<div class="wizard-field">
|
||||
<label>Copy values from</label>
|
||||
<select class="prop-select" value={selected.params.from ?? ''}
|
||||
onChange={(e) => patchParams(selected.id, { from: (e.target as HTMLSelectElement).value })}>
|
||||
<option value="">defaults (empty)</option>
|
||||
{configInstances.map(ci => <option key={ci.id} value={ci.id}>{ci.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div class="wizard-field">
|
||||
<label>Write new id to variable</label>
|
||||
<SignalPicker value={selected.params.target ?? ''} options={allSignalOptions()}
|
||||
onOpen={openAllSignals} allowFreeform
|
||||
onChange={(ref) => patchParams(selected.id, { target: ref })} />
|
||||
<p class="hint">Creates a new instance for the chosen set (optionally seeded from another
|
||||
instance) and writes its id to this panel variable, ready to feed apply/read/write
|
||||
nodes set to "From variable".</p>
|
||||
</div>
|
||||
</Fragment>
|
||||
)}
|
||||
|
||||
{selected.kind === 'action.config.snapshot' && (
|
||||
<Fragment>
|
||||
<div class="wizard-field">
|
||||
<label>Config set</label>
|
||||
<select class="prop-select" value={selected.params.set ?? ''}
|
||||
onChange={(e) => patchParams(selected.id, { set: (e.target as HTMLSelectElement).value })}>
|
||||
<option value="">choose…</option>
|
||||
{configSets.map(cs => <option key={cs.id} value={cs.id}>{cs.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div class="wizard-field">
|
||||
<label>New instance name</label>
|
||||
<input class="prop-input" value={selected.params.name ?? ''}
|
||||
placeholder="(auto)"
|
||||
onInput={(e) => patchParams(selected.id, { name: (e.target as HTMLInputElement).value })} />
|
||||
</div>
|
||||
<div class="wizard-field">
|
||||
<label>Write new id to variable</label>
|
||||
<SignalPicker value={selected.params.target ?? ''} options={allSignalOptions()}
|
||||
onOpen={openAllSignals} allowFreeform
|
||||
onChange={(ref) => patchParams(selected.id, { target: ref })} />
|
||||
<p class="hint">Reads the current live value of every target signal of the set and stores
|
||||
them as a new instance, then writes the new instance id to this panel variable.</p>
|
||||
</div>
|
||||
</Fragment>
|
||||
)}
|
||||
|
||||
{selected.kind === 'action.accumulate' && (
|
||||
<Fragment>
|
||||
<div class="wizard-field">
|
||||
|
||||
@@ -55,6 +55,17 @@ const MULTI_SIGNAL_WIDGETS = new Set(['plot', 'multiled']);
|
||||
|
||||
export default function PropertiesPane({ selected, multiCount, iface, onChange, onIfaceChange, width }: Props) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [configSets, setConfigSets] = useState<{ id: string; name: string }[]>([]);
|
||||
|
||||
// Config sets are only needed by the Config Selector widget; fetch lazily the
|
||||
// first time one is selected.
|
||||
useEffect(() => {
|
||||
if (selected?.type !== 'configselect' || configSets.length > 0) return;
|
||||
fetch('/api/v1/config/sets')
|
||||
.then(r => r.ok ? r.json() : [])
|
||||
.then((list: { id: string; name: string }[]) => setConfigSets(list ?? []))
|
||||
.catch(() => {});
|
||||
}, [selected?.type]);
|
||||
|
||||
function setOpt(key: string, value: string) {
|
||||
if (!selected) return;
|
||||
@@ -360,6 +371,39 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{w.type === 'configselect' && (
|
||||
<div>
|
||||
<Field label="Label">
|
||||
<TextInput value={w.options['label'] ?? 'Config'} onCommit={(v) => setOpt('label', v)} />
|
||||
</Field>
|
||||
<Field label="Config set">
|
||||
<select class="prop-select" value={w.options['set'] ?? ''}
|
||||
onChange={(e) => setOpt('set', (e.target as HTMLSelectElement).value)}>
|
||||
<option value="">choose…</option>
|
||||
{configSets.map(cs => <option key={cs.id} value={cs.id}>{cs.name}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Output variable">
|
||||
<div class="prop-field-col">
|
||||
<select class="prop-input" value={w.options['output'] ?? ''}
|
||||
onChange={(e) => setOpt('output', (e.target as HTMLSelectElement).value)}>
|
||||
<option value="">(none)</option>
|
||||
{(iface.statevars ?? []).map(v => (
|
||||
<option key={v.name} value={v.name}>{v.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<span class="prop-hint">A panel variable (define one of type "string" in the Variables tab); receives the selected instance id.</span>
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="Instance subset">
|
||||
<div class="prop-field-col">
|
||||
<TextInput value={w.options['instances'] ?? ''} onCommit={(v) => setOpt('instances', v)} />
|
||||
<span class="prop-hint">Comma-separated instance ids to show. Empty = all instances of the set.</span>
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import SignalPicker, { SignalOption } from './SignalPicker';
|
||||
import LuaEditor from './LuaEditor';
|
||||
import type { SignalDef, SynthGraph, SynthGraphNode } from './lib/types';
|
||||
import { inferNodeTypes, SynthType } from './lib/synthTypes';
|
||||
import { VersionTree, DiffViewer } from './VersionHistory';
|
||||
|
||||
interface DataSource { name: string; }
|
||||
interface SignalInfo { name: string; type?: string; }
|
||||
@@ -313,6 +314,11 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
|
||||
// Quick-add HUD (S = signal, N = node); null when closed.
|
||||
const [hud, setHud] = useState<null | 'signal' | 'node'>(null);
|
||||
const [hudFilter, setHudFilter] = useState('');
|
||||
// Version history pane (existing signals only).
|
||||
const [showVersions, setShowVersions] = useState(false);
|
||||
const [viewingVersion, setViewingVersion] = useState<number | null>(null);
|
||||
const [diff, setDiff] = useState<{ av: number; bv: number } | null>(null);
|
||||
const [verReload, setVerReload] = useState(0);
|
||||
|
||||
// Identity fields — editable only when creating a new signal.
|
||||
const [newName, setNewName] = useState('');
|
||||
@@ -710,6 +716,44 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
|
||||
}
|
||||
}
|
||||
|
||||
// Apply a fetched SignalDef to the editor (meta fields + rebuilt node graph).
|
||||
function applyDef(d: SignalDef, asEdit: boolean) {
|
||||
setDef(d);
|
||||
setUnit(d.meta?.unit ?? '');
|
||||
setDesc(d.meta?.description ?? '');
|
||||
setDispLow(String(d.meta?.displayLow ?? '0'));
|
||||
setDispHigh(String(d.meta?.displayHigh ?? '100'));
|
||||
const g = buildInitial(d);
|
||||
if (asEdit) commit(g); else { undoStack.current = []; redoStack.current = []; setGraph(g); bump(); }
|
||||
g.nodes.forEach(n => { if (n.kind === 'source' && n.ds) loadSignals(n.ds); });
|
||||
}
|
||||
|
||||
// Load a past revision into the editor as an undoable edit; saving promotes it
|
||||
// to a new current version (non-destructive).
|
||||
async function viewVersion(version: number) {
|
||||
if (canUndo && !confirm('Discard unsaved changes to load this version?')) return;
|
||||
try {
|
||||
const res = await fetch(`/api/v1/synthetic/${encodeURIComponent(sigName)}/versions/${version}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const d: SignalDef = await res.json();
|
||||
applyDef(d, false);
|
||||
setViewingVersion(version);
|
||||
} catch (err) {
|
||||
setError(`Load version failed: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadCurrent() {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/synthetic/${encodeURIComponent(sigName)}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
applyDef(await res.json(), false);
|
||||
setViewingVersion(null);
|
||||
} catch (err) {
|
||||
setError(`Reload failed: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
}
|
||||
|
||||
function nodeClass(n: GNode): string {
|
||||
const cat = n.kind === 'source' ? 'trigger' : n.kind === 'output' ? 'action' : 'flow';
|
||||
const err = nodeErrors.has(n.id) ? ' flow-node-error' : '';
|
||||
@@ -721,6 +765,10 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
|
||||
<div class="synth-graph" onClick={(e) => e.stopPropagation()}>
|
||||
<div class="wizard-header">
|
||||
<span>Synthetic Signal{create ? ' — new' : ` — ${name}`}</span>
|
||||
{!create && (
|
||||
<button class={`toolbar-btn${showVersions ? ' toolbar-btn-primary' : ''}`}
|
||||
style="margin-left:auto;" onClick={() => setShowVersions(v => !v)}>History</button>
|
||||
)}
|
||||
<button class="icon-btn" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
@@ -982,6 +1030,30 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!create && showVersions && (
|
||||
<div class="ver-pane synth-ver-pane">
|
||||
<div class="ver-pane-head">Version history
|
||||
<button class="icon-btn" onClick={() => setShowVersions(false)}>✕</button>
|
||||
</div>
|
||||
<VersionTree
|
||||
base={`/api/v1/synthetic/${encodeURIComponent(sigName)}`}
|
||||
viewing={viewingVersion}
|
||||
dirty={canUndo}
|
||||
reloadKey={verReload}
|
||||
onView={viewVersion}
|
||||
onForked={() => { onSaved(); setVerReload(k => k + 1); }}
|
||||
onPromoted={async () => { await reloadCurrent(); onSaved(); setVerReload(k => k + 1); }}
|
||||
onCompare={(av, bv) => setDiff({ av, bv })}
|
||||
onError={setError} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{diff && (
|
||||
<DiffViewer base={`/api/v1/synthetic/${encodeURIComponent(sigName)}`}
|
||||
av={diff.av} bv={diff.bv} title={`${sigName} — v${diff.av} → v${diff.bv}`}
|
||||
onClose={() => setDiff(null)} />
|
||||
)}
|
||||
|
||||
<div class="wizard-footer">
|
||||
{error && <span class="wizard-error" style="flex:1;">{error}</span>}
|
||||
{!error && firstError && <span class="hint" style="flex:1;">{firstError}</span>}
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useEffect, useCallback } from 'preact/hooks';
|
||||
import { diffLines, diffStats, sideBySide, DiffRow } from './lib/linediff';
|
||||
|
||||
// VersionMeta mirrors the Go VersionMeta returned by every versioned document
|
||||
// type (interfaces, synthetic, controllogic, config sets/instances).
|
||||
export interface VersionMeta {
|
||||
version: number;
|
||||
name: string;
|
||||
tag?: string;
|
||||
current: boolean;
|
||||
savedAt: string;
|
||||
}
|
||||
|
||||
function errMsg(e: unknown): string { return e instanceof Error ? e.message : String(e); }
|
||||
|
||||
function fmtTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return iso;
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
async function fetchJSON<T = any>(url: string, opts?: RequestInit): Promise<T | null> {
|
||||
const res = await fetch(url, opts);
|
||||
if (!res.ok && res.status !== 204) {
|
||||
let m = `HTTP ${res.status}`;
|
||||
try { const e = await res.json(); if (e && e.error) m = e.error; } catch { /* ignore */ }
|
||||
throw new Error(m);
|
||||
}
|
||||
if (res.status === 204) return null;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// fetchVersionText returns a revision's serialized content as text, pretty-
|
||||
// printing JSON so structural changes produce clean, stable line diffs (XML
|
||||
// panels are returned verbatim).
|
||||
async function fetchVersionText(base: string, v: number): Promise<string> {
|
||||
const res = await fetch(`${base}/versions/${v}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const raw = await res.text();
|
||||
try { return JSON.stringify(JSON.parse(raw), null, 2); } catch { return raw; }
|
||||
}
|
||||
|
||||
// ── Version tree ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function VersionTree({ base, viewing, dirty, canWrite = true, onView, onForked, onPromoted, onCompare, onError, reloadKey }: {
|
||||
base: string; // e.g. /api/v1/synthetic/<name>
|
||||
viewing: number | null; // version currently open in the editor (active)
|
||||
dirty?: boolean; // unsaved edits → dashed connector at the top
|
||||
canWrite?: boolean; // gate promote/fork affordances
|
||||
onView: (version: number) => void;
|
||||
onForked: (newId: string) => void;
|
||||
onPromoted: (version: number) => void;
|
||||
onCompare: (av: number, bv: number) => void;
|
||||
onError: (m: string) => void;
|
||||
reloadKey?: number; // bump to force a versions reload
|
||||
}) {
|
||||
const [versions, setVersions] = useState<VersionMeta[]>([]);
|
||||
const [a, setA] = useState<number | null>(null);
|
||||
const [b, setB] = useState<number | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const v = (await fetchJSON<VersionMeta[]>(`${base}/versions`)) ?? [];
|
||||
setVersions(v);
|
||||
if (v.length >= 2) { setA(v[1].version); setB(v[0].version); }
|
||||
else if (v.length === 1) { setA(v[0].version); setB(v[0].version); }
|
||||
} catch (e) { onError(`Versions failed: ${errMsg(e)}`); }
|
||||
}, [base, onError]);
|
||||
|
||||
useEffect(() => { load(); }, [load, reloadKey]);
|
||||
|
||||
async function fork(version: number) {
|
||||
try {
|
||||
const obj = await fetchJSON<{ id: string }>(`${base}/versions/${version}/fork`, { method: 'POST' });
|
||||
if (obj?.id) onForked(obj.id);
|
||||
} catch (e) { onError(`Fork failed: ${errMsg(e)}`); }
|
||||
}
|
||||
async function promote(version: number) {
|
||||
if (!confirm(`Promote v${version} to a new current version?`)) return;
|
||||
try {
|
||||
await fetchJSON(`${base}/versions/${version}/promote`, { method: 'POST' });
|
||||
await load();
|
||||
onPromoted(version);
|
||||
} catch (e) { onError(`Promote failed: ${errMsg(e)}`); }
|
||||
}
|
||||
|
||||
const activeVer = viewing ?? versions.find(v => v.current)?.version ?? null;
|
||||
|
||||
return (
|
||||
<div class="ver-tree">
|
||||
{dirty && (
|
||||
<div class="ver-node ver-node-unsaved">
|
||||
<div class="ver-rail">
|
||||
<span class="ver-dot ver-dot-unsaved" />
|
||||
<span class="ver-line ver-line-dashed" />
|
||||
</div>
|
||||
<div class="ver-body"><span class="ver-num">unsaved changes</span></div>
|
||||
</div>
|
||||
)}
|
||||
{versions.length === 0 && <div class="hint ver-empty">No versions yet.</div>}
|
||||
{versions.map((v, idx) => {
|
||||
const active = v.version === activeVer;
|
||||
const last = idx === versions.length - 1;
|
||||
const cls = `ver-dot${v.current ? ' ver-dot-current' : ''}${active ? ' ver-dot-active' : ''}`;
|
||||
return (
|
||||
<div key={v.version} class={`ver-node${active ? ' ver-node-active' : ''}`}>
|
||||
<div class="ver-rail">
|
||||
<span class={cls} title={v.current ? 'Selected (executed) version' : ''} />
|
||||
{!last && <span class="ver-line" />}
|
||||
</div>
|
||||
<div class="ver-body">
|
||||
<button class="ver-num" title="View this version" onClick={() => onView(v.version)}>
|
||||
v{v.version}
|
||||
{v.current && <span class="ver-badge">current</span>}
|
||||
{active && !v.current && <span class="ver-badge ver-badge-view">viewing</span>}
|
||||
</button>
|
||||
{v.tag && <span class="ver-tag hint" title={v.tag}>{v.tag}</span>}
|
||||
<span class="ver-time hint">{fmtTime(v.savedAt)}</span>
|
||||
<span class="ver-actions">
|
||||
{activeVer !== null && v.version !== activeVer && (
|
||||
<button class="cl-mini-btn" title={`Diff against the viewed version (v${activeVer})`}
|
||||
onClick={() => onCompare(Math.min(activeVer, v.version), Math.max(activeVer, v.version))}>diff</button>
|
||||
)}
|
||||
{canWrite && <button class="cl-mini-btn" title="Fork into a new document" onClick={() => fork(v.version)}>fork</button>}
|
||||
{canWrite && !v.current && <button class="cl-mini-btn" title="Promote to current" onClick={() => promote(v.version)}>promote</button>}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{versions.length >= 2 && (
|
||||
<div class="ver-compare">
|
||||
<span class="hint">Compare</span>
|
||||
<select class="prop-select" value={String(a ?? '')} onChange={(e) => setA(Number((e.target as HTMLSelectElement).value))}>
|
||||
{versions.map(v => <option key={v.version} value={v.version}>v{v.version}</option>)}
|
||||
</select>
|
||||
<span>→</span>
|
||||
<select class="prop-select" value={String(b ?? '')} onChange={(e) => setB(Number((e.target as HTMLSelectElement).value))}>
|
||||
{versions.map(v => <option key={v.version} value={v.version}>v{v.version}</option>)}
|
||||
</select>
|
||||
<button class="panel-btn" disabled={a === null || b === null} onClick={() => onCompare(a!, b!)}>Diff</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Diff viewer ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function DiffViewer({ base, av, bv, title, onClose }: {
|
||||
base: string;
|
||||
av: number;
|
||||
bv: number;
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [rows, setRows] = useState<DiffRow[] | null>(null);
|
||||
const [mode, setMode] = useState<'unified' | 'split'>('split');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
(async () => {
|
||||
try {
|
||||
const [left, right] = await Promise.all([fetchVersionText(base, av), fetchVersionText(base, bv)]);
|
||||
if (alive) setRows(diffLines(left, right));
|
||||
} catch (e) { if (alive) setError(errMsg(e)); }
|
||||
})();
|
||||
return () => { alive = false; };
|
||||
}, [base, av, bv]);
|
||||
|
||||
const stats = rows ? diffStats(rows) : { added: 0, removed: 0 };
|
||||
|
||||
return (
|
||||
<div class="cl-confirm-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
||||
<div class="cl-confirm ver-diff-modal">
|
||||
<div class="ver-diff-head">
|
||||
<span class="cl-confirm-title">Diff — {title}</span>
|
||||
<span class="ver-diff-stats">
|
||||
<span class="ver-diff-add">+{stats.added}</span> <span class="ver-diff-del">−{stats.removed}</span>
|
||||
</span>
|
||||
<span class="ver-diff-modes">
|
||||
<button class={`panel-btn${mode === 'split' ? ' panel-btn-primary' : ''}`} onClick={() => setMode('split')}>Side by side</button>
|
||||
<button class={`panel-btn${mode === 'unified' ? ' panel-btn-primary' : ''}`} onClick={() => setMode('unified')}>Unified</button>
|
||||
</span>
|
||||
<button class="panel-btn" onClick={onClose}>Close</button>
|
||||
</div>
|
||||
{error && <div class="cl-error">{error}</div>}
|
||||
{!rows && !error && <div class="hint ver-diff-loading">Loading…</div>}
|
||||
{rows && (mode === 'unified' ? <UnifiedDiff rows={rows} /> : <SplitDiff rows={rows} />)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UnifiedDiff({ rows }: { rows: DiffRow[] }) {
|
||||
return (
|
||||
<div class="ver-diff ver-diff-unified">
|
||||
{rows.map((r, i) => (
|
||||
<div key={i} class={`ver-diff-row ver-diff-${r.op}`}>
|
||||
<span class="ver-diff-ln">{r.leftNo ?? ''}</span>
|
||||
<span class="ver-diff-ln">{r.rightNo ?? ''}</span>
|
||||
<span class="ver-diff-sign">{r.op === 'add' ? '+' : r.op === 'del' ? '−' : ' '}</span>
|
||||
<span class="ver-diff-text">{r.text || '\u00a0'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SplitDiff({ rows }: { rows: DiffRow[] }) {
|
||||
const pairs = sideBySide(rows);
|
||||
return (
|
||||
<div class="ver-diff ver-diff-split">
|
||||
{pairs.map((p, i) => (
|
||||
<div key={i} class="ver-diff-splitrow">
|
||||
<span class="ver-diff-ln">{p.left?.leftNo ?? ''}</span>
|
||||
<span class={`ver-diff-cell ver-diff-${p.left ? p.left.op : 'empty'}`}>{p.left ? (p.left.text || '\u00a0') : ''}</span>
|
||||
<span class="ver-diff-ln">{p.right?.rightNo ?? ''}</span>
|
||||
<span class={`ver-diff-cell ver-diff-${p.right ? p.right.op : 'empty'}`}>{p.right ? (p.right.text || '\u00a0') : ''}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// Line-based diff (git style) used by the version history diff viewer. A simple
|
||||
// Longest-Common-Subsequence over lines yields a sequence of equal/added/removed
|
||||
// rows that can be rendered as a unified diff or split side-by-side.
|
||||
|
||||
export type DiffOp = 'equal' | 'add' | 'del';
|
||||
|
||||
export interface DiffRow {
|
||||
op: DiffOp;
|
||||
// Line content. For 'equal' both sides are identical (text).
|
||||
text: string;
|
||||
// 1-based line numbers in the left/right document; null when absent on a side.
|
||||
leftNo: number | null;
|
||||
rightNo: number | null;
|
||||
}
|
||||
|
||||
function splitLines(s: string): string[] {
|
||||
// Drop a single trailing newline so a file ending in "\n" doesn't yield a
|
||||
// spurious empty final row.
|
||||
const t = s.endsWith('\n') ? s.slice(0, -1) : s;
|
||||
return t.length === 0 ? [] : t.split('\n');
|
||||
}
|
||||
|
||||
// diffLines computes the LCS table and walks it back into ordered rows.
|
||||
export function diffLines(leftText: string, rightText: string): DiffRow[] {
|
||||
const a = splitLines(leftText);
|
||||
const b = splitLines(rightText);
|
||||
const n = a.length;
|
||||
const m = b.length;
|
||||
|
||||
// lcs[i][j] = length of LCS of a[i:] and b[j:].
|
||||
const lcs: number[][] = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
|
||||
for (let i = n - 1; i >= 0; i--) {
|
||||
for (let j = m - 1; j >= 0; j--) {
|
||||
lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
|
||||
}
|
||||
}
|
||||
|
||||
const rows: DiffRow[] = [];
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
let la = 1;
|
||||
let lb = 1;
|
||||
while (i < n && j < m) {
|
||||
if (a[i] === b[j]) {
|
||||
rows.push({ op: 'equal', text: a[i], leftNo: la++, rightNo: lb++ });
|
||||
i++; j++;
|
||||
} else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
|
||||
rows.push({ op: 'del', text: a[i], leftNo: la++, rightNo: null });
|
||||
i++;
|
||||
} else {
|
||||
rows.push({ op: 'add', text: b[j], leftNo: null, rightNo: lb++ });
|
||||
j++;
|
||||
}
|
||||
}
|
||||
while (i < n) rows.push({ op: 'del', text: a[i++], leftNo: la++, rightNo: null });
|
||||
while (j < m) rows.push({ op: 'add', text: b[j++], leftNo: null, rightNo: lb++ });
|
||||
return rows;
|
||||
}
|
||||
|
||||
export interface DiffStats { added: number; removed: number; }
|
||||
|
||||
export function diffStats(rows: DiffRow[]): DiffStats {
|
||||
let added = 0;
|
||||
let removed = 0;
|
||||
for (const r of rows) {
|
||||
if (r.op === 'add') added++;
|
||||
else if (r.op === 'del') removed++;
|
||||
}
|
||||
return { added, removed };
|
||||
}
|
||||
|
||||
// sideBySide pairs rows into aligned [left, right] columns: a del with the next
|
||||
// add becomes one changed row; otherwise each row occupies one side.
|
||||
export interface SideRow {
|
||||
left: DiffRow | null;
|
||||
right: DiffRow | null;
|
||||
}
|
||||
|
||||
export function sideBySide(rows: DiffRow[]): SideRow[] {
|
||||
const out: SideRow[] = [];
|
||||
for (let k = 0; k < rows.length; k++) {
|
||||
const r = rows[k];
|
||||
if (r.op === 'equal') {
|
||||
out.push({ left: r, right: r });
|
||||
} else if (r.op === 'del') {
|
||||
// Pair consecutive del/add as a single changed line when possible.
|
||||
const next = rows[k + 1];
|
||||
if (next && next.op === 'add') {
|
||||
out.push({ left: r, right: next });
|
||||
k++;
|
||||
} else {
|
||||
out.push({ left: r, right: null });
|
||||
}
|
||||
} else {
|
||||
out.push({ left: null, right: r });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -75,6 +75,88 @@ function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
// Reads a single parameter's value from a config instance, coercing it to a
|
||||
// number. Returns null if the instance/set/param is missing or non-numeric.
|
||||
async function readConfigParam(instanceId: string, key: string): Promise<number | null> {
|
||||
try {
|
||||
const ir = await fetch(`/api/v1/config/instances/${encodeURIComponent(instanceId)}`);
|
||||
if (!ir.ok) return null;
|
||||
const inst: { setId: string; setVersion?: number; values?: Record<string, any> } = await ir.json();
|
||||
const sv = inst.setVersion && inst.setVersion > 0
|
||||
? `/api/v1/config/sets/${encodeURIComponent(inst.setId)}/versions/${inst.setVersion}`
|
||||
: `/api/v1/config/sets/${encodeURIComponent(inst.setId)}`;
|
||||
const sr = await fetch(sv);
|
||||
if (!sr.ok) return null;
|
||||
const set: { parameters?: Array<{ key: string; default?: any }> } = await sr.json();
|
||||
const param = (set.parameters ?? []).find(p => p.key === key);
|
||||
if (!param) return null;
|
||||
const raw = inst.values?.[key] ?? param.default;
|
||||
const n = toNum(raw);
|
||||
return isNaN(n) ? null : n;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Write a single numeric value into a config instance's parameter, creating a
|
||||
// new revision server-side. Reads the current instance, overwrites values[key],
|
||||
// and PUTs it back. Silently no-ops on any transport/parse error.
|
||||
async function writeConfigParam(instanceId: string, key: string, val: number): Promise<void> {
|
||||
try {
|
||||
const ir = await fetch(`/api/v1/config/instances/${encodeURIComponent(instanceId)}`);
|
||||
if (!ir.ok) return;
|
||||
const inst: any = await ir.json();
|
||||
inst.values = { ...(inst.values ?? {}), [key]: val };
|
||||
await fetch(`/api/v1/config/instances/${encodeURIComponent(instanceId)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(inst),
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Create a new config instance for `setId`, optionally seeding its values from
|
||||
// an existing instance `fromId`. Returns the new instance id, or '' on failure.
|
||||
async function createConfigInstance(setId: string, name: string, fromId: string): Promise<string> {
|
||||
try {
|
||||
let values: Record<string, any> = {};
|
||||
if (fromId) {
|
||||
const fr = await fetch(`/api/v1/config/instances/${encodeURIComponent(fromId)}`);
|
||||
if (fr.ok) {
|
||||
const src: any = await fr.json();
|
||||
values = { ...(src.values ?? {}) };
|
||||
}
|
||||
}
|
||||
const r = await fetch('/api/v1/config/instances', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, setId, values }),
|
||||
});
|
||||
if (!r.ok) return '';
|
||||
const out: any = await r.json();
|
||||
return String(out?.id ?? '');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// Snapshot the current live values of every target signal of `setId` into a new
|
||||
// config instance (optional label). Returns the new instance id, or '' on failure.
|
||||
async function snapshotConfigSet(setId: string, name: string): Promise<string> {
|
||||
try {
|
||||
const r = await fetch(`/api/v1/config/sets/${encodeURIComponent(setId)}/snapshot`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
if (!r.ok) return '';
|
||||
const out: any = await r.json();
|
||||
return String(out?.instance?.id ?? '');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function toNum(v: any): number {
|
||||
if (typeof v === 'number') return v;
|
||||
if (typeof v === 'boolean') return v ? 1 : 0;
|
||||
@@ -158,6 +240,20 @@ class LogicEngine {
|
||||
return toNum(this.live.get(refKey(ds, name)));
|
||||
}
|
||||
|
||||
// Resolve the config-instance id a node operates on. With instanceSource
|
||||
// 'var' the id is read (as a raw string) from a panel-local variable / signal
|
||||
// — this is how a config-selector widget feeds the apply/read/write nodes.
|
||||
// Otherwise the fixed `instance` param (an id chosen at design time) is used.
|
||||
private resolveInstanceId(node: LogicNode): string {
|
||||
if ((node.params.instanceSource ?? 'fixed') === 'var') {
|
||||
const r = parseRef(node.params.instanceVar ?? '');
|
||||
if (!r) return '';
|
||||
const v = this.live.get(refKey(r.ds, r.name));
|
||||
return v == null ? '' : String(v).trim();
|
||||
}
|
||||
return (node.params.instance ?? '').trim();
|
||||
}
|
||||
|
||||
/** Activate a panel's logic graph. Replaces any previously loaded graph. */
|
||||
load(graph: LogicGraph | undefined): void {
|
||||
this.clear();
|
||||
@@ -266,6 +362,18 @@ class LogicEngine {
|
||||
case 'action.write':
|
||||
case 'action.accumulate':
|
||||
case 'action.log': collectRefs(node.params.expr ?? '').forEach(want); break;
|
||||
case 'action.config.apply':
|
||||
case 'action.config.read':
|
||||
case 'action.config.write': {
|
||||
// Dynamic-instance nodes read their target instance id from a panel
|
||||
// var; subscribe it so the live cache holds the selected id.
|
||||
if ((node.params.instanceSource ?? 'fixed') === 'var') {
|
||||
const r = parseRef(node.params.instanceVar ?? '');
|
||||
if (r) want(r);
|
||||
}
|
||||
if (node.kind === 'action.config.write') collectRefs(node.params.expr ?? '').forEach(want);
|
||||
break;
|
||||
}
|
||||
case 'action.dialog.info':
|
||||
case 'action.dialog.error':
|
||||
case 'action.dialog.setpoint': {
|
||||
@@ -390,6 +498,65 @@ class LogicEngine {
|
||||
return;
|
||||
}
|
||||
|
||||
case 'action.config.apply': {
|
||||
const id = this.resolveInstanceId(node);
|
||||
if (id) {
|
||||
try {
|
||||
await fetch(`/api/v1/config/instances/${encodeURIComponent(id)}/apply`, { method: 'POST' });
|
||||
} catch {}
|
||||
}
|
||||
await this.follow(node.id, 'out', ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
case 'action.config.read': {
|
||||
const id = this.resolveInstanceId(node);
|
||||
const key = (node.params.key ?? '').trim();
|
||||
const ref = parseRef(node.params.target ?? '');
|
||||
if (id && key && ref) {
|
||||
const val = await readConfigParam(id, key);
|
||||
if (val !== null && !isNaN(val)) wsClient.write(ref, val);
|
||||
}
|
||||
await this.follow(node.id, 'out', ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
case 'action.config.write': {
|
||||
const id = this.resolveInstanceId(node);
|
||||
const key = (node.params.key ?? '').trim();
|
||||
const val = evalExpr(node.params.expr ?? '', ctx.resolve);
|
||||
if (id && key && !isNaN(val)) {
|
||||
await writeConfigParam(id, key, val);
|
||||
}
|
||||
await this.follow(node.id, 'out', ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
case 'action.config.create': {
|
||||
const setId = (node.params.set ?? '').trim();
|
||||
const name = (node.params.name ?? '').trim() || 'auto';
|
||||
const fromId = (node.params.from ?? '').trim();
|
||||
const target = parseRef(node.params.target ?? '');
|
||||
if (setId) {
|
||||
const newId = await createConfigInstance(setId, name, fromId);
|
||||
if (newId && target) wsClient.write(target, newId);
|
||||
}
|
||||
await this.follow(node.id, 'out', ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
case 'action.config.snapshot': {
|
||||
const setId = (node.params.set ?? '').trim();
|
||||
const name = (node.params.name ?? '').trim();
|
||||
const target = parseRef(node.params.target ?? '');
|
||||
if (setId) {
|
||||
const newId = await snapshotConfigSet(setId, name);
|
||||
if (newId && target) wsClient.write(target, newId);
|
||||
}
|
||||
await this.follow(node.id, 'out', ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
case 'action.delay':
|
||||
await sleep(Math.max(0, parseInt(node.params.ms ?? '0', 10) || 0));
|
||||
await this.follow(node.id, 'out', ctx);
|
||||
|
||||
@@ -152,7 +152,12 @@ export type LogicNodeKind =
|
||||
| 'action.widget'
|
||||
| 'action.dialog.info'
|
||||
| 'action.dialog.error'
|
||||
| 'action.dialog.setpoint';
|
||||
| 'action.dialog.setpoint'
|
||||
| 'action.config.apply'
|
||||
| 'action.config.read'
|
||||
| 'action.config.write'
|
||||
| 'action.config.create'
|
||||
| 'action.config.snapshot';
|
||||
|
||||
// A block on the flow canvas. `params` holds kind-specific fields as strings.
|
||||
export interface LogicNode {
|
||||
|
||||
@@ -888,6 +888,46 @@ body {
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
|
||||
/* Config selector widget — a labelled combo of a config set's instances whose
|
||||
choice is written to a panel-local variable. */
|
||||
.configselect {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 8px;
|
||||
background: #1a1f2e;
|
||||
border: 1px solid #2d3748;
|
||||
border-radius: 6px;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
font-family: system-ui, sans-serif;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cs-label {
|
||||
color: #94a3b8;
|
||||
font-size: 0.8rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cs-select {
|
||||
flex: 1;
|
||||
width: auto;
|
||||
background: #0f1117;
|
||||
border: 1px solid #3d4f6e;
|
||||
border-radius: 4px;
|
||||
color: #e2e8f0;
|
||||
font-size: 0.85rem;
|
||||
padding: 2px 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cs-select:focus {
|
||||
outline: none;
|
||||
border-color: #4a9eff;
|
||||
}
|
||||
|
||||
.sv-btn:hover {
|
||||
background: #1d4ed8;
|
||||
}
|
||||
@@ -2210,6 +2250,153 @@ body {
|
||||
.lua-cmt { color: #546e7a; font-style: italic; }
|
||||
.lua-num { color: #f78c6c; }
|
||||
|
||||
/* ── CUE editor (config rules) ───────────────────────────────────────────────── */
|
||||
.cue-editor {
|
||||
position: relative;
|
||||
border: 1px solid #3d4f6e;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background: #0a0d14;
|
||||
font-family: ui-monospace, 'Cascadia Code', 'Fira Code', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
min-height: 6em;
|
||||
}
|
||||
|
||||
.cue-pre,
|
||||
.cue-textarea {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
margin: 0;
|
||||
padding: 6px 8px;
|
||||
border: none;
|
||||
outline: none;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
tab-size: 2;
|
||||
font: inherit;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.cue-pre {
|
||||
color: #e2e8f0;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cue-textarea {
|
||||
color: transparent;
|
||||
caret-color: #e2e8f0;
|
||||
background: transparent;
|
||||
resize: none;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.cue-textarea:focus {
|
||||
outline: none;
|
||||
box-shadow: inset 0 0 0 1px #4a9eff;
|
||||
}
|
||||
|
||||
.cue-kw { color: #c792ea; font-weight: 600; }
|
||||
.cue-type { color: #82aaff; }
|
||||
.cue-attr { color: #ffcb6b; }
|
||||
.cue-str { color: #c3e88d; }
|
||||
.cue-cmt { color: #546e7a; font-style: italic; }
|
||||
.cue-num { color: #f78c6c; }
|
||||
|
||||
.cue-ac {
|
||||
position: fixed;
|
||||
z-index: 2000;
|
||||
margin: 0;
|
||||
padding: 2px;
|
||||
list-style: none;
|
||||
background: #131826;
|
||||
border: 1px solid #3d4f6e;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||||
max-height: 14em;
|
||||
overflow: auto;
|
||||
font-family: ui-monospace, 'Cascadia Code', 'Fira Code', monospace;
|
||||
font-size: 12px;
|
||||
min-width: 8em;
|
||||
}
|
||||
|
||||
.cue-ac-item {
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
color: #cdd6e4;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cue-ac-item.active,
|
||||
.cue-ac-item:hover {
|
||||
background: #2a4d7a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ── Config rules ────────────────────────────────────────────────────────────── */
|
||||
.cfg-rule-set {
|
||||
font-size: 11px;
|
||||
opacity: 0.75;
|
||||
margin-left: auto;
|
||||
margin-right: 6px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 9em;
|
||||
}
|
||||
|
||||
.cfg-rule-help {
|
||||
margin: 4px 0 6px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.cfg-rule-help code {
|
||||
background: #111726;
|
||||
border: 1px solid #2a3550;
|
||||
border-radius: 3px;
|
||||
padding: 0 3px;
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.cfg-rule-check {
|
||||
margin-top: 8px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.cfg-rule-check-ok {
|
||||
background: #11271a;
|
||||
border: 1px solid #2e6b46;
|
||||
color: #b6f0c8;
|
||||
}
|
||||
.cfg-rule-check-err {
|
||||
background: #2a1416;
|
||||
border: 1px solid #7a3038;
|
||||
color: #ffc7cd;
|
||||
}
|
||||
.cfg-rule-violations {
|
||||
margin: 4px 0 0;
|
||||
padding-left: 18px;
|
||||
}
|
||||
.cfg-rule-violations code,
|
||||
.cfg-rule-derived code {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 3px;
|
||||
padding: 0 3px;
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
.cfg-rule-derived {
|
||||
display: inline-block;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
/* wizard is already wide enough and resizable — no extra :has() rule needed */
|
||||
|
||||
/* ── Synthetic pipeline ─────────────────────────────────────────────────────── */
|
||||
@@ -4155,7 +4342,13 @@ kbd {
|
||||
}
|
||||
.cfg-req { color: #f87171; margin-left: 0.15rem; }
|
||||
.cfg-value-input { width: 10rem; flex: 0 0 auto; }
|
||||
/* Constrain the editing control to its column so it can't overflow and cover
|
||||
the adjacent signal-name label. */
|
||||
.cfg-value-input .prop-input,
|
||||
.cfg-value-input .prop-select { width: 100%; box-sizing: border-box; }
|
||||
.cfg-value-target {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
font-size: 0.72rem;
|
||||
color: #64748b;
|
||||
overflow: hidden;
|
||||
@@ -4287,6 +4480,200 @@ kbd {
|
||||
.cfg-diff-changed .cfg-diff-status { color: #facc15; }
|
||||
.cfg-diff-unchanged .cfg-diff-status { color: #64748b; }
|
||||
|
||||
/* ── Version history tree (slick git-style pane) ───────────────────────────── */
|
||||
.ver-tree {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.ver-empty { padding: 0.4rem 0; }
|
||||
.ver-node {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0.6rem;
|
||||
min-height: 2rem;
|
||||
}
|
||||
/* The rail holds the circle and the connector line to the next node below. */
|
||||
.ver-rail {
|
||||
position: relative;
|
||||
flex: 0 0 16px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
.ver-dot {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
margin-top: 0.45rem;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #475569;
|
||||
background: #1a1f2e;
|
||||
box-sizing: border-box;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
/* Selected (the revision that is executed/shown) → filled. */
|
||||
.ver-dot-current { background: #2563eb; border-color: #2563eb; }
|
||||
/* Active (the revision currently viewed/edited) → larger. */
|
||||
.ver-dot-active {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-top: 0.3rem;
|
||||
border-color: #60a5fa;
|
||||
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.18);
|
||||
}
|
||||
.ver-dot-unsaved { background: transparent; border-style: dashed; border-color: #f59e0b; }
|
||||
.ver-line {
|
||||
position: absolute;
|
||||
top: 0.45rem;
|
||||
bottom: -0.55rem;
|
||||
width: 2px;
|
||||
background: #2d3748;
|
||||
z-index: 0;
|
||||
}
|
||||
.ver-line-dashed {
|
||||
background: repeating-linear-gradient(to bottom, #f59e0b 0, #f59e0b 3px, transparent 3px, transparent 6px);
|
||||
}
|
||||
.ver-body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
flex: 1;
|
||||
padding: 0.3rem 0;
|
||||
border-bottom: 1px solid #1a2030;
|
||||
min-width: 0;
|
||||
}
|
||||
.ver-node-active .ver-body { background: rgba(96, 165, 250, 0.05); border-radius: 4px; }
|
||||
.ver-num {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #cbd5e1;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
min-width: 2.6rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0;
|
||||
}
|
||||
.ver-num:hover { color: #fff; }
|
||||
.ver-badge {
|
||||
font-size: 0.62rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
border-radius: 3px;
|
||||
padding: 0.05rem 0.3rem;
|
||||
}
|
||||
.ver-badge-view { background: #475569; }
|
||||
.ver-tag { color: #94a3b8; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ver-time { color: #64748b; font-size: 0.7rem; white-space: nowrap; }
|
||||
.ver-actions { display: flex; gap: 0.3rem; flex: 0 0 auto; }
|
||||
.ver-unsaved .ver-num, .ver-node-unsaved .ver-num { color: #f59e0b; font-weight: 500; font-style: italic; }
|
||||
.ver-compare {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin-top: 0.5rem;
|
||||
padding-top: 0.45rem;
|
||||
border-top: 1px solid #2d3748;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
/* ── Version pane wrappers (embedded sidebars in the editors) ──────────────── */
|
||||
/* Control-logic editor: FlowEditor + history sidebar side by side. */
|
||||
.cl-editor-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.cl-editor-main .flow-editor { flex: 1; min-width: 0; }
|
||||
.ver-pane {
|
||||
flex: 0 0 20rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
padding: 0.75rem 0.9rem;
|
||||
border-left: 1px solid #2d3748;
|
||||
background: #11151f;
|
||||
}
|
||||
.ver-pane-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-weight: 600;
|
||||
color: #cbd5e1;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
/* Synthetic editor: history floats over the right edge of the modal. */
|
||||
.synth-ver-pane {
|
||||
position: absolute;
|
||||
top: 3rem;
|
||||
right: 0;
|
||||
bottom: 3.2rem;
|
||||
z-index: 5;
|
||||
box-shadow: -6px 0 18px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
/* ── Version diff viewer (unified / side-by-side text diff) ────────────────── */
|
||||
.ver-diff-modal {
|
||||
background: #1a1f2e;
|
||||
border: 1px solid #2d3748;
|
||||
border-radius: 10px;
|
||||
width: min(1100px, 96vw);
|
||||
max-height: 88vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
.ver-diff-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
padding: 0.6rem 1rem;
|
||||
border-bottom: 1px solid #2d3748;
|
||||
font-size: 0.85rem;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.ver-diff-head .cl-confirm-title { flex: 1; }
|
||||
.ver-diff-stats { font-family: ui-monospace, monospace; font-size: 0.78rem; }
|
||||
.ver-diff-add { color: #4ade80; }
|
||||
.ver-diff-del { color: #f87171; }
|
||||
.ver-diff-modes { display: flex; gap: 0.3rem; }
|
||||
.ver-diff-loading { padding: 1rem; }
|
||||
.ver-diff {
|
||||
overflow: auto;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 0.74rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.ver-diff-row { display: flex; white-space: pre; }
|
||||
.ver-diff-ln {
|
||||
flex: 0 0 3rem;
|
||||
text-align: right;
|
||||
padding: 0 0.5rem;
|
||||
color: #475569;
|
||||
user-select: none;
|
||||
background: #151a27;
|
||||
}
|
||||
.ver-diff-sign { flex: 0 0 1.2rem; text-align: center; color: #64748b; }
|
||||
.ver-diff-text { flex: 1; padding-right: 1rem; }
|
||||
.ver-diff-add { background: rgba(34, 197, 94, 0.12); }
|
||||
.ver-diff-del { background: rgba(239, 68, 68, 0.12); }
|
||||
.ver-diff-equal { color: #94a3b8; }
|
||||
.ver-diff-row.ver-diff-add .ver-diff-text { color: #bbf7d0; }
|
||||
.ver-diff-row.ver-diff-del .ver-diff-text { color: #fecaca; }
|
||||
/* Side-by-side grid */
|
||||
.ver-diff-splitrow { display: grid; grid-template-columns: 3rem 1fr 3rem 1fr; white-space: pre; }
|
||||
.ver-diff-cell { padding: 0 0.6rem; min-width: 0; overflow-x: hidden; }
|
||||
.ver-diff-cell.ver-diff-add { background: rgba(34, 197, 94, 0.12); color: #bbf7d0; }
|
||||
.ver-diff-cell.ver-diff-del { background: rgba(239, 68, 68, 0.12); color: #fecaca; }
|
||||
.ver-diff-cell.ver-diff-equal { color: #94a3b8; }
|
||||
.ver-diff-cell.ver-diff-empty { background: #141824; }
|
||||
|
||||
/* ── Config manager: array value editor ────────────────────────────────── */
|
||||
.cfg-value-input-array { width: auto; flex: 1; }
|
||||
.cfg-array { display: flex; flex-direction: column; gap: 0.35rem; }
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import { wsClient } from '../lib/ws';
|
||||
import { getSignalStore } from '../lib/stores';
|
||||
import type { Widget, SignalValue } from '../lib/types';
|
||||
|
||||
interface InstanceMeta { id: string; name: string; setId?: string; }
|
||||
|
||||
interface Props { widget: Widget; onContextMenu?: (e: MouseEvent) => void; }
|
||||
|
||||
// A combo that lists the config instances of a chosen set and writes the
|
||||
// selected instance id (a string) to a panel-local variable. The output var can
|
||||
// then drive the apply/read/write config logic nodes (set to "From variable"),
|
||||
// letting an operator pick which configuration the flow operates on at run time.
|
||||
export default function ConfigSelect({ widget, onContextMenu }: Props) {
|
||||
const setId = widget.options['set'] || '';
|
||||
const output = widget.options['output'] || '';
|
||||
const label = widget.options['label'] || 'Config';
|
||||
// Optional comma-separated allow-list of instance ids; empty shows them all.
|
||||
const subset = (widget.options['instances'] || '')
|
||||
.split(',').map(s => s.trim()).filter(Boolean);
|
||||
|
||||
const [instances, setInstances] = useState<InstanceMeta[]>([]);
|
||||
const [selected, setSelected] = useState('');
|
||||
|
||||
// Mirror the output var so external writers (e.g. a create-config node) keep
|
||||
// the combo in sync.
|
||||
useEffect(() => {
|
||||
if (!output) return;
|
||||
const unsub = getSignalStore({ ds: 'local', name: output }).subscribe((v: SignalValue) => {
|
||||
const id = v.value == null ? '' : String(v.value);
|
||||
setSelected(prev => (id && id !== prev ? id : prev));
|
||||
});
|
||||
return unsub;
|
||||
}, [output]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetch('/api/v1/config/instances')
|
||||
.then(r => r.ok ? r.json() : [])
|
||||
.then((list: InstanceMeta[]) => {
|
||||
if (cancelled) return;
|
||||
let filtered = (list ?? []).filter(ci => !setId || ci.setId === setId);
|
||||
if (subset.length > 0) filtered = filtered.filter(ci => subset.includes(ci.id));
|
||||
setInstances(filtered);
|
||||
// Default the output to the first instance when nothing is chosen yet.
|
||||
setSelected(prev => {
|
||||
if (prev && filtered.some(ci => ci.id === prev)) return prev;
|
||||
const first = filtered[0]?.id ?? '';
|
||||
if (first && output) wsClient.write({ ds: 'local', name: output }, first);
|
||||
return first;
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
}, [setId, widget.options['instances']]);
|
||||
|
||||
function onChange(id: string) {
|
||||
setSelected(id);
|
||||
if (output) wsClient.write({ ds: 'local', name: output }, id);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
class="configselect"
|
||||
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
|
||||
onContextMenu={onContextMenu}
|
||||
>
|
||||
<span class="cs-label">{label}:</span>
|
||||
<select
|
||||
class="cs-select"
|
||||
value={selected}
|
||||
onChange={(e: Event) => onChange((e.target as HTMLSelectElement).value)}
|
||||
>
|
||||
{instances.length === 0 && <option value="">no instances</option>}
|
||||
{instances.map(ci => <option key={ci.id} value={ci.id}>{ci.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user