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
+105
View File
@@ -26,9 +26,26 @@
import { wsClient } from './ws';
import { getSignalStore } from './stores';
import { writable } from './store';
import type { SignalRef, SignalValue, LogicGraph, LogicNode } from './types';
import { evalExpr, evalBool, collectRefs, type Resolver } from './expr';
// A pending user-interaction dialog requested by an action.dialog.* node. The
// engine pushes one to `logicDialogs` and awaits the user's response; the
// LogicDialogs component renders it and calls `resolve` (the entered value for
// a set-point dialog, '' for info/error OK, or null for Cancel).
export interface DialogRequest {
id: string;
kind: 'info' | 'error' | 'setpoint';
title: string;
message: string;
defaultValue?: string;
resolve: (result: string | null) => void;
}
// Active dialogs, rendered by <LogicDialogs/>. Cleared when the panel unmounts.
export const logicDialogs = writable<DialogRequest[]>([]);
// Guards against runaway flows (cycles / pathological loops).
const MAX_STEPS = 100000;
const MAX_LOOP = 100000;
@@ -132,10 +149,26 @@ class LogicEngine {
// button/threshold/change are driven imperatively / by subscriptions.
}
}
// Panel-open lifecycle: fire every "On open" trigger once now that the
// graph is wired and subscriptions are live.
for (const node of this.graph.nodes) {
if (node.kind === 'trigger.start') this.activate(node.id);
}
}
/** Deactivate all triggers and subscriptions. Called when a panel unmounts. */
clear(): void {
// Panel-close lifecycle: fire every "On close" trigger before tearing down,
// while the live cache and wiring are still intact (synchronous writes run
// immediately; anything past an await is truncated as the graph resets).
for (const node of this.graph.nodes) {
if (node.kind === 'trigger.stop') this.activate(node.id);
}
// Dismiss any dialogs left open by the panel being closed (resolve null).
for (const d of logicDialogs.get()) { try { d.resolve(null); } catch {} }
logicDialogs.set([]);
for (const c of this.cleanups) { try { c(); } catch {} }
this.cleanups = [];
this.graph = { nodes: [], wires: [] };
@@ -188,6 +221,14 @@ class LogicEngine {
case 'action.write':
case 'action.accumulate':
case 'action.log': collectRefs(node.params.expr ?? '').forEach(want); break;
case 'action.dialog.info':
case 'action.dialog.error':
this.messageRefs(node.params.message ?? '').forEach(want); break;
case 'action.dialog.setpoint':
this.messageRefs(node.params.message ?? '').forEach(want);
this.messageRefs(node.params.title ?? '').forEach(want);
collectRefs(node.params.default ?? '').forEach(want);
break;
}
}
@@ -338,6 +379,36 @@ class LogicEngine {
return;
}
case 'action.dialog.info':
case 'action.dialog.error': {
await this.showDialog({
kind: node.kind === 'action.dialog.error' ? 'error' : 'info',
title: this.interpolate(node.params.title ?? '', ctx.resolve),
message: this.interpolate(node.params.message ?? '', ctx.resolve),
});
await this.follow(node.id, 'out', ctx);
return;
}
case 'action.dialog.setpoint': {
const ref = parseRef(node.params.target ?? '');
const def = (node.params.default ?? '').trim()
? String(evalExpr(node.params.default ?? '', ctx.resolve))
: '';
const result = await this.showDialog({
kind: 'setpoint',
title: this.interpolate(node.params.title ?? '', ctx.resolve),
message: this.interpolate(node.params.message ?? '', ctx.resolve),
defaultValue: def,
});
// Cancel (null) aborts the flow; OK writes the value and continues.
if (result === null) return;
const num = parseFloat(result);
if (ref && !isNaN(num)) wsClient.write(ref, num);
await this.follow(node.id, 'out', ctx);
return;
}
default:
// A trigger reached mid-walk (unusual): just pass through.
await this.follow(node.id, 'out', ctx);
@@ -361,6 +432,40 @@ class LogicEngine {
URL.revokeObjectURL(url);
}
// ── Dialogs (user interaction) ───────────────────────────────────────────────
// Push a dialog request to the shared store and resolve when the user responds
// ('' on info/error OK, the entered text on a set-point OK, null on Cancel).
private showDialog(req: Omit<DialogRequest, 'id' | 'resolve'>): Promise<string | null> {
return new Promise((resolve) => {
const id = 'dlg_' + Math.random().toString(36).slice(2, 9);
const full: DialogRequest = {
...req,
id,
resolve: (r) => { logicDialogs.update(list => list.filter(d => d.id !== id)); resolve(r); },
};
logicDialogs.update(list => [...list, full]);
});
}
// Substitute {ds:name} (and {sys:*}) tokens in dialog text with live values.
private interpolate(text: string, resolve: Resolver): string {
return (text ?? '').replace(/\{([^}]*)\}/g, (_m, inner) => {
const v = evalExpr('{' + inner + '}', resolve);
return isNaN(v) ? '?' : String(v);
});
}
// Signals referenced by {…} tokens in dialog message/title text.
private messageRefs(text: string): SignalRef[] {
const out: SignalRef[] = [];
for (const m of (text ?? '').matchAll(/\{([^}]*)\}/g)) {
const r = parseRef(m[1]);
if (r) out.push(r);
}
return out;
}
// An AND-gate fires when every incoming trigger is currently satisfied: the
// activating trigger counts as satisfied, and level triggers (threshold) use
// their current truth. Momentary triggers that are not firing now are false.