Implemented confi snapshots

This commit is contained in:
Martino Ferrari
2026-06-21 23:50:03 +02:00
parent 04d31a15c4
commit 113e5a0fe8
51 changed files with 4921 additions and 241 deletions
+2
View File
@@ -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
View File
@@ -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: &gt;=0 &amp; &lt;=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); }
+194 -4
View File
@@ -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>
)}
+259
View File
@@ -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>
);
}
+1
View File
@@ -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
View File
@@ -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)} />
)}
+167
View File
@@ -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">
+44
View File
@@ -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>
)}
+72
View File
@@ -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>}
+227
View File
@@ -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>
);
}
+99
View File
@@ -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;
}
+167
View File
@@ -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);
+6 -1
View File
@@ -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 {
+387
View File
@@ -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; }
+80
View File
@@ -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>
);
}