Implemented admin pane + user permission

This commit is contained in:
Martino Ferrari
2026-06-22 17:49:14 +02:00
parent 73fcbe7b28
commit ac24011487
67 changed files with 5925 additions and 411 deletions
+71 -18
View File
@@ -204,7 +204,7 @@ interface WireOut { to: string; port: string }
// expression resolver that exposes the system signals {sys:time}/{sys:dt}.
interface RunCtx { firedTrigger: string; steps: { n: number }; dt: number; resolve: Resolver }
class LogicEngine {
export class LogicEngine {
private graph: LogicGraph = { nodes: [], wires: [] };
private byId = new Map<string, LogicNode>();
// Outgoing wires grouped by source node id.
@@ -227,6 +227,37 @@ class LogicEngine {
private lastFire = new Map<string, number>();
private cleanups: Array<() => void> = [];
// Dry-run / debug mode: when true the engine performs no external side effects
// (signal writes, config mutations, CSV exports, widget commands, dialogs) and
// instead records each node's last value/firing time so an editor overlay can
// visualise the flow without touching production. Used by a throwaway engine
// instance spun up by the Logic editor; the view-mode singleton leaves it off.
private dryRun = false;
private debugStates = new Map<string, { value: any; ts: number }>();
/** Enable/disable dry-run mode. Must be set before load(). */
setDryRun(on: boolean): void { this.dryRun = on; }
// Record that a node fired this tick, optionally capturing its computed value.
private markActive(id: string, value?: any): void {
if (!this.dryRun) return;
const prev = this.debugStates.get(id);
this.debugStates.set(id, { value: value !== undefined ? value : prev?.value, ts: Date.now() });
}
/** Per-node debug snapshot: a node is "active" if it fired within ~0.8 s. */
getDebug(): Map<string, { value: any; active: boolean }> {
const now = Date.now();
const out = new Map<string, { value: any; active: boolean }>();
for (const [id, s] of this.debugStates) out.set(id, { value: s.value, active: now - s.ts < 800 });
return out;
}
// Suppress signal writes in dry-run; otherwise forward to the live client.
private dryWrite(ref: SignalRef, val: any): void {
if (!this.dryRun) wsClient.write(ref, val);
}
// Resolve an expression reference. Two built-in system signals are served
// synthetically (never subscribed): {sys:time} = current epoch seconds,
// {sys:dt} = seconds since the firing trigger last fired. Everything else
@@ -320,9 +351,11 @@ class LogicEngine {
this.watchers = new Map();
this.arrays = new Map();
this.lastFire = new Map();
this.debugStates = new Map();
// Restore every widget to its default state so reopening a panel (whose
// widget ids are stable) does not inherit disabled/hidden/paused flags.
resetWidgetCmds();
// widget ids are stable) does not inherit disabled/hidden/paused flags. The
// dry-run editor engine must not touch the real widget command stores.
if (!this.dryRun) resetWidgetCmds();
}
/** Fire every button-trigger node whose `name` param matches. */
@@ -435,12 +468,22 @@ class LogicEngine {
// ── Execution ───────────────────────────────────────────────────────────────
/** Manually fire a trigger node (debug mode: double-click to force a flow
* run). No-op if the id isn't a trigger node. */
fireTrigger(nodeId: string): boolean {
const node = this.byId.get(nodeId);
if (!node || !node.kind.startsWith('trigger.')) return false;
this.activate(nodeId);
return true;
}
// A trigger activated: walk its outgoing 'out' wires.
private activate(triggerId: string): void {
const now = Date.now();
const last = this.lastFire.get(triggerId);
const dt = last === undefined ? 0 : (now - last) / 1000;
this.lastFire.set(triggerId, now);
this.markActive(triggerId);
const ctx: RunCtx = {
firedTrigger: triggerId,
steps: { n: 0 },
@@ -462,15 +505,20 @@ class LogicEngine {
if (ctx.steps.n++ > MAX_STEPS) return;
const node = this.byId.get(nodeId);
if (!node) return;
this.markActive(node.id);
switch (node.kind) {
case 'gate.and':
if (this.gateSatisfied(node.id, ctx.firedTrigger)) await this.follow(node.id, 'out', ctx);
case 'gate.and': {
const ok = this.gateSatisfied(node.id, ctx.firedTrigger);
this.markActive(node.id, ok);
if (ok) await this.follow(node.id, 'out', ctx);
return;
}
case 'flow.if': {
const branch = evalBool(node.params.cond ?? '', ctx.resolve) ? 'then' : 'else';
await this.follow(node.id, branch, ctx);
const pass = evalBool(node.params.cond ?? '', ctx.resolve);
this.markActive(node.id, pass);
await this.follow(node.id, pass ? 'then' : 'else', ctx);
return;
}
@@ -493,14 +541,15 @@ class LogicEngine {
case 'action.write': {
const ref = parseRef(node.params.target ?? '');
const val = evalExpr(node.params.expr ?? '', ctx.resolve);
if (ref && !isNaN(val)) wsClient.write(ref, val);
this.markActive(node.id, val);
if (ref && !isNaN(val)) this.dryWrite(ref, val);
await this.follow(node.id, 'out', ctx);
return;
}
case 'action.config.apply': {
const id = this.resolveInstanceId(node);
if (id) {
if (id && !this.dryRun) {
try {
await fetch(`/api/v1/config/instances/${encodeURIComponent(id)}/apply`, { method: 'POST' });
} catch {}
@@ -513,9 +562,9 @@ class LogicEngine {
const id = this.resolveInstanceId(node);
const key = (node.params.key ?? '').trim();
const ref = parseRef(node.params.target ?? '');
if (id && key && ref) {
if (id && key && ref && !this.dryRun) {
const val = await readConfigParam(id, key);
if (val !== null && !isNaN(val)) wsClient.write(ref, val);
if (val !== null && !isNaN(val)) this.dryWrite(ref, val);
}
await this.follow(node.id, 'out', ctx);
return;
@@ -525,7 +574,7 @@ class LogicEngine {
const id = this.resolveInstanceId(node);
const key = (node.params.key ?? '').trim();
const val = evalExpr(node.params.expr ?? '', ctx.resolve);
if (id && key && !isNaN(val)) {
if (id && key && !isNaN(val) && !this.dryRun) {
await writeConfigParam(id, key, val);
}
await this.follow(node.id, 'out', ctx);
@@ -537,9 +586,9 @@ class LogicEngine {
const name = (node.params.name ?? '').trim() || 'auto';
const fromId = (node.params.from ?? '').trim();
const target = parseRef(node.params.target ?? '');
if (setId) {
if (setId && !this.dryRun) {
const newId = await createConfigInstance(setId, name, fromId);
if (newId && target) wsClient.write(target, newId);
if (newId && target) this.dryWrite(target, newId);
}
await this.follow(node.id, 'out', ctx);
return;
@@ -549,9 +598,9 @@ class LogicEngine {
const setId = (node.params.set ?? '').trim();
const name = (node.params.name ?? '').trim();
const target = parseRef(node.params.target ?? '');
if (setId) {
if (setId && !this.dryRun) {
const newId = await snapshotConfigSet(setId, name);
if (newId && target) wsClient.write(target, newId);
if (newId && target) this.dryWrite(target, newId);
}
await this.follow(node.id, 'out', ctx);
return;
@@ -565,6 +614,7 @@ class LogicEngine {
case 'action.accumulate': {
const name = (node.params.array ?? '').trim();
const val = evalExpr(node.params.expr ?? '', ctx.resolve);
this.markActive(node.id, val);
if (name && !isNaN(val)) {
const arr = this.arrays.get(name) ?? this.arrays.set(name, []).get(name)!;
arr.push({ t: Date.now(), v: val });
@@ -574,7 +624,7 @@ class LogicEngine {
}
case 'action.export': {
this.exportArrays(this.exportColumns(node), node.params.align ?? 'common', node.params.filename ?? '');
if (!this.dryRun) this.exportArrays(this.exportColumns(node), node.params.align ?? 'common', node.params.filename ?? '');
await this.follow(node.id, 'out', ctx);
return;
}
@@ -589,6 +639,7 @@ class LogicEngine {
case 'action.log': {
const val = evalExpr(node.params.expr ?? '', ctx.resolve);
const label = (node.params.label ?? '').trim();
this.markActive(node.id, val);
console.log(`[logic]${label ? ' ' + label + ':' : ''}`, val);
await this.follow(node.id, 'out', ctx);
return;
@@ -596,7 +647,7 @@ class LogicEngine {
case 'action.widget': {
const wid = (node.params.widget ?? '').trim();
if (wid) {
if (wid && !this.dryRun) {
switch (node.params.op ?? '') {
case 'enable': setWidgetDisabled(wid, false); break;
case 'disable': setWidgetDisabled(wid, true); break;
@@ -613,6 +664,7 @@ class LogicEngine {
case 'action.dialog.info':
case 'action.dialog.error': {
if (this.dryRun) { await this.follow(node.id, 'out', ctx); return; }
const specs = this.dialogFieldSpecs(node);
await this.showDialog({
kind: node.kind === 'action.dialog.error' ? 'error' : 'info',
@@ -625,6 +677,7 @@ class LogicEngine {
}
case 'action.dialog.setpoint': {
if (this.dryRun) { await this.follow(node.id, 'out', ctx); return; }
const specs = this.dialogFieldSpecs(node);
const result = await this.showDialog({
kind: 'setpoint',