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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user