Add control logic engine, panel logic dialogs, logic-edit restriction

Introduce server-side control-logic flow graphs (cron/alarm triggers,
Lua blocks) with CRUD endpoints, panel-logic lifecycle triggers and
user-interaction dialog nodes, and a synthetic node-graph editor.

Add an optional logic-editor allowlist (server.logic_editors) gating who
may add/edit panel logic and control logic, surfaced via /api/v1/me and
enforced in the API; hide logic affordances in the UI accordingly.

Update README, example config, and functional/technical specs to cover
all current features (plot panels, panel/control logic, local variables,
access control) and refresh the in-app manual and contextual help.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Martino Ferrari
2026-06-19 07:27:35 +02:00
parent aba394b84d
commit afefba3184
35 changed files with 4633 additions and 467 deletions
+79 -1
View File
@@ -48,6 +48,8 @@ const PALETTE: PaletteEntry[] = [
{ kind: 'trigger.change', label: 'On change', params: { signal: '' } },
{ kind: 'trigger.timer', label: 'Timer', params: { interval: '1000' } },
{ kind: 'trigger.loop', label: 'Panel loop', params: { interval: '1000' } },
{ kind: 'trigger.start', label: 'On open', params: {} },
{ kind: 'trigger.stop', label: 'On close', params: {} },
{ kind: 'gate.and', label: 'AND gate', params: {} },
{ kind: 'flow.if', label: 'If / else', params: { cond: '' } },
{ kind: 'flow.loop', label: 'Loop', params: { mode: 'count', count: '3', cond: '' } },
@@ -57,6 +59,9 @@ const PALETTE: PaletteEntry[] = [
{ kind: 'action.export', label: 'Export CSV', params: { array: 'data', filename: '' } },
{ kind: 'action.clear', label: 'Clear array', params: { array: 'data' } },
{ kind: 'action.log', label: 'Log', params: { expr: '', label: '' } },
{ 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: { target: '', title: 'Set value', message: '', default: '' } },
];
const KIND_LABEL: Record<LogicNodeKind, string> = {
@@ -65,6 +70,8 @@ const KIND_LABEL: Record<LogicNodeKind, string> = {
'trigger.change': 'On change',
'trigger.timer': 'Timer',
'trigger.loop': 'Panel loop',
'trigger.start': 'On open',
'trigger.stop': 'On close',
'gate.and': 'AND gate',
'flow.if': 'If / else',
'flow.loop': 'Loop',
@@ -74,6 +81,9 @@ const KIND_LABEL: Record<LogicNodeKind, string> = {
'action.export': 'Export CSV',
'action.clear': 'Clear array',
'action.log': 'Log',
'action.dialog.info': 'Info dialog',
'action.dialog.error': 'Error dialog',
'action.dialog.setpoint': 'Set-point dialog',
};
const THRESHOLD_OPS = ['>', '<', '>=', '<=', '==', '!='] as const;
@@ -172,7 +182,9 @@ export default function LogicEditor({ graph, onChange, statevars, onStateVarsCha
if (selected?.kind === 'trigger.threshold' || selected?.kind === 'trigger.change') {
loadSignals(splitRef(selected.params.signal ?? '').ds);
}
if (selected?.kind === 'action.write') loadSignals(splitRef(selected.params.target ?? '').ds);
if (selected?.kind === 'action.write' || selected?.kind === 'action.dialog.setpoint') {
loadSignals(splitRef(selected.params.target ?? '').ds);
}
}, [selectedNode, selected?.kind]);
// ── History ────────────────────────────────────────────────────────────────
@@ -675,6 +687,67 @@ export default function LogicEditor({ graph, onChange, statevars, onStateVarsCha
</Fragment>
)}
{(selected.kind === 'trigger.start' || selected.kind === 'trigger.stop') && (
<p class="hint">{selected.kind === 'trigger.start'
? 'Fires once when the panel is opened — use it to initialise state or greet the operator.'
: 'Fires once when the panel is closed — use it to write a safe value or log a closing message. Only immediate (no-delay) actions are guaranteed to run.'}</p>
)}
{(selected.kind === 'action.dialog.info' || selected.kind === 'action.dialog.error') && (
<Fragment>
<div class="wizard-field">
<label>Title</label>
<input class="prop-input" value={selected.params.title ?? ''}
placeholder={selected.kind === 'action.dialog.error' ? 'Error' : 'Info'}
onInput={(e) => patchParams(selected.id, { title: (e.target as HTMLInputElement).value })} />
</div>
<div class="wizard-field">
<label>Message</label>
<textarea class="prop-input" rows={4} value={selected.params.message ?? ''}
placeholder="Shown to the operator. Embed values as {ds:name}."
onInput={(e) => patchParams(selected.id, { message: (e.target as HTMLTextAreaElement).value })} />
<p class="hint">Waits for the operator to dismiss the dialog before continuing.
Embed live values with <code>{'{ds:name}'}</code> (e.g. Level is {'{stub:level}'}).</p>
</div>
</Fragment>
)}
{selected.kind === 'action.dialog.setpoint' && (() => {
const { ds, name } = splitRef(selected.params.target ?? '');
return (
<Fragment>
<div class="wizard-field">
<label>Target data source</label>
<SearchableSelect value={ds} options={dsOptions}
onSelect={(newDs) => { loadSignals(newDs); patchParams(selected.id, { target: `${newDs}:` }); }} />
</div>
<div class="wizard-field">
<label>Target signal</label>
<SearchableSelect value={name} options={signalOptions(ds)}
onSelect={(sig) => patchParams(selected.id, { target: `${ds}:${sig}` })}
placeholder={ds ? 'Search signals…' : 'Select data source first'} />
</div>
<div class="wizard-field">
<label>Title</label>
<input class="prop-input" value={selected.params.title ?? ''}
placeholder="Set value"
onInput={(e) => patchParams(selected.id, { title: (e.target as HTMLInputElement).value })} />
</div>
<div class="wizard-field">
<label>Message</label>
<textarea class="prop-input" rows={3} value={selected.params.message ?? ''}
placeholder="Prompt shown above the input. Embed values as {ds:name}."
onInput={(e) => patchParams(selected.id, { message: (e.target as HTMLTextAreaElement).value })} />
</div>
<ExprField label="Default value (expression)" value={selected.params.default ?? ''}
onChange={(v) => patchParams(selected.id, { default: v })}
onInsert={(ds2, sig) => insertRef(selected.id, 'default', ds2, sig)}
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals}
hint="Pre-fills the input (e.g. the current set-point {local:sp}). The operator can edit it; OK writes the entered number to the target, Cancel aborts the flow." />
</Fragment>
);
})()}
<button class="panel-btn" style="margin-top:1rem;" onClick={() => deleteNode(selected.id)}>Delete node</button>
</Fragment>
)}
@@ -736,6 +809,8 @@ function nodeSummary(n: LogicNode): string {
case 'trigger.change': return `Δ ${n.params.signal || '?'}`;
case 'trigger.timer': return `every ${n.params.interval || '?'} ms`;
case 'trigger.loop': return `load + every ${n.params.interval || '?'} ms`;
case 'trigger.start': return 'on panel open';
case 'trigger.stop': return 'on panel close';
case 'gate.and': return 'all inputs';
case 'flow.if': return n.params.cond || '(no condition)';
case 'flow.loop': return (n.params.mode ?? 'count') === 'count'
@@ -747,6 +822,9 @@ function nodeSummary(n: LogicNode): string {
case 'action.export': return `export ${n.params.array || '?'} → csv`;
case 'action.clear': return `clear ${n.params.array || '?'}`;
case 'action.log': return `log ${n.params.label ? n.params.label + ': ' : ''}${n.params.expr || ''}`;
case 'action.dialog.info': return `info: ${n.params.title || n.params.message || '…'}`;
case 'action.dialog.error': return `error: ${n.params.title || n.params.message || '…'}`;
case 'action.dialog.setpoint': return `set ${n.params.target || '?'} (ask)`;
default: return '';
}
}