Implemented admin pane + user permission
This commit is contained in:
+602
-136
@@ -1,32 +1,114 @@
|
||||
// Package access implements uopi's global user-access policy: every user is
|
||||
// trusted (full write) by default, while a configured blacklist can downgrade
|
||||
// specific users to read-only or no access. It also resolves the per-request
|
||||
// user identity and the user→group memberships defined in config.
|
||||
// Package access implements uopi's role-based access policy. Access is granted
|
||||
// through group memberships: every user belongs implicitly to the built-in
|
||||
// "public" group (granting the baseline viewer role), and may additionally be
|
||||
// assigned a higher role in any number of named groups. Roles form a cumulative
|
||||
// ladder — viewer < operator < logic-editor < auditor < admin — where each level
|
||||
// includes all powers below it. A user's effective capability is the highest
|
||||
// role they hold across all their groups.
|
||||
//
|
||||
// This is the foundation layer (Phase 1). Per-panel ownership/ACL evaluation is
|
||||
// layered on top of this in a later phase.
|
||||
// Groups can be nested: a member of a parent group holds that role on every
|
||||
// descendant group too (unless the descendant assigns them a different role),
|
||||
// so an admin of an organisation group administers its sub-teams.
|
||||
//
|
||||
// As a bootstrap convenience, a policy with no role assignments at all is treated
|
||||
// as fully open (everyone is admin), matching an unconfigured/dev deployment.
|
||||
// Assigning any role switches to strict mode where unlisted users are viewers.
|
||||
//
|
||||
// The policy is seeded from the TOML config at startup but is runtime-mutable
|
||||
// through the admin pane: once EnablePersistence is called, every mutation is
|
||||
// written to a JSON sidecar ({storageDir}/access.json) which, when present on a
|
||||
// later startup, becomes the source of truth (the TOML config is then only a
|
||||
// bootstrap seed). All access is guarded by an RWMutex and safe for concurrent
|
||||
// use; the *Policy pointer is stable so existing shared-pointer wiring is
|
||||
// preserved.
|
||||
package access
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Level is a global access level. Higher levels include lower ones.
|
||||
// PublicGroup is the built-in group every user implicitly belongs to. It cannot
|
||||
// be renamed or deleted and is always a top-level (parentless) group.
|
||||
const PublicGroup = "public"
|
||||
|
||||
// Role is a cumulative access level granted by a group membership. Higher roles
|
||||
// include every power of the lower ones.
|
||||
type Role int
|
||||
|
||||
const (
|
||||
// RoleViewer permits reads only (no signal writes). It is the baseline every
|
||||
// user receives via the public group.
|
||||
RoleViewer Role = iota
|
||||
// RoleOperator adds signal writes (full HMI interaction).
|
||||
RoleOperator
|
||||
// RoleLogic adds editing panel and server-side control logic.
|
||||
RoleLogic
|
||||
// RoleAuditor adds viewing the audit log.
|
||||
RoleAuditor
|
||||
// RoleAdmin adds managing users, groups and access, and viewing server stats.
|
||||
RoleAdmin
|
||||
)
|
||||
|
||||
// RoleNames lists the role tokens from lowest to highest, for the admin UI.
|
||||
var RoleNames = []string{"viewer", "operator", "logiceditor", "auditor", "admin"}
|
||||
|
||||
// String renders the role using the tokens accepted by ParseRole.
|
||||
func (r Role) String() string {
|
||||
switch r {
|
||||
case RoleOperator:
|
||||
return "operator"
|
||||
case RoleLogic:
|
||||
return "logiceditor"
|
||||
case RoleAuditor:
|
||||
return "auditor"
|
||||
case RoleAdmin:
|
||||
return "admin"
|
||||
default:
|
||||
return "viewer"
|
||||
}
|
||||
}
|
||||
|
||||
// ParseRole maps a config/JSON token to a Role. Unknown values fall back to the
|
||||
// least-privileged viewer.
|
||||
func ParseRole(s string) Role {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "operator", "write", "operate", "rw":
|
||||
return RoleOperator
|
||||
case "logiceditor", "logic", "logic_editor", "editor":
|
||||
return RoleLogic
|
||||
case "auditor", "audit":
|
||||
return RoleAuditor
|
||||
case "admin", "administrator":
|
||||
return RoleAdmin
|
||||
default:
|
||||
return RoleViewer
|
||||
}
|
||||
}
|
||||
|
||||
// Level is a coarse global access level retained for the rest of the codebase
|
||||
// (WebSocket auth, middleware, per-panel ACL capping). It is derived from a
|
||||
// user's effective role: viewer → read-only, operator and above → write.
|
||||
type Level int
|
||||
|
||||
const (
|
||||
// LevelNone denies all access.
|
||||
// LevelNone denies all access. Never produced by the role model, but kept so
|
||||
// existing switch statements remain exhaustive.
|
||||
LevelNone Level = iota
|
||||
// LevelRead permits reads only (no create/update/delete/share, no signal writes).
|
||||
// LevelRead permits reads only.
|
||||
LevelRead
|
||||
// LevelWrite permits full access. This is the default for any user not blacklisted.
|
||||
// LevelWrite permits full write access.
|
||||
LevelWrite
|
||||
)
|
||||
|
||||
// String renders the level using the same tokens accepted by ParseLevel and
|
||||
// surfaced to the frontend via /api/v1/me.
|
||||
// String renders the level using the tokens surfaced to the frontend via
|
||||
// /api/v1/me.
|
||||
func (l Level) String() string {
|
||||
switch l {
|
||||
case LevelNone:
|
||||
@@ -38,88 +120,218 @@ func (l Level) String() string {
|
||||
}
|
||||
}
|
||||
|
||||
// ParseLevel maps a config string to a Level. Unknown values restrict to
|
||||
// read-only, since the only reason to list a user is to limit them.
|
||||
func ParseLevel(s string) Level {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "noaccess", "none", "no":
|
||||
return LevelNone
|
||||
case "readonly", "read", "ro":
|
||||
return LevelRead
|
||||
case "write", "readwrite", "rw", "full":
|
||||
func roleToLevel(r Role) Level {
|
||||
if r >= RoleOperator {
|
||||
return LevelWrite
|
||||
default:
|
||||
return LevelRead
|
||||
}
|
||||
return LevelRead
|
||||
}
|
||||
|
||||
// Policy holds the resolved global access configuration. It is immutable after
|
||||
// construction and safe for concurrent use.
|
||||
// group holds a group's parent (for nesting) and explicit per-user role
|
||||
// assignments.
|
||||
type group struct {
|
||||
parent string
|
||||
members map[string]Role
|
||||
}
|
||||
|
||||
// Policy holds the resolved role-based access configuration. It is safe for
|
||||
// concurrent use; reads take a shared lock and admin mutations take an exclusive
|
||||
// lock and persist to disk.
|
||||
type Policy struct {
|
||||
defaultUser string
|
||||
blacklist map[string]Level // user → downgraded level
|
||||
userGroups map[string][]string // user → groups they belong to
|
||||
groupNames []string // all configured group names (sorted)
|
||||
logicEditors map[string]bool // users + group names allowed to edit logic
|
||||
auditReaders map[string]bool // users + group names allowed to view the audit log
|
||||
mu sync.RWMutex
|
||||
path string // access.json path; "" disables persistence
|
||||
defaultUser string // immutable after construction/load
|
||||
groups map[string]*group
|
||||
}
|
||||
|
||||
// New builds a Policy. blacklist maps a username to a config level string;
|
||||
// groups maps a group name to its member usernames. logicEditors optionally
|
||||
// restricts who may edit panel/control logic (usernames or group names); empty
|
||||
// means no restriction.
|
||||
func New(defaultUser string, blacklist map[string]string, groups map[string][]string, logicEditors, auditReaders []string) *Policy {
|
||||
// GroupSpec seeds one group at construction time: its name, optional parent, and
|
||||
// explicit user→role assignments.
|
||||
type GroupSpec struct {
|
||||
Name string
|
||||
Parent string
|
||||
Members map[string]Role
|
||||
}
|
||||
|
||||
// New builds a Policy from group specs. The built-in public group is always
|
||||
// created. A spec listing the same user in multiple roles keeps the last one;
|
||||
// callers should pass the highest intended role.
|
||||
func New(defaultUser string, specs []GroupSpec) *Policy {
|
||||
p := &Policy{
|
||||
defaultUser: strings.TrimSpace(defaultUser),
|
||||
blacklist: make(map[string]Level),
|
||||
userGroups: make(map[string][]string),
|
||||
logicEditors: make(map[string]bool),
|
||||
auditReaders: make(map[string]bool),
|
||||
defaultUser: strings.TrimSpace(defaultUser),
|
||||
groups: make(map[string]*group),
|
||||
}
|
||||
for _, e := range logicEditors {
|
||||
e = strings.TrimSpace(e)
|
||||
if e != "" {
|
||||
p.logicEditors[e] = true
|
||||
}
|
||||
}
|
||||
for _, e := range auditReaders {
|
||||
e = strings.TrimSpace(e)
|
||||
if e != "" {
|
||||
p.auditReaders[e] = true
|
||||
}
|
||||
}
|
||||
for user, lvl := range blacklist {
|
||||
u := strings.TrimSpace(user)
|
||||
if u == "" {
|
||||
for _, s := range specs {
|
||||
name := strings.TrimSpace(s.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
p.blacklist[u] = ParseLevel(lvl)
|
||||
}
|
||||
for g, members := range groups {
|
||||
g = strings.TrimSpace(g)
|
||||
if g == "" {
|
||||
continue
|
||||
}
|
||||
p.groupNames = append(p.groupNames, g)
|
||||
for _, m := range members {
|
||||
m = strings.TrimSpace(m)
|
||||
if m == "" {
|
||||
continue
|
||||
g := p.ensureGroupLocked(name)
|
||||
g.parent = strings.TrimSpace(s.Parent)
|
||||
for u, r := range s.Members {
|
||||
if u = strings.TrimSpace(u); u != "" {
|
||||
g.members[u] = r
|
||||
}
|
||||
p.userGroups[m] = append(p.userGroups[m], g)
|
||||
}
|
||||
}
|
||||
sort.Strings(p.groupNames)
|
||||
p.ensureGroupLocked(PublicGroup).parent = ""
|
||||
p.normalizeParentsLocked()
|
||||
return p
|
||||
}
|
||||
|
||||
// GroupNames returns a copy of every configured user-group name, sorted.
|
||||
func (p *Policy) GroupNames() []string {
|
||||
out := make([]string, len(p.groupNames))
|
||||
copy(out, p.groupNames)
|
||||
return out
|
||||
// ensureGroupLocked returns the named group, creating an empty one if needed.
|
||||
// The caller must hold p.mu for writing (or be in construction).
|
||||
func (p *Policy) ensureGroupLocked(name string) *group {
|
||||
g, ok := p.groups[name]
|
||||
if !ok {
|
||||
g = &group{members: make(map[string]Role)}
|
||||
p.groups[name] = g
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
// normalizeParentsLocked drops parent references to missing groups or that would
|
||||
// form a cycle, and forces the public group to be a root.
|
||||
func (p *Policy) normalizeParentsLocked() {
|
||||
for name, g := range p.groups {
|
||||
if name == PublicGroup {
|
||||
g.parent = ""
|
||||
continue
|
||||
}
|
||||
if g.parent == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := p.groups[g.parent]; !ok || p.hasCycleLocked(name) {
|
||||
g.parent = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// hasCycleLocked reports whether following parent links from start loops back.
|
||||
func (p *Policy) hasCycleLocked(start string) bool {
|
||||
seen := make(map[string]bool)
|
||||
for cur := start; cur != ""; {
|
||||
if seen[cur] {
|
||||
return true
|
||||
}
|
||||
seen[cur] = true
|
||||
g, ok := p.groups[cur]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
cur = g.parent
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ── effective role ─────────────────────────────────────────────────────────
|
||||
|
||||
// configuredLocked reports whether any explicit role is assigned anywhere. An
|
||||
// unconfigured policy is treated as fully open (bootstrap-safe).
|
||||
func (p *Policy) configuredLocked() bool {
|
||||
for _, g := range p.groups {
|
||||
if len(g.members) > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// effectiveRoleLocked returns a user's highest role across all groups. Unlisted
|
||||
// users (and anonymous callers) get the viewer baseline once the policy is
|
||||
// configured; an unconfigured policy grants everyone admin.
|
||||
func (p *Policy) effectiveRoleLocked(user string) Role {
|
||||
if !p.configuredLocked() {
|
||||
return RoleAdmin
|
||||
}
|
||||
best := RoleViewer
|
||||
if user = strings.TrimSpace(user); user == "" {
|
||||
return best
|
||||
}
|
||||
for _, g := range p.groups {
|
||||
if r, ok := g.members[user]; ok && r > best {
|
||||
best = r
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// ── persistence ────────────────────────────────────────────────────────────
|
||||
|
||||
// persisted is the on-disk schema for access.json.
|
||||
type persisted struct {
|
||||
DefaultUser string `json:"defaultUser"`
|
||||
Groups map[string]persistedGroup `json:"groups"`
|
||||
}
|
||||
|
||||
type persistedGroup struct {
|
||||
Parent string `json:"parent"`
|
||||
Members map[string]string `json:"members"` // user → role token
|
||||
}
|
||||
|
||||
// EnablePersistence points the policy at {storageDir}/access.json. If the file
|
||||
// exists its contents replace the TOML-seeded state (the file is the source of
|
||||
// truth once written); otherwise the current state is kept and the file is only
|
||||
// created on the first mutation.
|
||||
func (p *Policy) EnablePersistence(storageDir string) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.path = filepath.Join(storageDir, "access.json")
|
||||
data, err := os.ReadFile(p.path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var ps persisted
|
||||
if err := json.Unmarshal(data, &ps); err != nil {
|
||||
return err
|
||||
}
|
||||
p.defaultUser = strings.TrimSpace(ps.DefaultUser)
|
||||
p.groups = make(map[string]*group)
|
||||
for name, pg := range ps.Groups {
|
||||
if name = strings.TrimSpace(name); name == "" {
|
||||
continue
|
||||
}
|
||||
g := p.ensureGroupLocked(name)
|
||||
g.parent = strings.TrimSpace(pg.Parent)
|
||||
for u, token := range pg.Members {
|
||||
if u = strings.TrimSpace(u); u != "" {
|
||||
g.members[u] = ParseRole(token)
|
||||
}
|
||||
}
|
||||
}
|
||||
p.ensureGroupLocked(PublicGroup).parent = ""
|
||||
p.normalizeParentsLocked()
|
||||
return nil
|
||||
}
|
||||
|
||||
// saveLocked atomically persists the current state when persistence is enabled.
|
||||
func (p *Policy) saveLocked() error {
|
||||
if p.path == "" {
|
||||
return nil
|
||||
}
|
||||
ps := persisted{DefaultUser: p.defaultUser, Groups: make(map[string]persistedGroup, len(p.groups))}
|
||||
for name, g := range p.groups {
|
||||
pg := persistedGroup{Parent: g.parent, Members: make(map[string]string, len(g.members))}
|
||||
for u, r := range g.members {
|
||||
pg.Members[u] = r.String()
|
||||
}
|
||||
ps.Groups[name] = pg
|
||||
}
|
||||
data, err := json.MarshalIndent(ps, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := p.path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, p.path)
|
||||
}
|
||||
|
||||
// ── reads ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// ResolveUser trims the proxy-provided header value and falls back to the
|
||||
// configured default_user when it is empty (e.g. unproxied/dev deployments).
|
||||
func (p *Policy) ResolveUser(headerValue string) string {
|
||||
@@ -130,81 +342,335 @@ func (p *Policy) ResolveUser(headerValue string) string {
|
||||
return u
|
||||
}
|
||||
|
||||
// Level returns the global access level for a user. Users that are neither
|
||||
// blacklisted nor anonymous get full write access.
|
||||
// Level returns the coarse global access level for a user.
|
||||
func (p *Policy) Level(user string) Level {
|
||||
user = strings.TrimSpace(user)
|
||||
if user == "" {
|
||||
// No identity at all (no proxy header, no default_user): trusted LAN.
|
||||
return LevelWrite
|
||||
}
|
||||
if lvl, ok := p.blacklist[user]; ok {
|
||||
return lvl
|
||||
}
|
||||
return LevelWrite
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return roleToLevel(p.effectiveRoleLocked(user))
|
||||
}
|
||||
|
||||
// LogicRestricted reports whether a logic-editor allowlist is configured. When
|
||||
// false, any write-capable user may edit panel/control logic.
|
||||
func (p *Policy) LogicRestricted() bool {
|
||||
return len(p.logicEditors) > 0
|
||||
// EffectiveRole returns the highest role a user holds across all groups.
|
||||
func (p *Policy) EffectiveRole(user string) Role {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return p.effectiveRoleLocked(user)
|
||||
}
|
||||
|
||||
// CanEditLogic reports whether a user may add or edit panel logic and
|
||||
// server-side control logic. When no allowlist is configured everyone with
|
||||
// write access qualifies; otherwise the user (or one of their groups) must be
|
||||
// listed. Anonymous/trusted-LAN callers (user=="") are always permitted.
|
||||
// CanEditLogic reports whether a user may add or edit panel and control logic
|
||||
// (logic-editor role or higher).
|
||||
func (p *Policy) CanEditLogic(user string) bool {
|
||||
user = strings.TrimSpace(user)
|
||||
if user == "" {
|
||||
return true
|
||||
}
|
||||
if !p.LogicRestricted() {
|
||||
return true
|
||||
}
|
||||
if p.logicEditors[user] {
|
||||
return true
|
||||
}
|
||||
for _, g := range p.userGroups[user] {
|
||||
if p.logicEditors[g] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return p.effectiveRoleLocked(user) >= RoleLogic
|
||||
}
|
||||
|
||||
// CanViewAudit reports whether a user may view the audit log. When no reader
|
||||
// allowlist is configured everyone with read access qualifies; otherwise the
|
||||
// user (or one of their groups) must be listed. Anonymous/trusted-LAN callers
|
||||
// (user=="") are always permitted.
|
||||
// CanViewAudit reports whether a user may view the audit log (auditor or admin).
|
||||
func (p *Policy) CanViewAudit(user string) bool {
|
||||
user = strings.TrimSpace(user)
|
||||
if user == "" {
|
||||
return true
|
||||
}
|
||||
if len(p.auditReaders) == 0 {
|
||||
return true
|
||||
}
|
||||
if p.auditReaders[user] {
|
||||
return true
|
||||
}
|
||||
for _, g := range p.userGroups[user] {
|
||||
if p.auditReaders[g] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return p.effectiveRoleLocked(user) >= RoleAuditor
|
||||
}
|
||||
|
||||
// GroupsOf returns a copy of the groups a user belongs to.
|
||||
func (p *Policy) GroupsOf(user string) []string {
|
||||
src := p.userGroups[strings.TrimSpace(user)]
|
||||
out := make([]string, len(src))
|
||||
copy(out, src)
|
||||
// CanAdmin reports whether a user may use the admin pane (admin role).
|
||||
func (p *Policy) CanAdmin(user string) bool {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return p.effectiveRoleLocked(user) >= RoleAdmin
|
||||
}
|
||||
|
||||
// GroupNames returns a copy of every group name, sorted.
|
||||
func (p *Policy) GroupNames() []string {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return p.groupNamesLocked()
|
||||
}
|
||||
|
||||
func (p *Policy) groupNamesLocked() []string {
|
||||
out := make([]string, 0, len(p.groups))
|
||||
for n := range p.groups {
|
||||
out = append(out, n)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// ── request-scoped user identity ────────────────────────────────────────────
|
||||
// GroupsOf returns the groups a user effectively belongs to: every group they
|
||||
// are an explicit member of, plus that group's descendants (membership flows
|
||||
// parent→child). The implicit public-group viewer baseline is not included.
|
||||
// Used by per-panel/folder sharing rules.
|
||||
func (p *Policy) GroupsOf(user string) []string {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
if user = strings.TrimSpace(user); user == "" {
|
||||
return nil
|
||||
}
|
||||
set := make(map[string]bool)
|
||||
for name, g := range p.groups {
|
||||
if _, ok := g.members[user]; ok {
|
||||
set[name] = true
|
||||
p.addDescendantsLocked(name, set)
|
||||
}
|
||||
}
|
||||
out := make([]string, 0, len(set))
|
||||
for n := range set {
|
||||
out = append(out, n)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// addDescendantsLocked adds every transitive child of parent into set.
|
||||
func (p *Policy) addDescendantsLocked(parent string, set map[string]bool) {
|
||||
for name, g := range p.groups {
|
||||
if g.parent == parent && !set[name] {
|
||||
set[name] = true
|
||||
p.addDescendantsLocked(name, set)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── admin snapshot ─────────────────────────────────────────────────────────
|
||||
|
||||
// MemberInfo is one user's role within a group.
|
||||
type MemberInfo struct {
|
||||
User string `json:"user"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
// GroupInfo describes one group for the admin pane.
|
||||
type GroupInfo struct {
|
||||
Name string `json:"name"`
|
||||
Parent string `json:"parent"`
|
||||
Members []MemberInfo `json:"members"`
|
||||
}
|
||||
|
||||
// UserInfo describes one user's memberships and resulting effective role.
|
||||
type UserInfo struct {
|
||||
Name string `json:"name"`
|
||||
EffectiveRole string `json:"effectiveRole"`
|
||||
Roles map[string]string `json:"roles"` // group → role token
|
||||
}
|
||||
|
||||
// AccessSnapshot is the full mutable access state, rendered for the admin pane.
|
||||
type AccessSnapshot struct {
|
||||
DefaultUser string `json:"defaultUser"`
|
||||
PublicGroup string `json:"publicGroup"`
|
||||
Roles []string `json:"roles"` // role ladder, low→high
|
||||
Configured bool `json:"configured"`
|
||||
Users []UserInfo `json:"users"`
|
||||
Groups []GroupInfo `json:"groups"`
|
||||
}
|
||||
|
||||
// Snapshot returns a copy of the full access configuration for the admin pane.
|
||||
func (p *Policy) Snapshot() AccessSnapshot {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
|
||||
snap := AccessSnapshot{
|
||||
DefaultUser: p.defaultUser,
|
||||
PublicGroup: PublicGroup,
|
||||
Roles: append([]string(nil), RoleNames...),
|
||||
Configured: p.configuredLocked(),
|
||||
}
|
||||
|
||||
userSet := make(map[string]bool)
|
||||
for _, name := range p.groupNamesLocked() {
|
||||
g := p.groups[name]
|
||||
gi := GroupInfo{Name: name, Parent: g.parent}
|
||||
users := make([]string, 0, len(g.members))
|
||||
for u := range g.members {
|
||||
users = append(users, u)
|
||||
userSet[u] = true
|
||||
}
|
||||
sort.Strings(users)
|
||||
for _, u := range users {
|
||||
gi.Members = append(gi.Members, MemberInfo{User: u, Role: g.members[u].String()})
|
||||
}
|
||||
snap.Groups = append(snap.Groups, gi)
|
||||
}
|
||||
|
||||
users := make([]string, 0, len(userSet))
|
||||
for u := range userSet {
|
||||
users = append(users, u)
|
||||
}
|
||||
sort.Strings(users)
|
||||
for _, u := range users {
|
||||
ui := UserInfo{Name: u, EffectiveRole: p.effectiveRoleLocked(u).String(), Roles: make(map[string]string)}
|
||||
for name, g := range p.groups {
|
||||
if r, ok := g.members[u]; ok {
|
||||
ui.Roles[name] = r.String()
|
||||
}
|
||||
}
|
||||
snap.Users = append(snap.Users, ui)
|
||||
}
|
||||
return snap
|
||||
}
|
||||
|
||||
// ── mutations (admin pane) ─────────────────────────────────────────────────
|
||||
|
||||
// ErrNotFound is returned when a named group does not exist.
|
||||
var ErrNotFound = errors.New("group not found")
|
||||
|
||||
// SetMemberRole assigns a user a role within an existing group.
|
||||
func (p *Policy) SetMemberRole(groupName, user string, role Role) error {
|
||||
groupName, user = strings.TrimSpace(groupName), strings.TrimSpace(user)
|
||||
if groupName == "" {
|
||||
return errors.New("empty group name")
|
||||
}
|
||||
if user == "" {
|
||||
return errors.New("empty user")
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
g, ok := p.groups[groupName]
|
||||
if !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
g.members[user] = role
|
||||
return p.saveLocked()
|
||||
}
|
||||
|
||||
// RemoveMember drops a user's explicit role in a group.
|
||||
func (p *Policy) RemoveMember(groupName, user string) error {
|
||||
groupName, user = strings.TrimSpace(groupName), strings.TrimSpace(user)
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
g, ok := p.groups[groupName]
|
||||
if !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
delete(g.members, user)
|
||||
return p.saveLocked()
|
||||
}
|
||||
|
||||
// SetUserRoles replaces a user's full set of memberships: the user is placed in
|
||||
// exactly the listed groups with the given roles and removed from all others.
|
||||
// Listed groups that do not exist are created.
|
||||
func (p *Policy) SetUserRoles(user string, roles map[string]Role) error {
|
||||
user = strings.TrimSpace(user)
|
||||
if user == "" {
|
||||
return errors.New("empty user")
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
want := make(map[string]Role, len(roles))
|
||||
for g, r := range roles {
|
||||
if g = strings.TrimSpace(g); g != "" {
|
||||
want[g] = r
|
||||
p.ensureGroupLocked(g)
|
||||
}
|
||||
}
|
||||
for name, g := range p.groups {
|
||||
if r, ok := want[name]; ok {
|
||||
g.members[user] = r
|
||||
} else {
|
||||
delete(g.members, user)
|
||||
}
|
||||
}
|
||||
p.normalizeParentsLocked()
|
||||
return p.saveLocked()
|
||||
}
|
||||
|
||||
// CreateGroup adds an empty group with an optional parent if it does not exist.
|
||||
func (p *Policy) CreateGroup(name, parent string) error {
|
||||
name, parent = strings.TrimSpace(name), strings.TrimSpace(parent)
|
||||
if name == "" {
|
||||
return errors.New("empty group name")
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if _, ok := p.groups[name]; ok {
|
||||
return nil
|
||||
}
|
||||
g := p.ensureGroupLocked(name)
|
||||
g.parent = parent
|
||||
p.normalizeParentsLocked()
|
||||
return p.saveLocked()
|
||||
}
|
||||
|
||||
// SetGroup replaces an existing group's parent and members. The public group's
|
||||
// parent is always forced to root.
|
||||
func (p *Policy) SetGroup(name, parent string, members map[string]Role) error {
|
||||
name, parent = strings.TrimSpace(name), strings.TrimSpace(parent)
|
||||
if name == "" {
|
||||
return errors.New("empty group name")
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
g, ok := p.groups[name]
|
||||
if !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
if name != PublicGroup {
|
||||
g.parent = parent
|
||||
}
|
||||
g.members = make(map[string]Role, len(members))
|
||||
for u, r := range members {
|
||||
if u = strings.TrimSpace(u); u != "" {
|
||||
g.members[u] = r
|
||||
}
|
||||
}
|
||||
p.normalizeParentsLocked()
|
||||
return p.saveLocked()
|
||||
}
|
||||
|
||||
// RenameGroup renames a group, re-pointing any children at the new name. The
|
||||
// public group cannot be renamed.
|
||||
func (p *Policy) RenameGroup(oldName, newName string) error {
|
||||
oldName, newName = strings.TrimSpace(oldName), strings.TrimSpace(newName)
|
||||
if oldName == "" || newName == "" {
|
||||
return errors.New("empty group name")
|
||||
}
|
||||
if oldName == PublicGroup {
|
||||
return errors.New("cannot rename the public group")
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
g, ok := p.groups[oldName]
|
||||
if !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
if oldName == newName {
|
||||
return nil
|
||||
}
|
||||
if _, exists := p.groups[newName]; exists {
|
||||
return errors.New("group already exists: " + newName)
|
||||
}
|
||||
delete(p.groups, oldName)
|
||||
p.groups[newName] = g
|
||||
for _, other := range p.groups {
|
||||
if other.parent == oldName {
|
||||
other.parent = newName
|
||||
}
|
||||
}
|
||||
return p.saveLocked()
|
||||
}
|
||||
|
||||
// DeleteGroup removes a group, reparenting its children to the deleted group's
|
||||
// parent. The public group cannot be deleted; member users keep other groups.
|
||||
func (p *Policy) DeleteGroup(name string) error {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == PublicGroup {
|
||||
return errors.New("cannot delete the public group")
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
g, ok := p.groups[name]
|
||||
if !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
parent := g.parent
|
||||
delete(p.groups, name)
|
||||
for _, other := range p.groups {
|
||||
if other.parent == name {
|
||||
other.parent = parent
|
||||
}
|
||||
}
|
||||
p.normalizeParentsLocked()
|
||||
return p.saveLocked()
|
||||
}
|
||||
|
||||
// ── request-scoped user identity ───────────────────────────────────────────
|
||||
|
||||
type ctxKey struct{}
|
||||
|
||||
|
||||
+257
-26
@@ -1,35 +1,266 @@
|
||||
package access
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCanEditLogic(t *testing.T) {
|
||||
groups := map[string][]string{"ops": {"carol"}}
|
||||
// spec is a small helper to build a GroupSpec.
|
||||
func spec(name, parent string, members map[string]Role) GroupSpec {
|
||||
return GroupSpec{Name: name, Parent: parent, Members: members}
|
||||
}
|
||||
|
||||
// No allowlist configured: everyone with write access may edit logic.
|
||||
open := New("", nil, groups, nil, nil)
|
||||
func TestUnconfiguredIsOpen(t *testing.T) {
|
||||
// No roles assigned anywhere → fully open (everyone is admin), matching a
|
||||
// fresh/dev deployment.
|
||||
p := New("", nil)
|
||||
for _, u := range []string{"", "alice", "carol"} {
|
||||
if !open.CanEditLogic(u) {
|
||||
t.Errorf("unrestricted: CanEditLogic(%q) = false, want true", u)
|
||||
if !p.CanAdmin(u) {
|
||||
t.Errorf("unconfigured: CanAdmin(%q) = false, want true", u)
|
||||
}
|
||||
}
|
||||
if open.LogicRestricted() {
|
||||
t.Error("LogicRestricted() = true with no allowlist")
|
||||
}
|
||||
|
||||
// Allowlist by username and by group name.
|
||||
p := New("", nil, groups, []string{"alice", "ops"}, nil)
|
||||
if !p.LogicRestricted() {
|
||||
t.Error("LogicRestricted() = false with allowlist set")
|
||||
}
|
||||
cases := map[string]bool{
|
||||
"": true, // anonymous / trusted LAN
|
||||
"alice": true, // listed user
|
||||
"carol": true, // member of listed group "ops"
|
||||
"bob": false, // not listed
|
||||
}
|
||||
for u, want := range cases {
|
||||
if got := p.CanEditLogic(u); got != want {
|
||||
t.Errorf("CanEditLogic(%q) = %v, want %v", u, got, want)
|
||||
if p.Level(u) != LevelWrite {
|
||||
t.Errorf("unconfigured: Level(%q) = %v, want write", u, p.Level(u))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoleLadder(t *testing.T) {
|
||||
p := New("", []GroupSpec{
|
||||
spec("public", "", map[string]Role{"adm": RoleAdmin}),
|
||||
spec("ops", "", map[string]Role{
|
||||
"viewer": RoleViewer,
|
||||
"op": RoleOperator,
|
||||
"logic": RoleLogic,
|
||||
"aud": RoleAuditor,
|
||||
}),
|
||||
})
|
||||
|
||||
// Configured now → unlisted users (and anonymous) are viewers (read-only).
|
||||
if p.Level("") != LevelRead {
|
||||
t.Errorf("anonymous Level = %v, want read", p.Level(""))
|
||||
}
|
||||
if p.Level("nobody") != LevelRead {
|
||||
t.Errorf("unlisted Level = %v, want read", p.Level("nobody"))
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
user string
|
||||
level Level
|
||||
editLogic bool
|
||||
audit bool
|
||||
admin bool
|
||||
}{
|
||||
{"viewer", LevelRead, false, false, false},
|
||||
{"op", LevelWrite, false, false, false},
|
||||
{"logic", LevelWrite, true, false, false},
|
||||
{"aud", LevelWrite, true, true, false},
|
||||
{"adm", LevelWrite, true, true, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if p.Level(c.user) != c.level {
|
||||
t.Errorf("%s: Level = %v, want %v", c.user, p.Level(c.user), c.level)
|
||||
}
|
||||
if p.CanEditLogic(c.user) != c.editLogic {
|
||||
t.Errorf("%s: CanEditLogic = %v, want %v", c.user, p.CanEditLogic(c.user), c.editLogic)
|
||||
}
|
||||
if p.CanViewAudit(c.user) != c.audit {
|
||||
t.Errorf("%s: CanViewAudit = %v, want %v", c.user, p.CanViewAudit(c.user), c.audit)
|
||||
}
|
||||
if p.CanAdmin(c.user) != c.admin {
|
||||
t.Errorf("%s: CanAdmin = %v, want %v", c.user, p.CanAdmin(c.user), c.admin)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveRoleIsMaxAcrossGroups(t *testing.T) {
|
||||
p := New("", []GroupSpec{
|
||||
spec("public", "", map[string]Role{"x": RoleViewer}),
|
||||
spec("a", "", map[string]Role{"x": RoleOperator}),
|
||||
spec("b", "", map[string]Role{"x": RoleAuditor}),
|
||||
})
|
||||
if got := p.EffectiveRole("x"); got != RoleAuditor {
|
||||
t.Errorf("EffectiveRole(x) = %v, want auditor (max across groups)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNestingInheritsMembership(t *testing.T) {
|
||||
// org → team-a → squad-1. A member of org is effectively in the descendants
|
||||
// too (membership flows parent→child), surfaced via GroupsOf for panel ACLs.
|
||||
p := New("", []GroupSpec{
|
||||
spec("org", "", map[string]Role{"boss": RoleAdmin}),
|
||||
spec("team-a", "org", nil),
|
||||
spec("squad-1", "team-a", nil),
|
||||
})
|
||||
groups := p.GroupsOf("boss")
|
||||
for _, want := range []string{"org", "team-a", "squad-1"} {
|
||||
if !containsStr(groups, want) {
|
||||
t.Errorf("GroupsOf(boss) = %v, missing %q", groups, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMutationsRoundTrip(t *testing.T) {
|
||||
p := New("", []GroupSpec{
|
||||
spec("public", "", map[string]Role{"root": RoleAdmin}),
|
||||
spec("ops", "", map[string]Role{"carol": RoleOperator}),
|
||||
})
|
||||
|
||||
// Set a user's full membership set: dave operator in ops, admin in eng (new).
|
||||
if err := p.SetUserRoles("dave", map[string]Role{"ops": RoleOperator, "eng": RoleAdmin}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if g := p.GroupsOf("dave"); !containsStr(g, "ops") || !containsStr(g, "eng") {
|
||||
t.Errorf("GroupsOf(dave) = %v, want ops+eng", g)
|
||||
}
|
||||
if !p.CanAdmin("dave") { // admin in eng
|
||||
t.Error("CanAdmin(dave) = false after admin role in eng")
|
||||
}
|
||||
|
||||
// Replacing membership removes dave from ops.
|
||||
if err := p.SetUserRoles("dave", map[string]Role{"eng": RoleAdmin}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if containsStr(p.GroupsOf("dave"), "ops") {
|
||||
t.Error("dave still in ops after membership replaced")
|
||||
}
|
||||
|
||||
// Rename carries dave's admin grant from eng → engineering.
|
||||
if err := p.RenameGroup("eng", "engineering"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !p.CanAdmin("dave") {
|
||||
t.Error("CanAdmin(dave) = false after eng renamed to engineering")
|
||||
}
|
||||
if !containsStr(p.GroupNames(), "engineering") || containsStr(p.GroupNames(), "eng") {
|
||||
t.Errorf("GroupNames after rename = %v", p.GroupNames())
|
||||
}
|
||||
|
||||
// Delete drops the group; dave loses admin but root (in public) keeps it.
|
||||
if err := p.DeleteGroup("engineering"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.CanAdmin("dave") {
|
||||
t.Error("CanAdmin(dave) = true after engineering deleted")
|
||||
}
|
||||
if !p.CanAdmin("root") {
|
||||
t.Error("CanAdmin(root) = false; root grant in public should survive")
|
||||
}
|
||||
if err := p.DeleteGroup("nope"); err != ErrNotFound {
|
||||
t.Errorf("DeleteGroup(nope) = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicGroupProtected(t *testing.T) {
|
||||
p := New("", []GroupSpec{spec("public", "", map[string]Role{"a": RoleAdmin})})
|
||||
if err := p.DeleteGroup(PublicGroup); err == nil {
|
||||
t.Error("DeleteGroup(public) = nil, want error")
|
||||
}
|
||||
if err := p.RenameGroup(PublicGroup, "other"); err == nil {
|
||||
t.Error("RenameGroup(public) = nil, want error")
|
||||
}
|
||||
if !containsStr(p.GroupNames(), PublicGroup) {
|
||||
t.Error("public group missing after protected mutations")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParentCycleRejected(t *testing.T) {
|
||||
p := New("", []GroupSpec{
|
||||
spec("a", "", map[string]Role{"x": RoleViewer}),
|
||||
spec("b", "a", nil),
|
||||
})
|
||||
// Making a's parent b would create a cycle a→b→a; it must be dropped to root.
|
||||
if err := p.SetGroup("a", "b", map[string]Role{"x": RoleViewer}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snap := p.Snapshot()
|
||||
for _, g := range snap.Groups {
|
||||
if g.Name == "a" && g.Parent != "" {
|
||||
t.Errorf("group a parent = %q, want root (cycle should be dropped)", g.Parent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersistenceRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
p := New("admin", []GroupSpec{
|
||||
spec("public", "", map[string]Role{"alice": RoleLogic}),
|
||||
spec("ops", "", map[string]Role{"carol": RoleAdmin}),
|
||||
})
|
||||
if err := p.EnablePersistence(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A mutation triggers the first write of access.json.
|
||||
if err := p.SetUserRoles("dave", map[string]Role{"ops": RoleOperator}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "access.json")); err != nil {
|
||||
t.Fatalf("access.json not written: %v", err)
|
||||
}
|
||||
|
||||
// A fresh policy loading the same dir should see the persisted state, not the
|
||||
// (different) seed it was constructed with.
|
||||
p2 := New("seed", []GroupSpec{spec("public", "", map[string]Role{"seeduser": RoleViewer})})
|
||||
if err := p2.EnablePersistence(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p2.ResolveUser("") != "admin" {
|
||||
t.Errorf("default user = %q, want admin", p2.ResolveUser(""))
|
||||
}
|
||||
if !p2.CanEditLogic("alice") || p2.CanEditLogic("zed") {
|
||||
t.Error("logic-editor role not persisted")
|
||||
}
|
||||
if !p2.CanAdmin("carol") {
|
||||
t.Error("admin role not persisted")
|
||||
}
|
||||
if !containsStr(p2.GroupsOf("dave"), "ops") {
|
||||
t.Error("dave membership not persisted")
|
||||
}
|
||||
if p2.EffectiveRole("seeduser") != RoleViewer || p2.Level("seeduser") != LevelRead {
|
||||
// seeduser came from the discarded seed; should be an unlisted viewer now.
|
||||
t.Error("persisted state did not supersede the seed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentAccess(t *testing.T) {
|
||||
p := New("", []GroupSpec{
|
||||
spec("public", "", map[string]Role{"root": RoleAdmin}),
|
||||
spec("ops", "", map[string]Role{"carol": RoleOperator}),
|
||||
})
|
||||
if err := p.EnablePersistence(t.TempDir()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 8; i++ {
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < 100; j++ {
|
||||
p.CanAdmin("carol")
|
||||
p.Level("carol")
|
||||
p.GroupsOf("carol")
|
||||
p.Snapshot()
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < 100; j++ {
|
||||
p.SetMemberRole("ops", "carol", RoleAuditor)
|
||||
p.SetUserRoles("carol", map[string]Role{"ops": RoleOperator, "eng": RoleAdmin})
|
||||
p.RemoveMember("ops", "carol")
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func containsStr(xs []string, v string) bool {
|
||||
for _, x := range xs {
|
||||
if x == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
+257
-6
@@ -2,6 +2,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
@@ -12,6 +13,8 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -23,6 +26,7 @@ import (
|
||||
"github.com/uopi/uopi/internal/controllogic"
|
||||
"github.com/uopi/uopi/internal/datasource"
|
||||
"github.com/uopi/uopi/internal/datasource/synthetic"
|
||||
"github.com/uopi/uopi/internal/metrics"
|
||||
"github.com/uopi/uopi/internal/panelacl"
|
||||
"github.com/uopi/uopi/internal/storage"
|
||||
)
|
||||
@@ -97,6 +101,14 @@ func (h *Handler) Register(mux *http.ServeMux, prefix string) {
|
||||
mux.HandleFunc("DELETE "+prefix+"/folders/{id}", h.deleteFolder)
|
||||
// User groups defined in config (read-only; for the sharing UI)
|
||||
mux.HandleFunc("GET "+prefix+"/usergroups", h.listUserGroups)
|
||||
// Admin pane: runtime access management + server statistics. Every route is
|
||||
// gated by CanAdmin (the admins allowlist).
|
||||
mux.HandleFunc("GET "+prefix+"/admin/access", h.getAdminAccess)
|
||||
mux.HandleFunc("PUT "+prefix+"/admin/users/{user}", h.putAdminUser)
|
||||
mux.HandleFunc("POST "+prefix+"/admin/groups", h.createAdminGroup)
|
||||
mux.HandleFunc("PUT "+prefix+"/admin/groups/{name}", h.updateAdminGroup)
|
||||
mux.HandleFunc("DELETE "+prefix+"/admin/groups/{name}", h.deleteAdminGroup)
|
||||
mux.HandleFunc("GET "+prefix+"/admin/stats", h.getAdminStats)
|
||||
// Signal group tree
|
||||
mux.HandleFunc("GET "+prefix+"/groups", h.getGroups)
|
||||
mux.HandleFunc("PUT "+prefix+"/groups", h.putGroups)
|
||||
@@ -106,6 +118,8 @@ func (h *Handler) Register(mux *http.ServeMux, prefix string) {
|
||||
mux.HandleFunc("GET "+prefix+"/synthetic/{name}", h.getSynthetic)
|
||||
mux.HandleFunc("PUT "+prefix+"/synthetic/{name}", h.updateSynthetic)
|
||||
mux.HandleFunc("DELETE "+prefix+"/synthetic/{name}", h.deleteSynthetic)
|
||||
// Single-shot debug trace of an unsaved synthetic graph (live editor overlay).
|
||||
mux.HandleFunc("POST "+prefix+"/synthetic/trace", h.traceSynthetic)
|
||||
// Synthetic signal git-style versioning (list / view / promote / fork).
|
||||
mux.HandleFunc("GET "+prefix+"/synthetic/{name}/versions", h.listSyntheticVersions)
|
||||
mux.HandleFunc("GET "+prefix+"/synthetic/{name}/versions/{version}", h.getSyntheticVersion)
|
||||
@@ -156,6 +170,7 @@ func (h *Handler) Register(mux *http.ServeMux, prefix string) {
|
||||
mux.HandleFunc("GET "+prefix+"/config/rules", h.listConfigRules)
|
||||
mux.HandleFunc("POST "+prefix+"/config/rules", h.createConfigRule)
|
||||
mux.HandleFunc("POST "+prefix+"/config/rules/check", h.checkConfigRule)
|
||||
mux.HandleFunc("POST "+prefix+"/config/rules/preview", h.previewConfigRule)
|
||||
mux.HandleFunc("GET "+prefix+"/config/rules/{id}", h.getConfigRule)
|
||||
mux.HandleFunc("PUT "+prefix+"/config/rules/{id}", h.updateConfigRule)
|
||||
mux.HandleFunc("DELETE "+prefix+"/config/rules/{id}", h.deleteConfigRule)
|
||||
@@ -183,6 +198,7 @@ func (h *Handler) getMe(w http.ResponseWriter, r *http.Request) {
|
||||
"groups": groups,
|
||||
"canEditLogic": h.policy.CanEditLogic(user),
|
||||
"canViewAudit": h.policy.CanViewAudit(user),
|
||||
"canAdmin": h.policy.CanAdmin(user),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -284,23 +300,25 @@ func (h *Handler) capByGlobal(user string, p panelacl.Perm) panelacl.Perm {
|
||||
return p
|
||||
}
|
||||
|
||||
// panelPerm returns the caller's effective permission on a panel. A request
|
||||
// with no resolved identity (no proxy header and no default_user) is a trusted
|
||||
// LAN deployment with no per-user enforcement, so it gets full write.
|
||||
// panelPerm returns the caller's effective permission on a panel. A request with
|
||||
// no resolved identity (no proxy header and no default_user) has no per-user ACL,
|
||||
// so it is governed purely by the global level: full write on an unconfigured
|
||||
// (open) policy, read-only once roles are configured.
|
||||
func (h *Handler) panelPerm(r *http.Request, id string) panelacl.Perm {
|
||||
user := caller(r)
|
||||
if user == "" {
|
||||
return panelacl.PermWrite
|
||||
return h.capByGlobal("", panelacl.PermWrite)
|
||||
}
|
||||
return h.capByGlobal(user, h.acl.PanelPerm(id, user, h.policy.GroupsOf(user)))
|
||||
}
|
||||
|
||||
// folderPerm returns the caller's effective permission on a folder. As with
|
||||
// panelPerm, an unidentified caller is trusted with full write.
|
||||
// panelPerm, an unidentified caller is governed by the global level (full write
|
||||
// on an unconfigured/open policy, read-only once roles are configured).
|
||||
func (h *Handler) folderPerm(r *http.Request, id string) panelacl.Perm {
|
||||
user := caller(r)
|
||||
if user == "" {
|
||||
return panelacl.PermWrite
|
||||
return h.capByGlobal("", panelacl.PermWrite)
|
||||
}
|
||||
return h.capByGlobal(user, h.acl.FolderPerm(id, user, h.policy.GroupsOf(user)))
|
||||
}
|
||||
@@ -1114,6 +1132,209 @@ func (h *Handler) listUserGroups(w http.ResponseWriter, _ *http.Request) {
|
||||
jsonOK(w, names)
|
||||
}
|
||||
|
||||
// ── /admin ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// requireAdmin writes a 403 and returns false unless the caller may use the
|
||||
// admin pane (CanAdmin). Anonymous/trusted-LAN callers and, when no admins
|
||||
// allowlist is configured, everyone, are permitted.
|
||||
func (h *Handler) requireAdmin(w http.ResponseWriter, r *http.Request) bool {
|
||||
if h.policy.CanAdmin(caller(r)) {
|
||||
return true
|
||||
}
|
||||
jsonError(w, http.StatusForbidden, "you are not permitted to administer this server")
|
||||
return false
|
||||
}
|
||||
|
||||
// getAdminAccess returns the full mutable access configuration (users, groups,
|
||||
// allowlists) for the admin pane.
|
||||
func (h *Handler) getAdminAccess(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
jsonOK(w, h.policy.Snapshot())
|
||||
}
|
||||
|
||||
type adminUserReq struct {
|
||||
// Roles maps each group the user should belong to → their role token. The
|
||||
// user is removed from any group not listed. Unknown groups are created.
|
||||
Roles map[string]string `json:"roles"`
|
||||
}
|
||||
|
||||
// putAdminUser replaces a user's full set of (group → role) memberships.
|
||||
func (h *Handler) putAdminUser(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
user := strings.TrimSpace(r.PathValue("user"))
|
||||
if user == "" {
|
||||
jsonError(w, http.StatusBadRequest, "empty user")
|
||||
return
|
||||
}
|
||||
var req adminUserReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
||||
return
|
||||
}
|
||||
roles := make(map[string]access.Role, len(req.Roles))
|
||||
for g, token := range req.Roles {
|
||||
if g = strings.TrimSpace(g); g != "" {
|
||||
roles[g] = access.ParseRole(token)
|
||||
}
|
||||
}
|
||||
if err := h.policy.SetUserRoles(user, roles); err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
h.recordMutation(r, "admin.user", user)
|
||||
jsonOK(w, h.policy.Snapshot())
|
||||
}
|
||||
|
||||
type adminGroupReq struct {
|
||||
Name string `json:"name"` // for update: the (possibly new) group name
|
||||
Parent string `json:"parent"` // parent group for nesting ("" = root)
|
||||
Members map[string]string `json:"members"`
|
||||
}
|
||||
|
||||
// createAdminGroup creates an empty group with an optional parent.
|
||||
func (h *Handler) createAdminGroup(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
var req adminGroupReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Name) == "" {
|
||||
jsonError(w, http.StatusBadRequest, "empty group name")
|
||||
return
|
||||
}
|
||||
if err := h.policy.CreateGroup(req.Name, req.Parent); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
h.recordMutation(r, "admin.group.create", req.Name)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
jsonOK(w, h.policy.Snapshot())
|
||||
}
|
||||
|
||||
// updateAdminGroup renames a group (when the body name differs from the path)
|
||||
// and replaces its parent and member roles.
|
||||
func (h *Handler) updateAdminGroup(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
old := strings.TrimSpace(r.PathValue("name"))
|
||||
var req adminGroupReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
name = old
|
||||
}
|
||||
if name != old {
|
||||
if err := h.policy.RenameGroup(old, name); err != nil {
|
||||
if errors.Is(err, access.ErrNotFound) {
|
||||
jsonError(w, http.StatusNotFound, "group not found: "+old)
|
||||
return
|
||||
}
|
||||
jsonError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
members := make(map[string]access.Role, len(req.Members))
|
||||
for u, token := range req.Members {
|
||||
if u = strings.TrimSpace(u); u != "" {
|
||||
members[u] = access.ParseRole(token)
|
||||
}
|
||||
}
|
||||
if err := h.policy.SetGroup(name, req.Parent, members); err != nil {
|
||||
if errors.Is(err, access.ErrNotFound) {
|
||||
jsonError(w, http.StatusNotFound, "group not found: "+name)
|
||||
return
|
||||
}
|
||||
jsonError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
h.recordMutation(r, "admin.group.update", name)
|
||||
jsonOK(w, h.policy.Snapshot())
|
||||
}
|
||||
|
||||
// deleteAdminGroup removes a group (members keep their other groups; children
|
||||
// are reparented). The built-in public group cannot be deleted.
|
||||
func (h *Handler) deleteAdminGroup(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(r.PathValue("name"))
|
||||
if err := h.policy.DeleteGroup(name); err != nil {
|
||||
if errors.Is(err, access.ErrNotFound) {
|
||||
jsonError(w, http.StatusNotFound, "group not found: "+name)
|
||||
return
|
||||
}
|
||||
jsonError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
h.recordMutation(r, "admin.group.delete", name)
|
||||
jsonOK(w, h.policy.Snapshot())
|
||||
}
|
||||
|
||||
// getAdminStats reports live server statistics: the in-process metrics counters,
|
||||
// observed signals and data sources from the broker, Go runtime stats, and the
|
||||
// system load average on Linux.
|
||||
func (h *Handler) getAdminStats(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
m := metrics.Snapshot()
|
||||
var ms runtime.MemStats
|
||||
runtime.ReadMemStats(&ms)
|
||||
sources := h.broker.DataSources()
|
||||
dsNames := make([]string, len(sources))
|
||||
for i, ds := range sources {
|
||||
dsNames[i] = ds.Name()
|
||||
}
|
||||
jsonOK(w, map[string]any{
|
||||
"uptimeSeconds": m.UptimeSeconds,
|
||||
"wsConnections": m.WsConnections,
|
||||
"observedSignals": h.broker.ActiveSubscriptions(),
|
||||
"msgIn": m.MsgIn,
|
||||
"msgOut": m.MsgOut,
|
||||
"writeOps": m.WriteOps,
|
||||
"historyReqs": m.HistoryReqs,
|
||||
"goroutines": runtime.NumGoroutine(),
|
||||
"memAllocBytes": ms.Alloc,
|
||||
"memSysBytes": ms.Sys,
|
||||
"heapInuseBytes": ms.HeapInuse,
|
||||
"loadAvg": readLoadAvg(),
|
||||
"dataSources": dsNames,
|
||||
})
|
||||
}
|
||||
|
||||
// readLoadAvg returns the 1/5/15-minute system load averages from
|
||||
// /proc/loadavg, or nil on non-Linux systems or any read/parse error.
|
||||
func readLoadAvg() []float64 {
|
||||
data, err := os.ReadFile("/proc/loadavg")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
fields := strings.Fields(string(data))
|
||||
if len(fields) < 3 {
|
||||
return nil
|
||||
}
|
||||
out := make([]float64, 3)
|
||||
for i := 0; i < 3; i++ {
|
||||
v, err := strconv.ParseFloat(fields[i], 64)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
out[i] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// genID returns a short unique id with the given prefix (e.g. "fld-1a2b3c4d").
|
||||
func genID(prefix string) string {
|
||||
var b [8]byte
|
||||
@@ -1221,6 +1442,36 @@ func (h *Handler) deleteSynthetic(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// traceSynthetic evaluates an unsaved synthetic graph once against the current
|
||||
// live value of each source signal and returns every node's computed value. It
|
||||
// persists nothing; it powers the editor's live/debug overlay.
|
||||
func (h *Handler) traceSynthetic(w http.ResponseWriter, r *http.Request) {
|
||||
if h.synthetic == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "synthetic data source not enabled")
|
||||
return
|
||||
}
|
||||
var def synthetic.SignalDef
|
||||
if err := json.NewDecoder(r.Body).Decode(&def); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
read := func(ds, name string) (any, error) {
|
||||
v, err := h.broker.ReadNow(ctx, broker.SignalRef{DS: ds, Name: name})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return v.Data, nil
|
||||
}
|
||||
res, err := h.synthetic.Trace(def, read)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
jsonOK(w, res)
|
||||
}
|
||||
|
||||
func (h *Handler) listSyntheticVersions(w http.ResponseWriter, r *http.Request) {
|
||||
if h.synthetic == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "synthetic data source not enabled")
|
||||
|
||||
+147
-1
@@ -61,7 +61,7 @@ func setup(t *testing.T) (*httptest.Server, func()) {
|
||||
clEngine := controllogic.NewEngine(ctx, brk, clStore, cfgStore, audit.Nop(), log)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
api.New(brk, nil, store, cfgStore, access.New("", nil, nil, nil, nil), acl, clStore, clEngine, audit.Nop(), "", "", log).Register(mux, "/api/v1")
|
||||
api.New(brk, nil, store, cfgStore, access.New("", nil), acl, clStore, clEngine, audit.Nop(), "", "", log).Register(mux, "/api/v1")
|
||||
|
||||
srv := httptest.NewServer(mux)
|
||||
return srv, func() {
|
||||
@@ -452,6 +452,152 @@ func TestStorageValidateID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── /api/v1/admin ─────────────────────────────────────────────────────────────
|
||||
|
||||
// adminSetup builds a server whose mux injects the user named by the
|
||||
// "X-Test-User" header into the request context (mirroring the real access
|
||||
// middleware), so admin-gating can be exercised. The policy restricts admin to
|
||||
// the "ops" group, of which "alice" is a member.
|
||||
func adminSetup(t *testing.T) (*httptest.Server, func()) {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
brk := broker.New(ctx, log)
|
||||
ds := stub.New()
|
||||
if err := ds.Connect(ctx); err != nil {
|
||||
t.Fatal("stub connect:", err)
|
||||
}
|
||||
brk.Register(ds)
|
||||
|
||||
dir := t.TempDir()
|
||||
store, _ := storage.New(dir)
|
||||
acl, _ := panelacl.New(dir)
|
||||
clStore, _ := controllogic.NewStore(dir)
|
||||
cfgStore, _ := confmgr.New(dir)
|
||||
clEngine := controllogic.NewEngine(ctx, brk, clStore, cfgStore, audit.Nop(), log)
|
||||
|
||||
// "alice" is an admin (via the ops group); everyone else is a viewer.
|
||||
policy := access.New("", []access.GroupSpec{
|
||||
{Name: "ops", Members: map[string]access.Role{"alice": access.RoleAdmin}},
|
||||
})
|
||||
if err := policy.EnablePersistence(dir); err != nil {
|
||||
t.Fatal("EnablePersistence:", err)
|
||||
}
|
||||
|
||||
inner := http.NewServeMux()
|
||||
api.New(brk, nil, store, cfgStore, policy, acl, clStore, clEngine, audit.Nop(), "", "", log).Register(inner, "/api/v1")
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if u := r.Header.Get("X-Test-User"); u != "" {
|
||||
r = r.WithContext(access.WithUser(r.Context(), u))
|
||||
}
|
||||
inner.ServeHTTP(w, r)
|
||||
})
|
||||
|
||||
srv := httptest.NewServer(mux)
|
||||
return srv, func() { srv.Close(); cancel() }
|
||||
}
|
||||
|
||||
func reqAs(t *testing.T, srv *httptest.Server, method, path, user string, body []byte) *http.Response {
|
||||
t.Helper()
|
||||
var rdr io.Reader
|
||||
if body != nil {
|
||||
rdr = bytes.NewReader(body)
|
||||
}
|
||||
req, err := http.NewRequest(method, srv.URL+path, rdr)
|
||||
if err != nil {
|
||||
t.Fatal("NewRequest:", err)
|
||||
}
|
||||
if user != "" {
|
||||
req.Header.Set("X-Test-User", user)
|
||||
}
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(method, path, err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func TestAdminForbiddenForNonAdmin(t *testing.T) {
|
||||
srv, teardown := adminSetup(t)
|
||||
defer teardown()
|
||||
|
||||
// "bob" is only a viewer → 403 on every admin route.
|
||||
assertStatus(t, reqAs(t, srv, http.MethodGet, "/api/v1/admin/access", "bob", nil), http.StatusForbidden)
|
||||
assertStatus(t, reqAs(t, srv, http.MethodGet, "/api/v1/admin/stats", "bob", nil), http.StatusForbidden)
|
||||
assertStatus(t, reqAs(t, srv, http.MethodPut, "/api/v1/admin/users/carol", "bob",
|
||||
[]byte(`{"roles":{"public":"operator"}}`)), http.StatusForbidden)
|
||||
}
|
||||
|
||||
func TestAdminUserAndGroupMutations(t *testing.T) {
|
||||
srv, teardown := adminSetup(t)
|
||||
defer teardown()
|
||||
|
||||
// alice (admin) assigns carol an auditor role in a new "team" group.
|
||||
resp := reqAs(t, srv, http.MethodPut, "/api/v1/admin/users/carol", "alice",
|
||||
[]byte(`{"roles":{"team":"auditor"}}`))
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
|
||||
var snap access.AccessSnapshot
|
||||
readJSON(t, resp, &snap)
|
||||
var carol *access.UserInfo
|
||||
for i := range snap.Users {
|
||||
if snap.Users[i].Name == "carol" {
|
||||
carol = &snap.Users[i]
|
||||
}
|
||||
}
|
||||
if carol == nil {
|
||||
t.Fatal("carol missing from snapshot")
|
||||
}
|
||||
if carol.EffectiveRole != "auditor" || carol.Roles["team"] != "auditor" {
|
||||
t.Errorf("carol = %+v, want auditor in team", carol)
|
||||
}
|
||||
|
||||
// Create (with parent), rename, and delete a group.
|
||||
assertStatus(t, reqAs(t, srv, http.MethodPost, "/api/v1/admin/groups", "alice",
|
||||
[]byte(`{"name":"eng","parent":"public"}`)), http.StatusCreated)
|
||||
resp = reqAs(t, srv, http.MethodPut, "/api/v1/admin/groups/eng", "alice",
|
||||
[]byte(`{"name":"engineering","members":{"carol":"admin"}}`))
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
readJSON(t, resp, &snap)
|
||||
if !hasGroup(snap, "engineering") || hasGroup(snap, "eng") {
|
||||
t.Errorf("groups after rename = %+v", snap.Groups)
|
||||
}
|
||||
|
||||
assertStatus(t, reqAs(t, srv, http.MethodDelete, "/api/v1/admin/groups/engineering", "alice", nil), http.StatusOK)
|
||||
assertStatus(t, reqAs(t, srv, http.MethodDelete, "/api/v1/admin/groups/nope", "alice", nil), http.StatusNotFound)
|
||||
// The built-in public group cannot be deleted.
|
||||
assertStatus(t, reqAs(t, srv, http.MethodDelete, "/api/v1/admin/groups/public", "alice", nil), http.StatusBadRequest)
|
||||
}
|
||||
|
||||
func hasGroup(s access.AccessSnapshot, name string) bool {
|
||||
for _, g := range s.Groups {
|
||||
if g.Name == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestAdminStats(t *testing.T) {
|
||||
srv, teardown := adminSetup(t)
|
||||
defer teardown()
|
||||
|
||||
resp := reqAs(t, srv, http.MethodGet, "/api/v1/admin/stats", "alice", nil)
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
var stats map[string]any
|
||||
readJSON(t, resp, &stats)
|
||||
for _, k := range []string{"uptimeSeconds", "wsConnections", "observedSignals", "goroutines", "dataSources"} {
|
||||
if _, ok := stats[k]; !ok {
|
||||
t.Errorf("stats missing key %q", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Ensure os is used (blank import guard) ─────────────────────────────────────
|
||||
|
||||
var _ = os.DevNull
|
||||
|
||||
@@ -765,3 +765,37 @@ func (h *Handler) checkConfigRule(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
jsonOK(w, confmgr.EvaluateRule(body.Source, body.Values))
|
||||
}
|
||||
|
||||
// previewConfigRule evaluates a (possibly unsaved) CUE source against a live
|
||||
// snapshot of the bound set's target signals, without storing anything. Unlike
|
||||
// check (which uses sample/default values), preview reads the current hardware
|
||||
// values so the operator sees exactly what the rule would derive from the real
|
||||
// configuration. Body: {setId, source}. Returns the captured snapshot plus the
|
||||
// rule result (violations + transformed values).
|
||||
func (h *Handler) previewConfigRule(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.configEnabled(w) {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
SetID string `json:"setId"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
||||
return
|
||||
}
|
||||
set, err := h.cfg.GetSet(body.SetID)
|
||||
if err != nil {
|
||||
jsonError(w, configStatus(err), err.Error())
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
results := h.readSetSignals(ctx, set)
|
||||
snap := confmgr.Snapshot(set, snapshotReader(results))
|
||||
res := confmgr.EvaluateRule(body.Source, snap.Values)
|
||||
jsonOK(w, struct {
|
||||
Snapshot confmgr.SnapshotResult `json:"snapshot"`
|
||||
Result confmgr.RuleResult `json:"result"`
|
||||
}{snap, res})
|
||||
}
|
||||
|
||||
+22
-33
@@ -19,28 +19,27 @@ type Config struct {
|
||||
|
||||
// AuditConfig controls the audit trail. When enabled, every user and automated
|
||||
// action that could affect the controlled system (signal writes, control-logic
|
||||
// changes) is recorded to a SQLite database for later review by audit staff.
|
||||
// changes) is recorded to a SQLite database for later review by audit staff. Who
|
||||
// may *view* the log is governed by the auditor role (see GroupDef).
|
||||
type AuditConfig struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
DBPath string `toml:"db_path"` // SQLite file; default {storage_dir}/audit.db
|
||||
// Readers lists users or group names allowed to view the audit log. Empty
|
||||
// leaves viewing unrestricted (any caller with read access). Anonymous /
|
||||
// trusted-LAN callers are always permitted.
|
||||
Readers []string `toml:"readers"`
|
||||
}
|
||||
|
||||
// GroupDef is a named set of users defined as [[groups]] in the config file.
|
||||
// GroupDef defines one access group as [[groups]] in the config file. Each group
|
||||
// has an optional parent (for nesting) and lists its members by role. Roles form
|
||||
// a cumulative ladder: viewer < operator < logiceditor < auditor < admin. A user
|
||||
// listed in several role buckets keeps the highest. The built-in "public" group
|
||||
// (every user is an implicit viewer member) may be configured by name to raise
|
||||
// specific users globally.
|
||||
type GroupDef struct {
|
||||
Name string `toml:"name"`
|
||||
Members []string `toml:"members"`
|
||||
}
|
||||
|
||||
// BlacklistEntry downgrades a specific user's global access level. Levels:
|
||||
// "readonly" (no writes) or "noaccess" (denied). Users not listed are trusted
|
||||
// with full access.
|
||||
type BlacklistEntry struct {
|
||||
User string `toml:"user"`
|
||||
Level string `toml:"level"`
|
||||
Name string `toml:"name"`
|
||||
Parent string `toml:"parent"`
|
||||
Viewers []string `toml:"viewers"`
|
||||
Operators []string `toml:"operators"`
|
||||
LogicEditors []string `toml:"logiceditors"`
|
||||
Auditors []string `toml:"auditors"`
|
||||
Admins []string `toml:"admins"`
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
@@ -58,18 +57,14 @@ type ServerConfig struct {
|
||||
|
||||
// DefaultUser is the identity used when the trusted user header is absent or
|
||||
// empty (e.g. unproxied/dev/LAN deployments). Empty leaves the user anonymous.
|
||||
//
|
||||
// Access levels (write/readonly), logic-edit, audit-view and admin rights are
|
||||
// all granted via group roles (see GroupDef / [[groups]]). A config with no
|
||||
// group roles at all is treated as fully open (everyone is admin), so an
|
||||
// unconfigured deployment behaves like trusted LAN. Once the admin pane writes
|
||||
// {storage_dir}/access.json, that file — not this config — is the source of
|
||||
// truth for access.
|
||||
DefaultUser string `toml:"default_user"`
|
||||
|
||||
// Blacklist downgrades specific users' global access level. Everyone not
|
||||
// listed is trusted with full write access.
|
||||
Blacklist []BlacklistEntry `toml:"blacklist"`
|
||||
|
||||
// LogicEditors optionally restricts who may add or edit panel logic (the
|
||||
// <logic> block of interfaces) and server-side control logic. Entries are
|
||||
// usernames or group names. When empty, no restriction applies (any user
|
||||
// with write access may edit logic). Anonymous/trusted-LAN callers are
|
||||
// always permitted.
|
||||
LogicEditors []string `toml:"logic_editors"`
|
||||
}
|
||||
|
||||
type DatasourceConfig struct {
|
||||
@@ -151,18 +146,12 @@ func applyEnv(cfg *Config) {
|
||||
if v := env("UOPI_SERVER_DEFAULT_USER"); v != "" {
|
||||
cfg.Server.DefaultUser = v
|
||||
}
|
||||
if v := env("UOPI_SERVER_LOGIC_EDITORS"); v != "" {
|
||||
cfg.Server.LogicEditors = strings.Fields(v)
|
||||
}
|
||||
if v := env("UOPI_AUDIT_ENABLED"); v != "" {
|
||||
cfg.Audit.Enabled = (v == "true" || v == "YES")
|
||||
}
|
||||
if v := env("UOPI_AUDIT_DB_PATH"); v != "" {
|
||||
cfg.Audit.DBPath = v
|
||||
}
|
||||
if v := env("UOPI_AUDIT_READERS"); v != "" {
|
||||
cfg.Audit.Readers = strings.Fields(v)
|
||||
}
|
||||
if v := env("UOPI_EPICS_CA_ADDR_LIST"); v != "" {
|
||||
cfg.Datasource.EPICS.CAAddrList = v
|
||||
}
|
||||
|
||||
+12
-4
@@ -24,12 +24,20 @@ type ConfigRule struct {
|
||||
Name string `json:"name"`
|
||||
SetID string `json:"setId"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Version int `json:"version"`
|
||||
Tag string `json:"tag,omitempty"`
|
||||
Owner string `json:"owner,omitempty"`
|
||||
Source string `json:"source"`
|
||||
// Enabled gates whether the rule runs when instances of the bound set are
|
||||
// saved/applied. A nil pointer (legacy rules created before the flag) is
|
||||
// treated as enabled, so existing rules keep their behaviour.
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
Version int `json:"version"`
|
||||
Tag string `json:"tag,omitempty"`
|
||||
Owner string `json:"owner,omitempty"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
// IsEnabled reports whether the rule participates in instance evaluation. A nil
|
||||
// Enabled flag (legacy rules) is treated as enabled.
|
||||
func (r ConfigRule) IsEnabled() bool { return r.Enabled == nil || *r.Enabled }
|
||||
|
||||
// RuleViolation is a single constraint failure from evaluating a rule.
|
||||
type RuleViolation struct {
|
||||
Rule string `json:"rule,omitempty"` // rule ID that produced it (aggregate runs)
|
||||
|
||||
@@ -45,6 +45,10 @@ type Meta struct {
|
||||
// empty for sets. Lets clients group/filter instances by set without a GET
|
||||
// per instance.
|
||||
SetID string `json:"setId,omitempty"`
|
||||
// Enabled is only populated for rules: nil for sets/instances and for legacy
|
||||
// rules without the flag. Lets the rule list show enabled/disabled status
|
||||
// without a GET per rule.
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
}
|
||||
|
||||
// VersionMeta describes a single persisted revision.
|
||||
@@ -85,6 +89,7 @@ type header struct {
|
||||
Version int `json:"version"`
|
||||
Tag string `json:"tag"`
|
||||
SetID string `json:"setId"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func validateID(id string) error {
|
||||
@@ -149,7 +154,7 @@ func (s *Store) List(k Kind) ([]Meta, error) {
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, Meta{ID: id, Name: h.Name, Version: h.Version, SetID: h.SetID})
|
||||
out = append(out, Meta{ID: id, Name: h.Name, Version: h.Version, SetID: h.SetID, Enabled: h.Enabled})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -587,6 +592,9 @@ func (s *Store) rulesForSet(setID string) ([]ConfigRule, error) {
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if !rule.IsEnabled() {
|
||||
continue
|
||||
}
|
||||
out = append(out, rule)
|
||||
}
|
||||
return out, nil
|
||||
|
||||
@@ -95,6 +95,26 @@ func TestRuleTransformPersists(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisabledRuleSkipped(t *testing.T) {
|
||||
s, _ := New(t.TempDir())
|
||||
set, _ := s.CreateSet(sampleSet(), "")
|
||||
disabled := false
|
||||
if _, err := s.CreateRule(ConfigRule{
|
||||
Name: "voltage cap",
|
||||
SetID: set.ID,
|
||||
Enabled: &disabled,
|
||||
Source: "voltage: <=24",
|
||||
}, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A value that would violate the rule still saves because the rule is off.
|
||||
if _, err := s.CreateInstance(ConfigInstance{
|
||||
Name: "bad", SetID: set.ID, Values: map[string]any{"voltage": 40.0},
|
||||
}, ""); err != nil {
|
||||
t.Fatalf("expected disabled rule to be skipped, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func asRuleError(err error, target **RuleError) bool {
|
||||
for err != nil {
|
||||
if re, ok := err.(*RuleError); ok {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -49,6 +49,17 @@ type NodeDef struct {
|
||||
type Graph struct {
|
||||
Nodes []GraphNode `json:"nodes"`
|
||||
Output string `json:"output"` // id of the output node
|
||||
// Groups is cosmetic editor metadata (node grouping/collapsing). It has no
|
||||
// effect on evaluation; stored and round-tripped so the editor keeps shape.
|
||||
Groups []NodeGroup `json:"groups,omitempty"`
|
||||
}
|
||||
|
||||
// NodeGroup is a cosmetic, editor-side grouping of graph nodes (no eval effect).
|
||||
type NodeGroup struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Members []string `json:"members"`
|
||||
Collapsed bool `json:"collapsed,omitempty"`
|
||||
}
|
||||
|
||||
// GraphNode is one node in a Graph.
|
||||
|
||||
@@ -52,7 +52,20 @@ func (rg *runtimeGraph) sourceRefs() []broker.SignalRef {
|
||||
// - stateful ops (filters) and lua are scalar-only; an array input errors,
|
||||
// since their per-evaluation state cannot be split across array lanes.
|
||||
func (rg *runtimeGraph) evalSample(sourceVals map[string]dsp.Sample) (dsp.Sample, error) {
|
||||
vals := make(map[string]dsp.Sample, len(rg.order))
|
||||
vals, err := rg.evalSampleTrace(sourceVals)
|
||||
if err != nil {
|
||||
return dsp.Sample{}, err
|
||||
}
|
||||
return vals[rg.outputID], nil
|
||||
}
|
||||
|
||||
// evalSampleTrace runs a full forward pass like evalSample but returns the value
|
||||
// computed for *every* node (sources, ops and the output), keyed by node id. It
|
||||
// is used by the editor's live/debug trace to show each node's current value.
|
||||
// On an op error it returns the values computed so far together with the error,
|
||||
// so partial results can still be displayed.
|
||||
func (rg *runtimeGraph) evalSampleTrace(sourceVals map[string]dsp.Sample) (map[string]dsp.Sample, error) {
|
||||
vals := make(map[string]dsp.Sample, len(rg.order)+len(sourceVals))
|
||||
for id, v := range sourceVals {
|
||||
vals[id] = v
|
||||
}
|
||||
@@ -65,7 +78,7 @@ func (rg *runtimeGraph) evalSample(sourceVals map[string]dsp.Sample) (dsp.Sample
|
||||
}
|
||||
r, err := evalOp(n, in)
|
||||
if err != nil {
|
||||
return dsp.Sample{}, fmt.Errorf("node %s (%s): %w", n.id, n.op.Type(), err)
|
||||
return vals, fmt.Errorf("node %s (%s): %w", n.id, n.op.Type(), err)
|
||||
}
|
||||
vals[n.id] = r
|
||||
case "output":
|
||||
@@ -74,7 +87,7 @@ func (rg *runtimeGraph) evalSample(sourceVals map[string]dsp.Sample) (dsp.Sample
|
||||
}
|
||||
}
|
||||
}
|
||||
return vals[rg.outputID], nil
|
||||
return vals, nil
|
||||
}
|
||||
|
||||
// evalOp runs a single op node over its Sample inputs, choosing the right
|
||||
|
||||
@@ -462,6 +462,65 @@ func outTypeOf(st *signalState) dsp.ValType {
|
||||
return st.rg.outType
|
||||
}
|
||||
|
||||
// TraceNode is a single node's computed value in a debug trace.
|
||||
type TraceNode struct {
|
||||
Value any `json:"value"`
|
||||
Type string `json:"type"` // "scalar" | "array"
|
||||
Approx bool `json:"approx,omitempty"`
|
||||
}
|
||||
|
||||
// TraceResult is the per-node outcome of a single-shot evaluation of an
|
||||
// (unsaved) synthetic graph, used by the editor's live/debug overlay.
|
||||
type TraceResult struct {
|
||||
Nodes map[string]TraceNode `json:"nodes"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// Trace compiles def and evaluates it once with fresh state, returning the value
|
||||
// computed for every node. Source node values are obtained via read(ds, name)
|
||||
// (typically a broker ReadNow snapshot); a source that cannot be read defaults to
|
||||
// scalar 0. Stateful ops are flagged Approx since their true running value
|
||||
// depends on accumulated state this single-shot pass does not have. A per-node
|
||||
// evaluation error is returned in Error with the partial values still populated.
|
||||
func (s *Synthetic) Trace(def SignalDef, read func(ds, name string) (any, error)) (TraceResult, error) {
|
||||
rg, err := compileGraph(def)
|
||||
if err != nil {
|
||||
return TraceResult{}, err
|
||||
}
|
||||
sourceVals := make(map[string]dsp.Sample, len(rg.sources))
|
||||
for _, src := range rg.sources {
|
||||
raw, rerr := read(src.ref.DS, src.ref.Name)
|
||||
if rerr != nil || raw == nil {
|
||||
sourceVals[src.id] = dsp.Scalar(0)
|
||||
continue
|
||||
}
|
||||
sourceVals[src.id] = toSample(raw)
|
||||
}
|
||||
vals, evalErr := rg.evalSampleTrace(sourceVals)
|
||||
|
||||
opType := make(map[string]string, len(rg.order))
|
||||
for _, n := range rg.order {
|
||||
if n.kind == "op" && n.op != nil {
|
||||
opType[n.id] = n.op.Type()
|
||||
}
|
||||
}
|
||||
res := TraceResult{Nodes: make(map[string]TraceNode, len(vals))}
|
||||
for id, sample := range vals {
|
||||
tn := TraceNode{Value: sample.AsAny(), Type: "scalar"}
|
||||
if sample.IsArray {
|
||||
tn.Type = "array"
|
||||
}
|
||||
if ot, ok := opType[id]; ok && dsp.IsStateful(ot) {
|
||||
tn.Approx = true
|
||||
}
|
||||
res.Nodes[id] = tn
|
||||
}
|
||||
if evalErr != nil {
|
||||
res.Error = evalErr.Error()
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// toSample coerces a datasource.Value.Data into a dsp.Sample: arrays become
|
||||
// array Samples (waveforms), everything else a scalar Sample.
|
||||
func toSample(v any) dsp.Sample {
|
||||
|
||||
@@ -22,8 +22,20 @@ var (
|
||||
"moving_average": true, "rms": true, "lowpass": true,
|
||||
"derivative": true, "integrate": true, "lua": true,
|
||||
}
|
||||
// statefulOps accumulate state across evaluations, so a single-shot trace
|
||||
// (fresh state) only approximates their true running value. lua is included
|
||||
// because a script may persist state in its state table.
|
||||
statefulOps = map[string]bool{
|
||||
"moving_average": true, "rms": true, "lowpass": true,
|
||||
"derivative": true, "integrate": true, "lua": true,
|
||||
}
|
||||
)
|
||||
|
||||
// IsStateful reports whether an op accumulates state across evaluations. The
|
||||
// editor's live/debug trace flags such nodes as "approx" since it evaluates a
|
||||
// single tick with fresh state.
|
||||
func IsStateful(op string) bool { return statefulOps[op] }
|
||||
|
||||
// OpOutputType reports the output ValType of an op given its input types, and
|
||||
// an error if the inputs are definitely incompatible with the op. Inputs may be
|
||||
// ValUnknown (a source whose real type is not yet known at compile time); such
|
||||
|
||||
@@ -38,6 +38,30 @@ func IncWrites() { writeOps.Add(1) }
|
||||
// IncHistoryReqs increments the history request counter.
|
||||
func IncHistoryReqs() { historyReqs.Add(1) }
|
||||
|
||||
// Stats is a point-in-time snapshot of the in-process counters, for rendering
|
||||
// as JSON in the admin pane (the Prometheus Handler renders the same data as
|
||||
// text).
|
||||
type Stats struct {
|
||||
UptimeSeconds float64 `json:"uptimeSeconds"`
|
||||
WsConnections int64 `json:"wsConnections"`
|
||||
MsgIn int64 `json:"msgIn"`
|
||||
MsgOut int64 `json:"msgOut"`
|
||||
WriteOps int64 `json:"writeOps"`
|
||||
HistoryReqs int64 `json:"historyReqs"`
|
||||
}
|
||||
|
||||
// Snapshot returns the current values of all counters and gauges.
|
||||
func Snapshot() Stats {
|
||||
return Stats{
|
||||
UptimeSeconds: time.Since(startTime).Seconds(),
|
||||
WsConnections: wsConns.Load(),
|
||||
MsgIn: msgIn.Load(),
|
||||
MsgOut: msgOut.Load(),
|
||||
WriteOps: writeOps.Load(),
|
||||
HistoryReqs: historyReqs.Load(),
|
||||
}
|
||||
}
|
||||
|
||||
// Handler returns an http.HandlerFunc that renders Prometheus-format metrics.
|
||||
// activeSubs is called each request to read the current number of unique signal
|
||||
// subscriptions from the broker; pass nil to omit the metric.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -22,6 +22,8 @@ type InterfaceMeta struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Version int `json:"version"`
|
||||
// Kind is "plot" for split-layout plot panels, empty for free-form panels.
|
||||
Kind string `json:"kind,omitempty"`
|
||||
}
|
||||
|
||||
// VersionMeta describes a single persisted revision of an interface.
|
||||
@@ -134,6 +136,7 @@ type rootAttrs struct {
|
||||
Name string `xml:"name,attr"`
|
||||
Version int `xml:"version,attr"`
|
||||
Tag string `xml:"tag,attr"`
|
||||
Kind string `xml:"kind,attr"`
|
||||
}
|
||||
|
||||
func (s *Store) readMeta(id string) (InterfaceMeta, error) {
|
||||
@@ -145,7 +148,7 @@ func (s *Store) readMeta(id string) (InterfaceMeta, error) {
|
||||
if err := xml.Unmarshal(data, &root); err != nil {
|
||||
return InterfaceMeta{}, err
|
||||
}
|
||||
return InterfaceMeta{ID: id, Name: root.Name, Version: root.Version}, nil
|
||||
return InterfaceMeta{ID: id, Name: root.Name, Version: root.Version, Kind: root.Kind}, nil
|
||||
}
|
||||
|
||||
// Get returns the raw XML bytes for the interface with the given ID.
|
||||
|
||||
Reference in New Issue
Block a user