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