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:
+79
-1
@@ -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 '';
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user