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
+174
View File
@@ -0,0 +1,174 @@
package controllogic
import (
"context"
"sync"
"time"
"github.com/uopi/uopi/internal/broker"
)
// DebugEvent reports a single node execution for the live debug view. Value is
// only meaningful when HasValue is true (e.g. an action.write's written value or
// a flow.if branch as 0/1); otherwise the event just marks the node as active.
type DebugEvent struct {
GraphID string `json:"graphId"`
NodeID string `json:"nodeId"`
Value float64 `json:"value"`
HasValue bool `json:"hasValue"`
TS int64 `json:"ts"` // unix millis
}
// DebugObserver receives node-execution events from running (and simulated)
// graphs. The server implements it; Observe must not block (the hub fans out
// drop-on-full so a slow editor never stalls the engine).
type DebugObserver interface {
Observe(DebugEvent)
}
// debugObsBox wraps a DebugObserver so atomic.Value always sees one type.
type debugObsBox struct{ o DebugObserver }
// SetDebugObserver installs the sink for node-execution events. Safe to call
// once at startup; read lock-free by running flows.
func (e *Engine) SetDebugObserver(o DebugObserver) {
e.debugObs.Store(debugObsBox{o: o})
}
// SetDebugWatch replaces the set of graph ids with at least one live debug
// subscriber. emitDebug short-circuits for graphs absent from this set, so the
// common (nobody watching) case costs a single atomic load. The hub owns the
// map and must not mutate it after publishing (it is read without a lock).
func (e *Engine) SetDebugWatch(ids map[string]bool) {
e.debugWatch.Store(ids)
}
// registerFire records a compiled graph's manual-fire channel under its route id
// (live graph id or simulate sandbox id).
func (e *Engine) registerFire(id string, ch chan string) {
e.fireMu.Lock()
e.fireChs[id] = ch
e.fireMu.Unlock()
}
// unregisterFire removes id's fire channel, but only if it still points at ch —
// so a newer generation that reused the same id (live reload) is not clobbered
// by the old generation's teardown.
func (e *Engine) unregisterFire(id string, ch chan string) {
e.fireMu.Lock()
if e.fireChs[id] == ch {
delete(e.fireChs, id)
}
e.fireMu.Unlock()
}
// FireTrigger asks the graph behind a debug route (graphID) to run triggerID's
// flow now, as if the trigger had fired. Non-blocking and best-effort: returns
// false if the route is gone or its fire buffer is full. The receiving graph
// validates that triggerID is actually one of its trigger nodes.
func (e *Engine) FireTrigger(graphID, triggerID string) bool {
e.fireMu.Lock()
ch := e.fireChs[graphID]
e.fireMu.Unlock()
if ch == nil || triggerID == "" {
return false
}
select {
case ch <- triggerID:
return true
default:
return false
}
}
// emitDebug reports a node execution to the observer when the graph is watched
// (or the graph is a simulate sandbox, which is always its own subscriber).
func (cg *compiledGraph) emitDebug(nodeID string, value float64, hasValue bool) {
if !cg.alwaysDebug {
w, _ := cg.engine.debugWatch.Load().(map[string]bool)
if !w[cg.id] {
return
}
}
box, _ := cg.engine.debugObs.Load().(debugObsBox)
if box.o == nil {
return
}
box.o.Observe(DebugEvent{
GraphID: cg.id,
NodeID: nodeID,
Value: value,
HasValue: hasValue,
TS: time.Now().UnixMilli(),
})
}
// StartSimulate runs g in a throwaway sandbox generation: real side effects are
// suppressed (data-source writes, config mutations, dialogs) but local vars,
// triggers, timers and the flow itself execute normally, emitting debug events
// the editor can visualise. It is independent of Reload's live generation. The
// returned stop func cancels the sandbox and waits for its goroutines to drain;
// it is safe to call more than once.
func (e *Engine) StartSimulate(g Graph) func() {
cg := compile(g)
cg.engine = e
cg.dryRun = true
cg.alwaysDebug = true
ctx, cancel := context.WithCancel(e.root)
wg := &sync.WaitGroup{}
cg.genCtx = ctx
cg.wg = wg
// Sandbox-local live cache so simulate reads don't disturb the live engine.
updates := make(chan broker.Update, 128)
var unsubs []func()
for _, r := range cg.refs {
unsub, err := e.broker.Subscribe(broker.SignalRef{DS: r.DS, Name: r.Name}, updates)
if err != nil {
e.log.Warn("control logic simulate: subscribe failed", "ds", r.DS, "signal", r.Name, "err", err)
continue
}
unsubs = append(unsubs, unsub)
}
e.registerFire(cg.id, cg.fireCh)
wg.Add(1)
go func() {
defer wg.Done()
<-ctx.Done()
for _, u := range unsubs {
u()
}
e.unregisterFire(cg.id, cg.fireCh)
}()
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case u := <-updates:
val := toNum(u.Value.Data)
key := refKey(u.Ref.DS, u.Ref.Name)
e.liveMu.Lock()
e.live[key] = val
e.liveMu.Unlock()
cg.onSignal(key, val)
case <-ctx.Done():
return
}
}
}()
cg.startTriggers()
var once sync.Once
return func() {
once.Do(func() {
cancel()
wg.Wait()
})
}
}
+196
View File
@@ -0,0 +1,196 @@
package controllogic
import (
"context"
"sync"
"testing"
"time"
)
// fakeObserver records every DebugEvent it receives, in order.
type fakeObserver struct {
mu sync.Mutex
events []DebugEvent
}
func (f *fakeObserver) Observe(ev DebugEvent) {
f.mu.Lock()
f.events = append(f.events, ev)
f.mu.Unlock()
}
func (f *fakeObserver) snapshot() []DebugEvent {
f.mu.Lock()
defer f.mu.Unlock()
return append([]DebugEvent(nil), f.events...)
}
func (f *fakeObserver) last(nodeID string) (DebugEvent, bool) {
f.mu.Lock()
defer f.mu.Unlock()
for i := len(f.events) - 1; i >= 0; i-- {
if f.events[i].NodeID == nodeID {
return f.events[i], true
}
}
return DebugEvent{}, false
}
// thresholdWriteGraph is a 2-node flow: a timer trigger → an action.write of 42
// to "tgt:OUT". Used by both the observe and simulate tests.
func thresholdWriteGraph(id string) Graph {
return Graph{
ID: id,
Name: "flow",
Nodes: []Node{
{ID: "t", Kind: "trigger.timer", Params: map[string]string{"interval": "100"}},
{ID: "w", Kind: "action.write", Params: map[string]string{"target": "tgt:OUT", "expr": "42"}},
},
Wires: []Wire{{From: "t", To: "w"}},
}
}
// TestDebugObserverCapturesNodeValues drives a flow directly and asserts the
// observer saw the trigger fire and the write node's value — and that the real
// data-source write still happened (live mode, not dry-run).
func TestDebugObserverCapturesNodeValues(t *testing.T) {
e, src, _ := setupConfigEngine(t)
obs := &fakeObserver{}
e.SetDebugObserver(obs)
e.SetDebugWatch(map[string]bool{"g1": true})
cg := compile(thresholdWriteGraph("g1"))
cg.engine = e
cg.genCtx = context.Background()
cg.wg = &sync.WaitGroup{}
cg.activate("t")
cg.wg.Wait()
events := obs.snapshot()
if len(events) == 0 {
t.Fatal("observer captured no events")
}
// Trigger then write must both be reported.
if _, ok := obs.last("t"); !ok {
t.Error("no event for trigger node 't'")
}
wEv, ok := obs.last("w")
if !ok {
t.Fatal("no event for write node 'w'")
}
if !wEv.HasValue || wEv.Value != 42 {
t.Errorf("write node event = %+v, want value 42 hasValue true", wEv)
}
if wEv.GraphID != "g1" {
t.Errorf("event GraphID = %q, want g1", wEv.GraphID)
}
// Live mode: the real write went through.
if got, ok := src.get("OUT"); !ok || toNum(got) != 42 {
t.Errorf("OUT = %v (ok=%v), want 42 written", got, ok)
}
}
// manualWriteGraph is a flow whose trigger never fires on its own (a threshold
// with no signal wired) → an action.write of 42 to "tgt:OUT". Used to prove
// FireTrigger forces a run that would otherwise never happen.
func manualWriteGraph(id string) Graph {
return Graph{
ID: id,
Name: "flow",
Nodes: []Node{
{ID: "t", Kind: "trigger.threshold", Params: map[string]string{"op": ">", "value": "0"}},
{ID: "w", Kind: "action.write", Params: map[string]string{"target": "tgt:OUT", "expr": "42"}},
},
Wires: []Wire{{From: "t", To: "w"}},
}
}
// TestFireTriggerForcesRun verifies FireTrigger drives a flow whose trigger
// never fires on its own, routed through the simulate sandbox.
func TestFireTriggerForcesRun(t *testing.T) {
e, _, _ := setupConfigEngine(t)
obs := &fakeObserver{}
e.SetDebugObserver(obs)
e.SetDebugWatch(map[string]bool{})
stop := e.StartSimulate(manualWriteGraph("sim"))
defer stop()
// Nothing should have run yet — the threshold trigger has no signal.
time.Sleep(50 * time.Millisecond)
if _, ok := obs.last("w"); ok {
t.Fatal("write node ran before any manual fire")
}
if !e.FireTrigger("sim", "t") {
t.Fatal("FireTrigger returned false for an active simulate route")
}
// Poll for the write node event (the flow runs on its own goroutine).
var wEv DebugEvent
for i := 0; i < 100; i++ {
if ev, ok := obs.last("w"); ok {
wEv = ev
break
}
time.Sleep(10 * time.Millisecond)
}
if !wEv.HasValue || wEv.Value != 42 {
t.Errorf("write node event = %+v, want value 42 hasValue true", wEv)
}
// An unknown route id must be a no-op (best-effort, returns false).
if e.FireTrigger("does-not-exist", "t") {
t.Error("FireTrigger returned true for an unknown route")
}
}
// TestDebugWatchGatesEmission verifies emitDebug is silent for graphs absent
// from the watch set, so unwatched live graphs cost nothing.
func TestDebugWatchGatesEmission(t *testing.T) {
e, _, _ := setupConfigEngine(t)
obs := &fakeObserver{}
e.SetDebugObserver(obs)
e.SetDebugWatch(map[string]bool{}) // nobody watching
cg := compile(thresholdWriteGraph("g1"))
cg.engine = e
cg.genCtx = context.Background()
cg.wg = &sync.WaitGroup{}
cg.activate("t")
cg.wg.Wait()
if got := obs.snapshot(); len(got) != 0 {
t.Errorf("observer received %d events for an unwatched graph, want 0", len(got))
}
}
// TestSimulateSuppressesWrites runs the graph through the dry-run sandbox and
// asserts node events are still emitted (alwaysDebug) but NO real write occurs.
func TestSimulateSuppressesWrites(t *testing.T) {
e, src, _ := setupConfigEngine(t)
obs := &fakeObserver{}
e.SetDebugObserver(obs)
// Deliberately leave the watch set empty: simulate must emit regardless.
e.SetDebugWatch(map[string]bool{})
stop := e.StartSimulate(thresholdWriteGraph("sim"))
time.Sleep(250 * time.Millisecond) // let the 100 ms timer fire at least once
stop()
if _, ok := src.get("OUT"); ok {
t.Errorf("simulate performed a real write to OUT, want none")
}
wEv, ok := obs.last("w")
if !ok {
t.Fatal("simulate produced no event for write node 'w'")
}
if !wEv.HasValue || wEv.Value != 42 {
t.Errorf("simulate write event = %+v, want value 42 hasValue true", wEv)
}
if wEv.GraphID != "sim" {
t.Errorf("simulate event GraphID = %q, want sim", wEv.GraphID)
}
}
+87 -9
View File
@@ -60,6 +60,20 @@ type Engine struct {
// holds while it waits for those same goroutines to drain).
notifier atomic.Value // notifierBox
// debugObs receives per-node execution events for the live debug view, and
// debugWatch is the set of graph ids with at least one live subscriber. Both
// are read lock-free by flow goroutines (same trick as notifier); emitDebug
// short-circuits cheaply when the firing graph has no watcher.
debugObs atomic.Value // debugObsBox
debugWatch atomic.Value // map[string]bool (immutable snapshot)
// fireChs maps a debug route id (live graph id or simulate sandbox id) to its
// compiled graph's manual-fire channel, so the debug UI can force a trigger to
// run. Entries are added/removed as generations (and simulate sandboxes) come
// and go; FireTrigger does a non-blocking send so a torn-down graph drops.
fireMu sync.Mutex
fireChs map[string]chan string
// Shared live signal cache for the current generation (key "ds\0name").
liveMu sync.RWMutex
live map[string]float64
@@ -88,9 +102,10 @@ func NewEngine(root context.Context, brk *broker.Broker, store *Store, cfg *conf
store: store,
cfg: cfg,
audit: rec,
log: log,
root: root,
live: map[string]float64{},
log: log,
root: root,
live: map[string]float64{},
fireChs: map[string]chan string{},
}
}
@@ -187,6 +202,7 @@ func (e *Engine) Reload() {
cg.engine = e
cg.genCtx = genCtx
cg.wg = wg
e.registerFire(cg.id, cg.fireCh)
}
// One shared updates channel feeds a single dispatch goroutine; every
@@ -209,6 +225,9 @@ func (e *Engine) Reload() {
for _, u := range unsubs {
u()
}
for _, cg := range compiled {
e.unregisterFire(cg.id, cg.fireCh)
}
}()
// Dispatch goroutine: keep the live cache fresh and drive level/edge triggers.
@@ -268,6 +287,9 @@ func (e *Engine) write(cg *compiledGraph, target string, val float64) {
cg.setLocal(name, val)
return
}
if cg.dryRun {
return // simulate: no real data-source write
}
src, ok := e.broker.Source(ds)
if !ok {
e.log.Warn("control logic: write to unknown data source", "ds", ds, "signal", name)
@@ -558,6 +580,14 @@ type compiledGraph struct {
genCtx context.Context
wg *sync.WaitGroup
// dryRun suppresses real side effects (data-source writes, config mutations,
// dialogs) — used by the debug "simulate" sandbox. Local-variable writes stay
// live so the flow still computes correctly. alwaysDebug forces emitDebug to
// fire regardless of debugWatch (the sandbox is its own subscriber).
dryRun bool
alwaysDebug bool
id string
name string
byId map[string]Node
out map[string][]wireOut
@@ -567,6 +597,11 @@ type compiledGraph struct {
watchers map[string][]string // signal key → trigger node ids
luaNodes map[string]*luaRuntime
// fireCh receives node ids of triggers the debug UI wants to fire manually.
// Drained by a generation goroutine (started in startTriggers) so the wg.Add
// in activate always happens on a tracked goroutine.
fireCh chan string
stateMu sync.Mutex
levelState map[string]bool // current truth of level triggers (threshold/alarm)
prevBool map[string]bool // edge detection for threshold/alarm
@@ -578,6 +613,7 @@ type compiledGraph struct {
func compile(g Graph) *compiledGraph {
cg := &compiledGraph{
id: g.ID,
name: g.Name,
byId: map[string]Node{},
out: map[string][]wireOut{},
@@ -591,6 +627,7 @@ func compile(g Graph) *compiledGraph {
hasVal: map[string]bool{},
lastFire: map[string]int64{},
locals: map[string]float64{},
fireCh: make(chan string, 16),
}
for _, n := range g.Nodes {
cg.byId[n.ID] = n
@@ -663,6 +700,24 @@ func (cg *compiledGraph) getLocal(name string) float64 {
// startTriggers launches timer and cron trigger goroutines for the generation.
func (cg *compiledGraph) startTriggers() {
// Manual-fire listener: drain fireCh on a tracked goroutine so activate's
// wg.Add never races the generation teardown's wg.Wait. Only fires nodes that
// are actual triggers in this graph.
cg.wg.Add(1)
go func() {
defer cg.wg.Done()
for {
select {
case id := <-cg.fireCh:
if n, ok := cg.byId[id]; ok && strings.HasPrefix(n.Kind, "trigger.") {
cg.activate(id)
}
case <-cg.genCtx.Done():
return
}
}
}()
hasCron := false
for _, n := range cg.byId {
switch n.Kind {
@@ -856,6 +911,8 @@ func (cg *compiledGraph) activate(triggerID string) {
}
}
cg.emitDebug(triggerID, 0, false)
cg.wg.Add(1)
go func() {
defer cg.wg.Done()
@@ -888,6 +945,8 @@ func (cg *compiledGraph) run(nodeID string, ctx *runCtx) {
default:
}
cg.emitDebug(node.ID, 0, false)
switch node.Kind {
case "gate.and":
if cg.gateSatisfied(node.ID, ctx.fired) {
@@ -896,9 +955,15 @@ func (cg *compiledGraph) run(nodeID string, ctx *runCtx) {
case "flow.if":
branch := "else"
if EvalBool(node.param("cond"), ctx.resolve) {
pass := EvalBool(node.param("cond"), ctx.resolve)
if pass {
branch = "then"
}
v := 0.0
if pass {
v = 1
}
cg.emitDebug(node.ID, v, true)
cg.follow(node.ID, branch, ctx)
case "flow.loop":
@@ -922,11 +987,14 @@ func (cg *compiledGraph) run(nodeID string, ctx *runCtx) {
case "action.write":
val := EvalExpr(node.param("expr"), ctx.resolve)
cg.emitDebug(node.ID, val, true)
cg.engine.write(cg, node.param("target"), val)
cg.follow(node.ID, "out", ctx)
case "action.config.apply":
cg.engine.applyConfig(cg, strings.TrimSpace(node.param("instance")))
if !cg.dryRun {
cg.engine.applyConfig(cg, strings.TrimSpace(node.param("instance")))
}
cg.follow(node.ID, "out", ctx)
case "action.config.read":
@@ -938,15 +1006,22 @@ func (cg *compiledGraph) run(nodeID string, ctx *runCtx) {
case "action.config.write":
val := EvalExpr(node.param("expr"), ctx.resolve)
cg.engine.writeConfigParam(cg, strings.TrimSpace(node.param("instance")), strings.TrimSpace(node.param("key")), val)
cg.emitDebug(node.ID, val, true)
if !cg.dryRun {
cg.engine.writeConfigParam(cg, strings.TrimSpace(node.param("instance")), strings.TrimSpace(node.param("key")), val)
}
cg.follow(node.ID, "out", ctx)
case "action.config.create":
cg.engine.createConfigInstance(cg, strings.TrimSpace(node.param("set")), strings.TrimSpace(node.param("name")), strings.TrimSpace(node.param("from")))
if !cg.dryRun {
cg.engine.createConfigInstance(cg, strings.TrimSpace(node.param("set")), strings.TrimSpace(node.param("name")), strings.TrimSpace(node.param("from")))
}
cg.follow(node.ID, "out", ctx)
case "action.config.snapshot":
cg.engine.snapshotConfig(cg, strings.TrimSpace(node.param("set")), strings.TrimSpace(node.param("name")))
if !cg.dryRun {
cg.engine.snapshotConfig(cg, strings.TrimSpace(node.param("set")), strings.TrimSpace(node.param("name")))
}
cg.follow(node.ID, "out", ctx)
case "action.delay":
@@ -967,6 +1042,7 @@ func (cg *compiledGraph) run(nodeID string, ctx *runCtx) {
case "action.log":
val := EvalExpr(node.param("expr"), ctx.resolve)
cg.emitDebug(node.ID, val, true)
label := strings.TrimSpace(node.param("label"))
cg.engine.log.Info("control logic log", "graph", cg.name, "label", label, "value", val)
cg.follow(node.ID, "out", ctx)
@@ -976,7 +1052,9 @@ func (cg *compiledGraph) run(nodeID string, ctx *runCtx) {
cg.follow(node.ID, "out", ctx)
case "action.dialog":
cg.engine.emitDialog(node)
if !cg.dryRun {
cg.engine.emitDialog(node)
}
cg.follow(node.ID, "out", ctx)
default:
+17 -7
View File
@@ -50,15 +50,25 @@ type Wire struct {
To string `json:"to"`
}
// NodeGroup is a cosmetic, editor-side grouping of nodes. The engine ignores it;
// it is stored and round-tripped so the editor keeps its visual organisation.
type NodeGroup struct {
ID string `json:"id"`
Label string `json:"label,omitempty"`
Members []string `json:"members"`
Collapsed bool `json:"collapsed,omitempty"`
}
// Graph is a named, independently-enableable control-logic flow.
type Graph struct {
ID string `json:"id"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
Version int `json:"version,omitempty"` // git-style revision; bumped on each Save
Tag string `json:"tag,omitempty"` // optional revision label (e.g. "restored from v3")
Nodes []Node `json:"nodes"`
Wires []Wire `json:"wires"`
ID string `json:"id"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
Version int `json:"version,omitempty"` // git-style revision; bumped on each Save
Tag string `json:"tag,omitempty"` // optional revision label (e.g. "restored from v3")
Nodes []Node `json:"nodes"`
Wires []Wire `json:"wires"`
Groups []NodeGroup `json:"groups,omitempty"`
}
func (n Node) param(key string) string {