Implemented admin pane + user permission
This commit is contained in:
@@ -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()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user