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
+187
View File
@@ -0,0 +1,187 @@
package server
import (
"encoding/json"
"log/slog"
"strconv"
"sync"
"sync/atomic"
"github.com/uopi/uopi/internal/controllogic"
)
// DebugHub fans control-logic node-execution events out to the editors watching
// them, and owns the per-client debug subscriptions. It implements
// controllogic.DebugObserver; the engine (and simulate sandboxes) call Observe
// on every node execution.
//
// Two subscription modes share one event shape:
// - "live" — observe the running, enabled graph by its id.
// - "simulate" — dry-run an unsaved graph in a throwaway sandbox. Each
// simulate sub gets a unique route id so its events never mix with the live
// graph's (which may share the same persisted id).
//
// Routing is by event GraphID: live events carry the real graph id (only
// emitted while that id is in the engine's debugWatch set, which the hub keeps
// in sync with its live subs), simulate events carry the sandbox's unique id.
type DebugHub struct {
engine *controllogic.Engine
log *slog.Logger
seq uint64 // unique simulate route ids
mu sync.Mutex
subs map[*wsClient]*debugSub // one debug sub per client
routes map[string]map[*wsClient]struct{} // route id → watching clients
liveCount map[string]int // live-subscribed graph id → count
}
type debugSub struct {
route string // graph id (live) or unique sandbox id (simulate)
live bool // true for "live", false for "simulate"
stop func() // simulate sandbox teardown (nil for live)
}
// NewDebugHub builds an empty hub bound to the engine it observes.
func NewDebugHub(engine *controllogic.Engine, log *slog.Logger) *DebugHub {
return &DebugHub{
engine: engine,
log: log,
subs: map[*wsClient]*debugSub{},
routes: map[string]map[*wsClient]struct{}{},
liveCount: map[string]int{},
}
}
// debugOut is the client-bound JSON form of a node-execution event.
type debugOut struct {
Type string `json:"type"` // always "debugNode"
GraphID string `json:"graphId"`
NodeID string `json:"nodeId"`
Value float64 `json:"value"`
HasValue bool `json:"hasValue"`
TS int64 `json:"ts"`
}
// Observe implements controllogic.DebugObserver: push the event to every client
// watching its route (drop-on-full so a slow editor never stalls the engine).
func (h *DebugHub) Observe(ev controllogic.DebugEvent) {
h.mu.Lock()
watchers := h.routes[ev.GraphID]
if len(watchers) == 0 {
h.mu.Unlock()
return
}
targets := make([]*wsClient, 0, len(watchers))
for c := range watchers {
targets = append(targets, c)
}
h.mu.Unlock()
b, err := json.Marshal(debugOut{
Type: "debugNode",
GraphID: ev.GraphID,
NodeID: ev.NodeID,
Value: ev.Value,
HasValue: ev.HasValue,
TS: ev.TS,
})
if err != nil {
return
}
for _, c := range targets {
select {
case c.outCh <- b:
default:
}
}
}
// subscribeLive starts (or replaces) a client's live observation of graphID.
func (h *DebugHub) subscribeLive(c *wsClient, graphID string) {
h.unsubscribe(c)
if graphID == "" {
return
}
sub := &debugSub{route: graphID, live: true}
h.mu.Lock()
h.addRoute(c, sub)
h.liveCount[graphID]++
watch := h.snapshotWatch()
h.mu.Unlock()
h.engine.SetDebugWatch(watch)
}
// subscribeSimulate starts (or replaces) a client's dry-run of an unsaved graph.
func (h *DebugHub) subscribeSimulate(c *wsClient, g controllogic.Graph) {
h.unsubscribe(c)
id := "sim-" + strconv.FormatUint(atomic.AddUint64(&h.seq, 1), 10)
g.ID = id // route sandbox events under a unique id (never collides with live)
stop := h.engine.StartSimulate(g)
sub := &debugSub{route: id, live: false, stop: stop}
h.mu.Lock()
h.addRoute(c, sub)
h.mu.Unlock()
}
// fire forces nodeID's trigger to run in the graph the client is currently
// debugging (live or simulate), routed by that session's id. No-op if the
// client has no active debug session.
func (h *DebugHub) fire(c *wsClient, nodeID string) {
h.mu.Lock()
sub := h.subs[c]
h.mu.Unlock()
if sub == nil {
return
}
h.engine.FireTrigger(sub.route, nodeID)
}
// addRoute records sub for c; caller holds h.mu.
func (h *DebugHub) addRoute(c *wsClient, sub *debugSub) {
h.subs[c] = sub
if h.routes[sub.route] == nil {
h.routes[sub.route] = map[*wsClient]struct{}{}
}
h.routes[sub.route][c] = struct{}{}
}
// unsubscribe tears down a client's debug sub (stopping its sandbox if any) and
// refreshes the engine's watch set. Safe when the client has no sub.
func (h *DebugHub) unsubscribe(c *wsClient) {
h.mu.Lock()
sub := h.subs[c]
if sub == nil {
h.mu.Unlock()
return
}
delete(h.subs, c)
if m := h.routes[sub.route]; m != nil {
delete(m, c)
if len(m) == 0 {
delete(h.routes, sub.route)
}
}
if sub.live {
if h.liveCount[sub.route]--; h.liveCount[sub.route] <= 0 {
delete(h.liveCount, sub.route)
}
}
watch := h.snapshotWatch()
h.mu.Unlock()
if sub.stop != nil {
sub.stop()
}
h.engine.SetDebugWatch(watch)
}
// snapshotWatch builds a fresh immutable set of live-watched graph ids. Caller
// holds h.mu; the result is published to the engine which reads it lock-free.
func (h *DebugHub) snapshotWatch() map[string]bool {
w := make(map[string]bool, len(h.liveCount))
for k := range h.liveCount {
w[k] = true
}
return w
}
+2 -2
View File
@@ -29,7 +29,7 @@ type Server struct {
// New creates the HTTP server, registers all routes, and returns a ready-to-start Server.
// synth may be nil if the synthetic data source is not enabled.
func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, cfgStore *confmgr.Store, policy *access.Policy, acl *panelacl.Store, ctrlLogic *controllogic.Store, ctrlEngine *controllogic.Engine, dialogs *DialogHub, rec audit.Recorder, channelFinderURL, archiverURL, trustedUserHeader string, log *slog.Logger) *Server {
func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, cfgStore *confmgr.Store, policy *access.Policy, acl *panelacl.Store, ctrlLogic *controllogic.Store, ctrlEngine *controllogic.Engine, dialogs *DialogHub, debug *DebugHub, rec audit.Recorder, channelFinderURL, archiverURL, trustedUserHeader string, log *slog.Logger) *Server {
if rec == nil {
rec = audit.Nop()
}
@@ -42,7 +42,7 @@ func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Syntheti
})
// WebSocket endpoint
mux.Handle("/ws", &wsHandler{broker: brk, log: log, userHeader: trustedUserHeader, policy: policy, audit: rec, dialogs: dialogs})
mux.Handle("/ws", &wsHandler{broker: brk, log: log, userHeader: trustedUserHeader, policy: policy, audit: rec, dialogs: dialogs, debug: debug})
// Prometheus-format metrics
mux.HandleFunc("/metrics", metrics.Handler(brk.ActiveSubscriptions))
+47
View File
@@ -17,6 +17,7 @@ import (
"github.com/uopi/uopi/internal/access"
"github.com/uopi/uopi/internal/audit"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/controllogic"
"github.com/uopi/uopi/internal/datasource"
"github.com/uopi/uopi/internal/metrics"
)
@@ -37,6 +38,14 @@ type inMsg struct {
// dialogResponse — id of the control-logic dialog being answered.
ID string `json:"id,omitempty"`
// debugSubscribe — live observation / dry-run of a control-logic graph.
Mode string `json:"mode,omitempty"` // "live" | "simulate"
GraphID string `json:"graphId,omitempty"` // live: id of the running graph
Graph json.RawMessage `json:"graph,omitempty"` // simulate: the unsaved graph
// fireTrigger — force a trigger node of the current debug session to run.
NodeID string `json:"nodeId,omitempty"`
// history
Start time.Time `json:"start"`
End time.Time `json:"end"`
@@ -106,6 +115,8 @@ type wsHandler struct {
audit audit.Recorder
// dialogs fans control-logic dialogs to clients and routes responses.
dialogs *DialogHub
// debug fans control-logic node-execution events to watching editors.
debug *DebugHub
}
func (h *wsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -149,6 +160,7 @@ func (h *wsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
policy: h.policy,
audit: rec,
dialogs: h.dialogs,
debug: h.debug,
outCh: make(chan []byte, 512),
updateCh: make(chan broker.Update, 1024),
subs: make(map[broker.SignalRef]func()),
@@ -160,6 +172,10 @@ func (h *wsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
h.dialogs.add(c)
defer h.dialogs.remove(c)
}
// Tear down any debug subscription (and its simulate sandbox) on disconnect.
if h.debug != nil {
defer h.debug.unsubscribe(c)
}
var wg sync.WaitGroup
wg.Add(3)
@@ -189,6 +205,7 @@ type wsClient struct {
policy *access.Policy // global access-level enforcement
audit audit.Recorder // signal-write audit recorder (never nil)
dialogs *DialogHub // control-logic dialog fan-out (nil if disabled)
debug *DebugHub // control-logic debug fan-out (nil if disabled)
outCh chan []byte // serialised outgoing messages
updateCh chan broker.Update // raw updates from the broker
@@ -287,6 +304,16 @@ func (c *wsClient) handleMessage(ctx context.Context, data []byte) {
c.handleHistory(ctx, msg)
case "dialogResponse":
c.handleDialogResponse(ctx, msg)
case "debugSubscribe":
c.handleDebugSubscribe(ctx, msg)
case "debugUnsubscribe":
if c.debug != nil {
c.debug.unsubscribe(c)
}
case "fireTrigger":
if c.debug != nil {
c.debug.fire(c, msg.NodeID)
}
default:
c.sendError(ctx, "UNKNOWN_TYPE", "unknown message type: "+msg.Type)
}
@@ -423,6 +450,26 @@ func (c *wsClient) handleDialogResponse(ctx context.Context, msg inMsg) {
c.dialogs.respond(ctx, c, msg.ID, num)
}
// handleDebugSubscribe starts a control-logic debug session for this client.
// mode "live" observes the running graph identified by GraphID; mode "simulate"
// dry-runs the unsaved Graph payload in a sandbox (no real writes). Either way
// the previous session (if any) is replaced; node events arrive as "debugNode".
func (c *wsClient) handleDebugSubscribe(ctx context.Context, msg inMsg) {
if c.debug == nil {
return
}
if msg.Mode == "simulate" {
var g controllogic.Graph
if err := json.Unmarshal(msg.Graph, &g); err != nil {
c.sendError(ctx, "DEBUG_ERROR", "invalid graph: "+err.Error())
return
}
c.debug.subscribeSimulate(c, g)
return
}
c.debug.subscribeLive(c, msg.GraphID)
}
func (c *wsClient) handleHistory(ctx context.Context, msg inMsg) {
metrics.IncHistoryReqs()
ds, ok := c.broker.Source(msg.DS)