Compare commits

..

3 Commits

Author SHA1 Message Date
Martino Ferrari 446de7f1ee Improving all side of app 2026-06-20 14:28:28 +02:00
Martino Ferrari 901b87d407 Implementing more advanced feature: audit and more 2026-06-19 14:17:46 +02:00
Martino Ferrari 8f6dbcba49 Working on audit 2026-06-19 09:44:57 +02:00
60 changed files with 3515 additions and 941 deletions
+28
View File
@@ -0,0 +1,28 @@
# TODO
- [ ] Implement git style versioning for: synthetic variable, panels, control logic
- [ ] Implement configuration manager:
- configuration is a set of signals (that can be organized in group and subgroup) that are used to configure a system or a sub system
- with configuration manager user should be able to:
- Define and manage configuration sets: delete (keep server side copy of deleted config), create (specifying default to parameters, mandatory, optional etc), edit (with versioning git style) and compare set
- user can fork an existing config instance to create a new one
- user can compare two instances: show unified diff or side by side diff
- etc...
- Create, delete (keep server cache), edit (git style versioning) and compare configuration instances: meaning set actual value to a configuration set
- user can fork an existing config instance to create a new one
- user can compare two instances: show unified diff or side by side diff
- etc...
- [ ] Implement admin pane: create / manage groups, set users permits, manage auditors etc
- [ ] Implement new datasources:
- [ ] modbus tcp
- [ ] scpi tcp
- [ ] Improve UX:
- [ ] Synthetic editor:
- color code the node link by type
- hover on a block in error should show the reason
- add proper array functionality
- add shortcut to add Signals (S) and nodes (N) with HUD
- [ ] Panels:
- in view mode the widgets should have no border/bg but blend with background
- add widgets such
- [ ] Implement proper distributed server side nodes to balance load and have redundancy (if a node is not available anymore all its clients migrate seamelessly to another)
+51 -3
View File
@@ -9,14 +9,18 @@ import (
"log/slog"
"os"
"os/signal"
"os/user"
"path/filepath"
"syscall"
"github.com/uopi/uopi/internal/access"
"github.com/uopi/uopi/internal/audit"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/config"
"github.com/uopi/uopi/internal/controllogic"
"github.com/uopi/uopi/internal/datasource/epics"
"github.com/uopi/uopi/internal/datasource/pva"
"github.com/uopi/uopi/internal/datasource/servervar"
"github.com/uopi/uopi/internal/datasource/stub"
"github.com/uopi/uopi/internal/datasource/synthetic"
"github.com/uopi/uopi/internal/panelacl"
@@ -41,6 +45,16 @@ func main() {
os.Exit(1)
}
// In an unproxied/local deployment (no trusted_user_header) without an explicit
// default_user, attribute actions to the OS user running the process so the UI
// and audit log show a real identity instead of anonymous.
if cfg.Server.TrustedUserHeader == "" && cfg.Server.DefaultUser == "" {
if u, err := user.Current(); err == nil && u.Username != "" {
cfg.Server.DefaultUser = u.Username
log.Info("no default_user configured; defaulting to OS user", "user", u.Username)
}
}
if err := os.MkdirAll(cfg.Server.StorageDir, 0o755); err != nil {
log.Error("failed to create storage dir", "dir", cfg.Server.StorageDir, "err", err)
os.Exit(1)
@@ -122,6 +136,15 @@ func main() {
}
}
// Server variables: a small persistent key/value source ("srv") that the
// control-logic engine writes (e.g. sequence state) and panels can read.
srvVars, err := servervar.New(cfg.Server.StorageDir)
if err != nil {
log.Error("server variables init", "err", err)
os.Exit(1)
}
brk.Register(srvVars)
// Build the global access policy from config: every user is trusted with
// full write access unless downgraded by the blacklist; groups are named
// sets of users referenced by per-panel sharing.
@@ -133,7 +156,26 @@ func main() {
for _, g := range cfg.Groups {
groups[g.Name] = g.Members
}
policy := access.New(cfg.Server.DefaultUser, blacklist, groups, cfg.Server.LogicEditors)
policy := access.New(cfg.Server.DefaultUser, blacklist, groups, cfg.Server.LogicEditors, cfg.Audit.Readers)
// Audit log: when enabled, every system-affecting action (user/automated
// signal writes, interface and control-logic mutations) is recorded to SQLite.
// When disabled a no-op recorder is used so call sites need no guards.
recorder := audit.Nop()
if cfg.Audit.Enabled {
dbPath := cfg.Audit.DBPath
if dbPath == "" {
dbPath = filepath.Join(cfg.Server.StorageDir, "audit.db")
}
r, err := audit.NewSQLite(dbPath, log)
if err != nil {
log.Error("failed to open audit log", "path", dbPath, "err", err)
os.Exit(1)
}
defer r.Close()
recorder = r
log.Info("audit log enabled", "path", dbPath)
}
// Server-side control logic: flow graphs that run continuously under the root
// context, independent of any panel. The store is loaded from disk and the
@@ -143,10 +185,16 @@ func main() {
log.Error("failed to open control logic store", "err", err)
os.Exit(1)
}
ctrlEngine := controllogic.NewEngine(ctx, brk, ctrlStore, log)
ctrlEngine := controllogic.NewEngine(ctx, brk, ctrlStore, recorder, log)
// Dialog hub: control-logic action.dialog nodes push notifications/inputs to
// connected clients (filtered by user/group) and route input responses back
// to server variables. Installed before Reload so running graphs can emit.
dialogs := server.NewDialogHub(brk, policy, recorder, log)
ctrlEngine.SetNotifier(dialogs)
ctrlEngine.Reload()
srv := server.New(cfg.Server.Listen, webFS, brk, synthDS, store, policy, aclStore, ctrlStore, ctrlEngine, cfg.Datasource.EPICS.ChannelFinderURL, cfg.Datasource.EPICS.ArchiveURL, cfg.Server.TrustedUserHeader, log)
srv := server.New(cfg.Server.Listen, webFS, brk, synthDS, store, policy, aclStore, ctrlStore, ctrlEngine, dialogs, recorder, cfg.Datasource.EPICS.ChannelFinderURL, cfg.Datasource.EPICS.ArchiveURL, cfg.Server.TrustedUserHeader, log)
if err := srv.Start(ctx); err != nil {
fmt.Fprintf(os.Stderr, "server error: %v\n", err)
+13 -1
View File
@@ -9,4 +9,16 @@ require (
github.com/yuin/gopher-lua v1.1.2
)
require golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/sys v0.36.0 // indirect
modernc.org/libc v1.66.10 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.42.2 // indirect
)
+23
View File
@@ -2,9 +2,32 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/evanw/esbuild v0.28.0 h1:V96ghtc5p5JnNUQIUsc5H3kr+AcFcMqOJll2ZmJW6Lo=
github.com/evanw/esbuild v0.28.0/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA=
github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.42.2 h1:7hkZUNJvJFN2PgfUdjni9Kbvd4ef4mNLOu0B9FGxM74=
modernc.org/sqlite v1.42.2/go.mod h1:+VkC6v3pLOAE0A0uVucQEcbVW0I5nHCeDaBf+DpsQT8=
+32 -1
View File
@@ -61,18 +61,20 @@ type Policy struct {
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
}
// 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 []string) *Policy {
func New(defaultUser string, blacklist map[string]string, groups map[string][]string, logicEditors, auditReaders []string) *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),
}
for _, e := range logicEditors {
e = strings.TrimSpace(e)
@@ -80,6 +82,12 @@ func New(defaultUser string, blacklist map[string]string, groups map[string][]st
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 == "" {
@@ -165,6 +173,29 @@ func (p *Policy) CanEditLogic(user string) bool {
return false
}
// 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.
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
}
// GroupsOf returns a copy of the groups a user belongs to.
func (p *Policy) GroupsOf(user string) []string {
src := p.userGroups[strings.TrimSpace(user)]
+2 -2
View File
@@ -6,7 +6,7 @@ func TestCanEditLogic(t *testing.T) {
groups := map[string][]string{"ops": {"carol"}}
// No allowlist configured: everyone with write access may edit logic.
open := New("", nil, groups, nil)
open := New("", nil, groups, nil, nil)
for _, u := range []string{"", "alice", "carol"} {
if !open.CanEditLogic(u) {
t.Errorf("unrestricted: CanEditLogic(%q) = false, want true", u)
@@ -17,7 +17,7 @@ func TestCanEditLogic(t *testing.T) {
}
// Allowlist by username and by group name.
p := New("", nil, groups, []string{"alice", "ops"})
p := New("", nil, groups, []string{"alice", "ops"}, nil)
if !p.LogicRestricted() {
t.Error("LogicRestricted() = false with allowlist set")
}
+100 -5
View File
@@ -8,12 +8,15 @@ import (
"errors"
"io"
"log/slog"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"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"
@@ -31,13 +34,18 @@ type Handler struct {
acl *panelacl.Store
ctrlLogic *controllogic.Store
ctrlEngine *controllogic.Engine
channelFinderURL string // empty if not configured
archiverURL string // empty if not configured
audit audit.Recorder // never nil; audit.Nop when disabled
channelFinderURL string // empty if not configured
archiverURL string // empty if not configured
log *slog.Logger
}
// New creates an API Handler. synth may be nil if the synthetic DS is disabled.
func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, policy *access.Policy, acl *panelacl.Store, ctrlLogic *controllogic.Store, ctrlEngine *controllogic.Engine, channelFinderURL, archiverURL string, log *slog.Logger) *Handler {
// rec records system-affecting mutations; pass audit.Nop() to disable auditing.
func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, policy *access.Policy, acl *panelacl.Store, ctrlLogic *controllogic.Store, ctrlEngine *controllogic.Engine, rec audit.Recorder, channelFinderURL, archiverURL string, log *slog.Logger) *Handler {
if rec == nil {
rec = audit.Nop()
}
return &Handler{
broker: b,
synthetic: synth,
@@ -46,6 +54,7 @@ func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, pol
acl: acl,
ctrlLogic: ctrlLogic,
ctrlEngine: ctrlEngine,
audit: rec,
channelFinderURL: channelFinderURL,
archiverURL: archiverURL,
log: log,
@@ -56,6 +65,7 @@ func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, pol
// Requires Go 1.22+ for method+path routing.
func (h *Handler) Register(mux *http.ServeMux, prefix string) {
mux.HandleFunc("GET "+prefix+"/me", h.getMe)
mux.HandleFunc("GET "+prefix+"/audit", h.getAudit)
mux.HandleFunc("GET "+prefix+"/datasources", h.listDataSources)
mux.HandleFunc("GET "+prefix+"/signals", h.listSignals)
mux.HandleFunc("GET "+prefix+"/signals/search", h.searchSignals)
@@ -118,15 +128,93 @@ func (h *Handler) getMe(w http.ResponseWriter, r *http.Request) {
"level": h.policy.Level(user).String(),
"groups": groups,
"canEditLogic": h.policy.CanEditLogic(user),
"canViewAudit": h.policy.CanViewAudit(user),
})
}
// ── /audit ────────────────────────────────────────────────────────────────────
// getAudit returns audit-log entries matching the query filters. Access is
// restricted to users permitted by CanViewAudit (the audit-readers allowlist).
// Filters: start, end (RFC3339), user, action, ds, signal, limit.
func (h *Handler) getAudit(w http.ResponseWriter, r *http.Request) {
if !h.policy.CanViewAudit(caller(r)) {
jsonError(w, http.StatusForbidden, "you are not permitted to view the audit log")
return
}
q := r.URL.Query()
f := audit.Filter{
Actor: q.Get("user"),
Action: q.Get("action"),
DS: q.Get("ds"),
Signal: q.Get("signal"),
}
if v := q.Get("start"); v != "" {
t, err := time.Parse(time.RFC3339, v)
if err != nil {
jsonError(w, http.StatusBadRequest, "invalid start time (want RFC3339): "+v)
return
}
f.Start = t
}
if v := q.Get("end"); v != "" {
t, err := time.Parse(time.RFC3339, v)
if err != nil {
jsonError(w, http.StatusBadRequest, "invalid end time (want RFC3339): "+v)
return
}
f.End = t
}
if v := q.Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
f.Limit = n
}
}
events, err := h.audit.Query(f)
if err != nil {
h.log.Error("audit query", "err", err)
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
jsonOK(w, events)
}
// ── access helpers ────────────────────────────────────────────────────────────
// caller returns the resolved end-user identity for the request (set by the
// access middleware).
func caller(r *http.Request) string { return access.UserFrom(r.Context()) }
// clientIP returns the best-effort client address for audit attribution,
// preferring the proxy-set X-Forwarded-For / X-Real-IP headers.
func clientIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if i := strings.IndexByte(xff, ','); i >= 0 {
return strings.TrimSpace(xff[:i])
}
return strings.TrimSpace(xff)
}
if xr := r.Header.Get("X-Real-IP"); xr != "" {
return strings.TrimSpace(xr)
}
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
return host
}
return r.RemoteAddr
}
// recordMutation appends a successful configuration change to the audit log.
func (h *Handler) recordMutation(r *http.Request, action, detail string) {
h.audit.Record(audit.Event{
Actor: caller(r),
ActorType: audit.ActorUser,
Action: action,
Detail: detail,
IP: clientIP(r),
Outcome: audit.OutcomeOK,
})
}
// capByGlobal lowers a per-panel/folder permission to what the user's global
// access level allows: read-only users can never exceed read, no-access users
// get nothing.
@@ -490,6 +578,7 @@ func (h *Handler) createInterface(w http.ResponseWriter, r *http.Request) {
h.log.Error("record panel ownership", "id", id, "err", err)
}
}
h.recordMutation(r, "interface.create", id)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(map[string]string{"id": id})
@@ -580,6 +669,7 @@ func (h *Handler) updateInterface(w http.ResponseWriter, r *http.Request) {
}
return
}
h.recordMutation(r, "interface.update", id)
w.WriteHeader(http.StatusNoContent)
}
@@ -721,6 +811,7 @@ func (h *Handler) deleteInterface(w http.ResponseWriter, r *http.Request) {
if err := h.acl.DeletePanel(id); err != nil {
h.log.Error("delete panel ACL", "id", id, "err", err)
}
h.recordMutation(r, "interface.delete", id)
w.WriteHeader(http.StatusNoContent)
}
@@ -1123,6 +1214,7 @@ func (h *Handler) createControlLogic(w http.ResponseWriter, r *http.Request) {
return
}
h.ctrlEngine.Reload()
h.recordMutation(r, "controllogic.create", g.ID+" "+g.Name)
w.WriteHeader(http.StatusCreated)
jsonOK(w, g)
}
@@ -1152,6 +1244,7 @@ func (h *Handler) updateControlLogic(w http.ResponseWriter, r *http.Request) {
return
}
h.ctrlEngine.Reload()
h.recordMutation(r, "controllogic.update", g.ID+" "+g.Name)
jsonOK(w, g)
}
@@ -1164,11 +1257,13 @@ func (h *Handler) deleteControlLogic(w http.ResponseWriter, r *http.Request) {
jsonError(w, http.StatusForbidden, "you are not permitted to edit logic")
return
}
if err := h.ctrlLogic.Delete(r.PathValue("id")); err != nil {
jsonError(w, http.StatusNotFound, "control logic graph not found: "+r.PathValue("id"))
id := r.PathValue("id")
if err := h.ctrlLogic.Delete(id); err != nil {
jsonError(w, http.StatusNotFound, "control logic graph not found: "+id)
return
}
h.ctrlEngine.Reload()
h.recordMutation(r, "controllogic.delete", id)
w.WriteHeader(http.StatusNoContent)
}
+12 -5
View File
@@ -14,6 +14,7 @@ import (
"github.com/uopi/uopi/internal/access"
"github.com/uopi/uopi/internal/api"
"github.com/uopi/uopi/internal/audit"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/controllogic"
"github.com/uopi/uopi/internal/datasource/stub"
@@ -50,10 +51,10 @@ func setup(t *testing.T) (*httptest.Server, func()) {
if err != nil {
t.Fatal("controllogic.NewStore:", err)
}
clEngine := controllogic.NewEngine(ctx, brk, clStore, log)
clEngine := controllogic.NewEngine(ctx, brk, clStore, audit.Nop(), log)
mux := http.NewServeMux()
api.New(brk, nil, store, access.New("", nil, nil, nil), acl, clStore, clEngine, "", "", log).Register(mux, "/api/v1")
api.New(brk, nil, store, access.New("", nil, nil, nil, nil), acl, clStore, clEngine, audit.Nop(), "", "", log).Register(mux, "/api/v1")
srv := httptest.NewServer(mux)
return srv, func() {
@@ -214,7 +215,9 @@ func TestSearchSignals(t *testing.T) {
resp := get(t, srv, "/api/v1/signals/search?q=sine")
assertStatus(t, resp, http.StatusOK)
var signals []struct{ Name string `json:"name"` }
var signals []struct {
Name string `json:"name"`
}
readJSON(t, resp, &signals)
for _, s := range signals {
@@ -259,7 +262,9 @@ func TestInterfaceCRUD(t *testing.T) {
// Create
resp = postRaw(t, srv, "/api/v1/interfaces", "application/xml", []byte(sampleXML))
assertStatus(t, resp, http.StatusCreated)
var created struct{ ID string `json:"id"` }
var created struct {
ID string `json:"id"`
}
readJSON(t, resp, &created)
if created.ID == "" {
t.Fatal("expected non-empty ID from create")
@@ -303,7 +308,9 @@ func TestInterfaceCRUD(t *testing.T) {
t.Fatal("clone:", err)
}
assertStatus(t, resp, http.StatusCreated)
var cloned struct{ ID string `json:"id"` }
var cloned struct {
ID string `json:"id"`
}
readJSON(t, resp, &cloned)
if cloned.ID == created.ID {
t.Error("clone produced same ID as original")
+65
View File
@@ -0,0 +1,65 @@
// Package audit records system-affecting actions (signal writes by users or the
// control-logic engine, interface and control-logic mutations) to an append-only
// SQLite log that audit staff can query later. It is enabled via [audit] in the
// config; when disabled a no-op Recorder is used so call sites need no guards.
package audit
import "time"
// Actor types distinguish human-initiated actions from automated ones.
const (
ActorUser = "user" // action attributed to an authenticated end-user
ActorSystem = "system" // action performed by the control-logic engine
)
// Outcomes record whether the action succeeded.
const (
OutcomeOK = "ok"
OutcomeError = "error"
)
// Event is a single audit record. Optional fields are omitted from the database
// when empty. Time defaults to the current time when zero.
type Event struct {
Time time.Time `json:"time"`
Actor string `json:"actor"` // username, or graph name for system actions
ActorType string `json:"actorType"` // ActorUser | ActorSystem
Action string `json:"action"` // e.g. "signal.write", "interface.update"
DS string `json:"ds,omitempty"` // data source (signal writes)
Signal string `json:"signal,omitempty"` // signal / target name
Value string `json:"value,omitempty"` // serialised written value
Detail string `json:"detail,omitempty"` // free-form context (id, graph, trigger)
IP string `json:"ip,omitempty"` // client address (user actions)
Outcome string `json:"outcome"` // OutcomeOK | OutcomeError
Error string `json:"error,omitempty"` // message when Outcome==OutcomeError
}
// Filter selects a subset of events for Query. Zero-valued fields are ignored.
type Filter struct {
Start time.Time
End time.Time
Actor string
Action string
DS string
Signal string
Limit int // <=0 means a default cap is applied
}
// Recorder appends events and answers queries. Implementations must be safe for
// concurrent use and Record must never block the caller for long.
type Recorder interface {
Record(Event)
Query(Filter) ([]Event, error)
Close() error
}
// nopRecorder is used when auditing is disabled. It discards every event.
type nopRecorder struct{}
func (nopRecorder) Record(Event) {}
func (nopRecorder) Query(Filter) ([]Event, error) { return []Event{}, nil }
func (nopRecorder) Close() error { return nil }
// Nop returns a Recorder that discards everything. Call sites can hold a Recorder
// unconditionally and call Record without checking whether auditing is enabled.
func Nop() Recorder { return nopRecorder{} }
+183
View File
@@ -0,0 +1,183 @@
package audit
import (
"database/sql"
"fmt"
"log/slog"
"strings"
"sync"
"time"
_ "modernc.org/sqlite" // pure-Go SQLite driver (no CGo, keeps the single static binary)
)
// defaultQueryLimit caps how many rows Query returns when the filter does not
// specify a smaller limit, protecting the API and UI from unbounded result sets.
const defaultQueryLimit = 1000
// writeBuffer is the depth of the async insert queue. When full, Record falls
// back to a synchronous insert so events are never silently dropped.
const writeBuffer = 4096
// sqliteRecorder appends events to a SQLite database. Inserts are normally
// handled by a background goroutine so Record does not block the calling write
// path; the queue has a synchronous fallback to guarantee durability under load.
type sqliteRecorder struct {
db *sql.DB
log *slog.Logger
ch chan Event
wg sync.WaitGroup
closed chan struct{}
once sync.Once
}
// NewSQLite opens (creating if needed) the audit database at path and starts the
// background writer. The returned Recorder must be Closed on shutdown.
func NewSQLite(path string, log *slog.Logger) (Recorder, error) {
// WAL + a busy timeout let the async writer and synchronous query/fallback
// paths share the file without "database is locked" errors.
dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)", path)
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("open audit db: %w", err)
}
if _, err := db.Exec(schema); err != nil {
_ = db.Close()
return nil, fmt.Errorf("init audit schema: %w", err)
}
r := &sqliteRecorder{
db: db,
log: log,
ch: make(chan Event, writeBuffer),
closed: make(chan struct{}),
}
r.wg.Add(1)
go r.writeLoop()
return r, nil
}
const schema = `
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts_ns INTEGER NOT NULL,
actor TEXT NOT NULL,
actor_type TEXT NOT NULL,
action TEXT NOT NULL,
ds TEXT NOT NULL DEFAULT '',
signal TEXT NOT NULL DEFAULT '',
value TEXT NOT NULL DEFAULT '',
detail TEXT NOT NULL DEFAULT '',
ip TEXT NOT NULL DEFAULT '',
outcome TEXT NOT NULL DEFAULT 'ok',
error TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit_log(ts_ns);
CREATE INDEX IF NOT EXISTS idx_audit_actor ON audit_log(actor);
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log(action);
`
func (r *sqliteRecorder) Record(e Event) {
if e.Time.IsZero() {
e.Time = time.Now()
}
if e.Outcome == "" {
e.Outcome = OutcomeOK
}
select {
case <-r.closed:
// Recorder is shutting down; best-effort synchronous insert.
r.insert(e)
case r.ch <- e:
default:
// Queue full: write synchronously rather than drop an audit record.
r.insert(e)
}
}
func (r *sqliteRecorder) writeLoop() {
defer r.wg.Done()
for e := range r.ch {
r.insert(e)
}
}
func (r *sqliteRecorder) insert(e Event) {
_, err := r.db.Exec(
`INSERT INTO audit_log (ts_ns, actor, actor_type, action, ds, signal, value, detail, ip, outcome, error)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
e.Time.UnixNano(), e.Actor, e.ActorType, e.Action,
e.DS, e.Signal, e.Value, e.Detail, e.IP, e.Outcome, e.Error,
)
if err != nil {
r.log.Error("audit: insert failed", "action", e.Action, "err", err)
}
}
func (r *sqliteRecorder) Query(f Filter) ([]Event, error) {
var where []string
var args []any
if !f.Start.IsZero() {
where = append(where, "ts_ns >= ?")
args = append(args, f.Start.UnixNano())
}
if !f.End.IsZero() {
where = append(where, "ts_ns <= ?")
args = append(args, f.End.UnixNano())
}
if f.Actor != "" {
where = append(where, "actor = ?")
args = append(args, f.Actor)
}
if f.Action != "" {
where = append(where, "action = ?")
args = append(args, f.Action)
}
if f.DS != "" {
where = append(where, "ds = ?")
args = append(args, f.DS)
}
if f.Signal != "" {
where = append(where, "signal LIKE ?")
args = append(args, "%"+f.Signal+"%")
}
limit := f.Limit
if limit <= 0 || limit > defaultQueryLimit {
limit = defaultQueryLimit
}
q := "SELECT ts_ns, actor, actor_type, action, ds, signal, value, detail, ip, outcome, error FROM audit_log"
if len(where) > 0 {
q += " WHERE " + strings.Join(where, " AND ")
}
q += " ORDER BY ts_ns DESC LIMIT ?"
args = append(args, limit)
rows, err := r.db.Query(q, args...)
if err != nil {
return nil, fmt.Errorf("query audit log: %w", err)
}
defer rows.Close()
out := []Event{}
for rows.Next() {
var e Event
var tsNs int64
if err := rows.Scan(&tsNs, &e.Actor, &e.ActorType, &e.Action,
&e.DS, &e.Signal, &e.Value, &e.Detail, &e.IP, &e.Outcome, &e.Error); err != nil {
return nil, fmt.Errorf("scan audit row: %w", err)
}
e.Time = time.Unix(0, tsNs)
out = append(out, e)
}
return out, rows.Err()
}
func (r *sqliteRecorder) Close() error {
r.once.Do(func() {
close(r.closed)
close(r.ch)
})
r.wg.Wait()
return r.db.Close()
}
+68
View File
@@ -0,0 +1,68 @@
package audit
import (
"log/slog"
"path/filepath"
"testing"
"time"
)
func TestSQLiteRoundTrip(t *testing.T) {
path := filepath.Join(t.TempDir(), "audit.db")
rec, err := NewSQLite(path, slog.Default())
if err != nil {
t.Fatal("NewSQLite:", err)
}
defer rec.Close()
base := time.Now()
rec.Record(Event{Time: base, Actor: "alice", ActorType: ActorUser, Action: "signal.write", DS: "epics", Signal: "PV:A", Value: "1.5"})
rec.Record(Event{Time: base.Add(time.Second), Actor: "flow1", ActorType: ActorSystem, Action: "signal.write", DS: "epics", Signal: "PV:B", Value: "0"})
rec.Record(Event{Time: base.Add(2 * time.Second), Actor: "bob", ActorType: ActorUser, Action: "interface.update", Detail: "panel-1", Outcome: OutcomeError, Error: "denied"})
// Flush the async writer.
if err := rec.Close(); err != nil {
t.Fatal("Close:", err)
}
rec, err = NewSQLite(path, slog.Default())
if err != nil {
t.Fatal("reopen:", err)
}
defer rec.Close()
all, err := rec.Query(Filter{})
if err != nil {
t.Fatal("Query:", err)
}
if len(all) != 3 {
t.Fatalf("got %d events, want 3", len(all))
}
// Newest first.
if all[0].Actor != "bob" {
t.Errorf("first actor = %q, want bob", all[0].Actor)
}
byActor, err := rec.Query(Filter{Actor: "alice"})
if err != nil {
t.Fatal("Query actor:", err)
}
if len(byActor) != 1 || byActor[0].Signal != "PV:A" {
t.Errorf("actor filter = %+v, want one PV:A event", byActor)
}
byAction, err := rec.Query(Filter{Action: "signal.write"})
if err != nil {
t.Fatal("Query action:", err)
}
if len(byAction) != 2 {
t.Errorf("action filter returned %d, want 2", len(byAction))
}
since, err := rec.Query(Filter{Start: base.Add(1500 * time.Millisecond)})
if err != nil {
t.Fatal("Query start:", err)
}
if len(since) != 1 || since[0].Actor != "bob" {
t.Errorf("time filter = %+v, want one bob event", since)
}
}
+26 -4
View File
@@ -12,10 +12,23 @@ import (
type Config struct {
Server ServerConfig `toml:"server"`
Datasource DatasourceConfig `toml:"datasource"`
Audit AuditConfig `toml:"audit"`
// Groups are named sets of users, referenced by panel sharing rules.
Groups []GroupDef `toml:"groups"`
}
// 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.
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.
type GroupDef struct {
Name string `toml:"name"`
@@ -71,10 +84,10 @@ type StubConfig struct {
}
type EPICSConfig struct {
Enabled bool `toml:"enabled"`
CAAddrList string `toml:"ca_addr_list"`
ArchiveURL string `toml:"archive_url"`
ChannelFinderURL string `toml:"channel_finder_url"`
Enabled bool `toml:"enabled"`
CAAddrList string `toml:"ca_addr_list"`
ArchiveURL string `toml:"archive_url"`
ChannelFinderURL string `toml:"channel_finder_url"`
AutoSyncFilter string `toml:"auto_sync_filter"`
AutoSyncFromArchiver bool `toml:"auto_sync_from_archiver"`
PVNames []string `toml:"pv_names"`
@@ -141,6 +154,15 @@ func applyEnv(cfg *Config) {
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
}
+75 -10
View File
@@ -8,8 +8,10 @@ import (
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/uopi/uopi/internal/audit"
"github.com/uopi/uopi/internal/broker"
)
@@ -25,23 +27,46 @@ const (
type Engine struct {
broker *broker.Broker
store *Store
audit audit.Recorder
log *slog.Logger
root context.Context
mu sync.Mutex
cancel context.CancelFunc // cancels the current generation
wg *sync.WaitGroup // tracks the current generation's goroutines
mu sync.Mutex
cancel context.CancelFunc // cancels the current generation
wg *sync.WaitGroup // tracks the current generation's goroutines
// notifier delivers action.dialog requests to connected clients. Stored in
// an atomic so flow goroutines can read it without taking e.mu (which Reload
// holds while it waits for those same goroutines to drain).
notifier atomic.Value // notifierBox
// Shared live signal cache for the current generation (key "ds\0name").
liveMu sync.RWMutex
live map[string]float64
}
// NewEngine creates an engine bound to root. Call Reload to start it.
func NewEngine(root context.Context, brk *broker.Broker, store *Store, log *slog.Logger) *Engine {
// notifierBox wraps a Notifier so atomic.Value always sees one concrete type.
type notifierBox struct{ n Notifier }
// dialogSeq generates unique action.dialog ids across all graphs.
var dialogSeq uint64
// SetNotifier installs the sink for action.dialog requests. Safe to call once
// at startup before or after Reload; it is read lock-free by running flows.
func (e *Engine) SetNotifier(n Notifier) {
e.notifier.Store(notifierBox{n: n})
}
// NewEngine creates an engine bound to root. Call Reload to start it. rec records
// the writes performed by flows; pass audit.Nop() to disable auditing.
func NewEngine(root context.Context, brk *broker.Broker, store *Store, rec audit.Recorder, log *slog.Logger) *Engine {
if rec == nil {
rec = audit.Nop()
}
return &Engine{
broker: brk,
store: store,
audit: rec,
log: log,
root: root,
live: map[string]float64{},
@@ -227,9 +252,45 @@ func (e *Engine) write(cg *compiledGraph, target string, val float64) {
e.log.Warn("control logic: write to unknown data source", "ds", ds, "signal", name)
return
}
ev := audit.Event{
Actor: cg.name,
ActorType: audit.ActorSystem,
Action: "signal.write",
DS: ds,
Signal: name,
Value: strconv.FormatFloat(val, 'g', -1, 64),
Detail: "control logic: " + cg.name,
Outcome: audit.OutcomeOK,
}
if err := src.Write(e.root, name, val); err != nil {
e.log.Warn("control logic: write failed", "ds", ds, "signal", name, "err", err)
ev.Outcome = audit.OutcomeError
ev.Error = err.Error()
}
e.audit.Record(ev)
}
// emitDialog delivers an action.dialog node's request to the installed Notifier
// (the WebSocket dialog hub). It is lock-free so it never deadlocks against a
// concurrent Reload that is waiting for this flow goroutine to finish.
func (e *Engine) emitDialog(n Node) {
box, _ := e.notifier.Load().(notifierBox)
if box.n == nil {
return
}
kind := strings.TrimSpace(n.param("kind"))
if kind != "error" && kind != "input" {
kind = "info"
}
box.n.Notify(Dialog{
ID: strconv.FormatUint(atomic.AddUint64(&dialogSeq, 1), 10),
Kind: kind,
Title: n.param("title"),
Message: n.param("message"),
Target: strings.TrimSpace(n.param("target")),
Users: splitCSV(n.param("users")),
Groups: splitCSV(n.param("groups")),
})
}
// ── compiled graph ─────────────────────────────────────────────────────────────
@@ -245,11 +306,11 @@ type compiledGraph struct {
genCtx context.Context
wg *sync.WaitGroup
name string
byId map[string]Node
out map[string][]wireOut
inc map[string][]string // incoming source ids per node (for gates)
refs map[string]RefLite // unique signals to subscribe (excl. sys/local)
name string
byId map[string]Node
out map[string][]wireOut
inc map[string][]string // incoming source ids per node (for gates)
refs map[string]RefLite // unique signals to subscribe (excl. sys/local)
watchers map[string][]string // signal key → trigger node ids
luaNodes map[string]*luaRuntime
@@ -638,6 +699,10 @@ func (cg *compiledGraph) run(nodeID string, ctx *runCtx) {
cg.runLua(node.ID, ctx)
cg.follow(node.ID, "out", ctx)
case "action.dialog":
cg.engine.emitDialog(node)
cg.follow(node.ID, "out", ctx)
default:
cg.follow(node.ID, "out", ctx)
}
+2
View File
@@ -29,6 +29,8 @@ package controllogic
// action.delay — waits `ms` before continuing.
// action.log — logs an expression value to the server log.
// action.lua — runs a sandboxed Lua script with get/set/log host funcs.
// action.dialog — pushes an info/error/input dialog to connected clients
// filtered by user/group; input responses write `target`.
// Node is a single node in a control-logic graph. Params are stored as strings
// (matching the panel logic model) and parsed per-kind by the engine.
+37
View File
@@ -0,0 +1,37 @@
package controllogic
import "strings"
// Dialog is a user-facing notification or input request emitted by an
// action.dialog node. It is delivered to connected clients whose identity
// matches Users/Groups (both empty = everyone). For an "input" dialog the
// client's response is written back to Target (a "ds:name" reference, e.g.
// "srv:approved") so control logic can read it on a later activation.
type Dialog struct {
ID string `json:"id"`
Kind string `json:"kind"` // "info" | "error" | "input"
Title string `json:"title"`
Message string `json:"message"`
Target string `json:"target,omitempty"`
Users []string `json:"users,omitempty"`
Groups []string `json:"groups,omitempty"`
}
// Notifier delivers control-logic dialogs to connected clients. The server
// implements it; the engine calls Notify when an action.dialog node runs.
// Notify must not block (the hub fans out without waiting on slow clients).
type Notifier interface {
Notify(Dialog)
}
// splitCSV parses a comma-separated user/group filter into trimmed,
// non-empty tokens. An empty string yields a nil slice (no filter).
func splitCSV(s string) []string {
var out []string
for _, p := range strings.Split(s, ",") {
if t := strings.TrimSpace(p); t != "" {
out = append(out, t)
}
}
return out
}
+29 -7
View File
@@ -7,6 +7,7 @@ import (
"os"
"path/filepath"
"sync"
"time"
)
const definitionsFile = "controllogic.json"
@@ -17,16 +18,18 @@ var ErrNotFound = errors.New("control logic graph not found")
// Store persists control-logic graphs as a single JSON file in the storage dir.
// Writes are atomic (tmp file + rename); all access is mutex-guarded.
type Store struct {
mu sync.RWMutex
path string
items map[string]Graph
mu sync.RWMutex
path string
trashDir string
items map[string]Graph
}
// NewStore opens (or initialises) the control-logic store under storageDir.
func NewStore(storageDir string) (*Store, error) {
s := &Store{
path: filepath.Join(storageDir, definitionsFile),
items: map[string]Graph{},
path: filepath.Join(storageDir, definitionsFile),
trashDir: filepath.Join(storageDir, "trash", "controllogic"),
items: map[string]Graph{},
}
if err := s.load(); err != nil {
return nil, err
@@ -99,13 +102,32 @@ func (s *Store) Save(g Graph) error {
return s.saveLocked()
}
// Delete removes a graph by id.
// Delete removes a graph by id, first writing a copy into the trash folder so it
// can be recovered if needed. The trash backup is best-effort: a failure to write
// it does not prevent the delete (but is reported).
func (s *Store) Delete(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.items[id]; !ok {
g, ok := s.items[id]
if !ok {
return ErrNotFound
}
if err := s.trash(g); err != nil {
return fmt.Errorf("move control logic to trash: %w", err)
}
delete(s.items, id)
return s.saveLocked()
}
// trash writes a single graph as a timestamped JSON file under the trash folder.
func (s *Store) trash(g Graph) error {
if err := os.MkdirAll(s.trashDir, 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(g, "", " ")
if err != nil {
return err
}
dst := filepath.Join(s.trashDir, fmt.Sprintf("%s.%d.json", g.ID, time.Now().UnixMilli()))
return os.WriteFile(dst, data, 0o644)
}
+2
View File
@@ -112,6 +112,8 @@ func fetchArchiveHistory(ctx context.Context, archiveURL, signal string, start,
Timestamp: ts,
Data: data,
Quality: q,
Severity: p.Severity,
Status: p.Status,
})
}
+9 -2
View File
@@ -22,7 +22,7 @@ import (
//
//export goCAMonitorCallback
func goCAMonitorCallback(handle C.uintptr_t, dbrType C.int, count C.long,
dbr unsafe.Pointer, severity C.int, epicsTimeSecs C.double) {
dbr unsafe.Pointer, severity C.int, status C.int, epicsTimeSecs C.double) {
h := uintptr(handle)
@@ -79,7 +79,13 @@ func goCAMonitorCallback(handle C.uintptr_t, dbrType C.int, count C.long,
data = float64(0)
}
v := datasource.Value{Timestamp: ts, Data: data, Quality: q}
v := datasource.Value{
Timestamp: ts,
Data: data,
Quality: q,
Severity: int(severity),
Status: int(status),
}
// Look up the subscription channel under the global handle table lock and
// perform a non-blocking send so we never stall the CA callback thread.
@@ -129,6 +135,7 @@ func goCAConnectionCallback(handle C.uintptr_t, connected C.int) {
Timestamp: time.Now(),
Data: float64(0),
Quality: datasource.QualityBad,
Severity: 3, // INVALID
}
select {
case ch <- v:
+10 -3
View File
@@ -13,7 +13,7 @@
* goCAConnectionCallback is called when a channel connects or disconnects.
*/
extern void goCAMonitorCallback(uintptr_t handle, int dbrType, long count,
void *dbr, int severity,
void *dbr, int severity, int status,
double epicsTimeSecs);
extern void goCAConnectionCallback(uintptr_t handle, int connected);
@@ -30,9 +30,10 @@ static void caMonitorCallbackShim(struct event_handler_args args) {
double timeSecs = 0.0;
int severity = 0;
int status = 0;
/*
* Determine the timestamp and alarm severity from the DBR type.
* Determine the timestamp and alarm severity/status from the DBR type.
* We request DBR_TIME_* types so the timestamp is embedded in the value
* buffer right after the alarm fields (struct dbr_time_double et al.).
*/
@@ -41,36 +42,42 @@ static void caMonitorCallbackShim(struct event_handler_args args) {
const struct dbr_time_double *p = (const struct dbr_time_double *)args.dbr;
timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9;
severity = (int)p->severity;
status = (int)p->status;
break;
}
case DBR_TIME_FLOAT: {
const struct dbr_time_float *p = (const struct dbr_time_float *)args.dbr;
timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9;
severity = (int)p->severity;
status = (int)p->status;
break;
}
case DBR_TIME_LONG: {
const struct dbr_time_long *p = (const struct dbr_time_long *)args.dbr;
timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9;
severity = (int)p->severity;
status = (int)p->status;
break;
}
case DBR_TIME_SHORT: {
const struct dbr_time_short *p = (const struct dbr_time_short *)args.dbr;
timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9;
severity = (int)p->severity;
status = (int)p->status;
break;
}
case DBR_TIME_STRING: {
const struct dbr_time_string *p = (const struct dbr_time_string *)args.dbr;
timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9;
severity = (int)p->severity;
status = (int)p->status;
break;
}
case DBR_TIME_ENUM: {
const struct dbr_time_enum *p = (const struct dbr_time_enum *)args.dbr;
timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9;
severity = (int)p->severity;
status = (int)p->status;
break;
}
default:
@@ -78,7 +85,7 @@ static void caMonitorCallbackShim(struct event_handler_args args) {
}
goCAMonitorCallback((uintptr_t)args.usr, (int)args.type, (long)args.count,
(void *)args.dbr, severity, timeSecs);
(void *)args.dbr, severity, status, timeSecs);
}
/*
+2
View File
@@ -275,6 +275,8 @@ func (e *EPICS) timeValueToDS(signal string, tv proto.TimeValue) datasource.Valu
Timestamp: tv.Timestamp,
Data: data,
Quality: quality,
Severity: int(tv.Severity),
Status: int(tv.Status),
}
}
+2
View File
@@ -45,6 +45,8 @@ type Value struct {
Timestamp time.Time
Data any // float64 | []float64 | string | int64 | bool
Quality Quality
Severity int // raw EPICS alarm severity (0=NO_ALARM,1=MINOR,2=MAJOR,3=INVALID); 0 for sources without alarm info
Status int // raw EPICS alarm status (.STAT); 0 for sources without alarm info
MetaUpdate bool // if true, metadata was refreshed — dispatcher should re-send meta
}
+23
View File
@@ -180,6 +180,7 @@ func structToValue(sv pvdata.StructValue) datasource.Value {
val := fieldByName(sv, "value")
ts := extractTimestamp(sv)
quality := extractQuality(sv)
severity, status := extractSeverityStatus(sv)
var data any
if val != nil {
@@ -190,6 +191,8 @@ func structToValue(sv pvdata.StructValue) datasource.Value {
Timestamp: ts,
Data: data,
Quality: quality,
Severity: severity,
Status: status,
}
}
@@ -329,6 +332,26 @@ func extractQuality(sv pvdata.StructValue) datasource.Quality {
}
}
// extractSeverityStatus pulls the raw EPICS alarm severity and status from the
// NTScalar "alarm" struct. Returns (0, 0) when no alarm struct is present.
func extractSeverityStatus(sv pvdata.StructValue) (severity, status int) {
alarm := structByName(sv, "alarm")
if alarm == nil {
return 0, 0
}
if v := fieldByName(*alarm, "severity"); v != nil {
if s, ok := v.(int32); ok {
severity = int(s)
}
}
if v := fieldByName(*alarm, "status"); v != nil {
if s, ok := v.(int32); ok {
status = int(s)
}
}
return severity, status
}
func toFloat64(v any) (float64, bool) {
switch x := v.(type) {
case float64:
+212
View File
@@ -0,0 +1,212 @@
// Package servervar provides a small persistent key/value data source for
// "server variables": named scalar values that the server-side control-logic
// engine writes (e.g. the state of a sequence) and that interface panels can
// read live. Panels may read any variable; writes from panels are gated to
// control-logic editors in the WebSocket write handler.
package servervar
import (
"context"
"encoding/json"
"os"
"path/filepath"
"sort"
"sync"
"time"
"github.com/uopi/uopi/internal/datasource"
)
const fileName = "servervars.json"
type variable struct {
value float64
ts time.Time
}
// Source is the "srv" data source. Variables are created on first write and
// persisted so their last value survives a restart.
type Source struct {
path string
mu sync.RWMutex
vars map[string]*variable
subs map[string]map[int]chan<- datasource.Value
nextID int
}
// New opens (or initialises) the server-variable store under storageDir.
func New(storageDir string) (*Source, error) {
s := &Source{
path: filepath.Join(storageDir, fileName),
vars: map[string]*variable{},
subs: map[string]map[int]chan<- datasource.Value{},
}
if err := s.load(); err != nil {
return nil, err
}
return s, nil
}
func (s *Source) load() error {
data, err := os.ReadFile(s.path)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return err
}
var raw map[string]float64
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
now := time.Now()
for name, v := range raw {
s.vars[name] = &variable{value: v, ts: now}
}
return nil
}
// saveLocked persists the current values atomically. Caller holds s.mu.
func (s *Source) saveLocked() {
raw := make(map[string]float64, len(s.vars))
for name, v := range s.vars {
raw[name] = v.value
}
data, err := json.MarshalIndent(raw, "", " ")
if err != nil {
return
}
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, data, 0o644); err != nil {
return
}
_ = os.Rename(tmp, s.path)
}
func meta(name string) datasource.Metadata {
return datasource.Metadata{
Name: name,
Type: datasource.TypeFloat64,
Description: "Server variable",
Writable: true,
}
}
// Name implements datasource.DataSource.
func (s *Source) Name() string { return "srv" }
// Connect is a no-op — the store is opened in New.
func (s *Source) Connect(_ context.Context) error { return nil }
// ListSignals returns metadata for every defined server variable.
func (s *Source) ListSignals(_ context.Context) ([]datasource.Metadata, error) {
s.mu.RLock()
defer s.mu.RUnlock()
names := make([]string, 0, len(s.vars))
for name := range s.vars {
names = append(names, name)
}
sort.Strings(names)
out := make([]datasource.Metadata, 0, len(names))
for _, name := range names {
out = append(out, meta(name))
}
return out, nil
}
// GetMetadata returns metadata for a single variable. Unknown names still report
// writable metadata so that control logic / authorised panels may create them.
func (s *Source) GetMetadata(_ context.Context, signal string) (datasource.Metadata, error) {
return meta(signal), nil
}
// Subscribe registers ch for updates and immediately delivers the current value.
func (s *Source) Subscribe(ctx context.Context, signal string, ch chan<- datasource.Value) (datasource.CancelFunc, error) {
s.mu.Lock()
if s.subs[signal] == nil {
s.subs[signal] = map[int]chan<- datasource.Value{}
}
id := s.nextID
s.nextID++
s.subs[signal][id] = ch
cur, ok := s.vars[signal]
var first datasource.Value
if ok {
first = datasource.Value{Timestamp: cur.ts, Data: cur.value, Quality: datasource.QualityGood}
}
s.mu.Unlock()
if ok {
go func() {
select {
case ch <- first:
case <-ctx.Done():
}
}()
}
return func() {
s.mu.Lock()
if m := s.subs[signal]; m != nil {
delete(m, id)
if len(m) == 0 {
delete(s.subs, signal)
}
}
s.mu.Unlock()
}, nil
}
func toFloat64(v any) float64 {
switch x := v.(type) {
case float64:
return x
case float32:
return float64(x)
case int:
return float64(x)
case int64:
return float64(x)
case bool:
if x {
return 1
}
return 0
case json.Number:
f, _ := x.Float64()
return f
default:
return 0
}
}
// Write sets a variable (creating it if needed), persists, and fans the new value
// out to all subscribers.
func (s *Source) Write(_ context.Context, signal string, value any) error {
v := toFloat64(value)
s.mu.Lock()
now := time.Now()
s.vars[signal] = &variable{value: v, ts: now}
s.saveLocked()
subs := make([]chan<- datasource.Value, 0, len(s.subs[signal]))
for _, ch := range s.subs[signal] {
subs = append(subs, ch)
}
s.mu.Unlock()
upd := datasource.Value{Timestamp: now, Data: v, Quality: datasource.QualityGood}
for _, ch := range subs {
select {
case ch <- upd:
default:
}
}
return nil
}
// History is not supported.
func (s *Source) History(_ context.Context, _ string, _, _ time.Time, _ int) ([]datasource.Value, error) {
return nil, datasource.ErrHistoryUnavailable
}
@@ -0,0 +1,74 @@
package servervar
import (
"context"
"testing"
"time"
"github.com/uopi/uopi/internal/datasource"
)
func TestWriteSubscribePersist(t *testing.T) {
dir := t.TempDir()
s, err := New(dir)
if err != nil {
t.Fatalf("New: %v", err)
}
ctx := context.Background()
if err := s.Write(ctx, "seq_state", 3.0); err != nil {
t.Fatalf("Write: %v", err)
}
// Subscribe should deliver the current value immediately.
ch := make(chan datasource.Value, 4)
cancel, err := s.Subscribe(ctx, "seq_state", ch)
if err != nil {
t.Fatalf("Subscribe: %v", err)
}
defer cancel()
select {
case v := <-ch:
if got := v.Data.(float64); got != 3.0 {
t.Fatalf("initial value = %v, want 3", got)
}
case <-time.After(time.Second):
t.Fatal("no initial value delivered")
}
// A later write fans out to the subscriber.
if err := s.Write(ctx, "seq_state", 7.0); err != nil {
t.Fatalf("Write 2: %v", err)
}
select {
case v := <-ch:
if got := v.Data.(float64); got != 7.0 {
t.Fatalf("updated value = %v, want 7", got)
}
case <-time.After(time.Second):
t.Fatal("no update delivered")
}
// ListSignals reports the variable.
sigs, err := s.ListSignals(ctx)
if err != nil || len(sigs) != 1 || sigs[0].Name != "seq_state" {
t.Fatalf("ListSignals = %+v, err %v", sigs, err)
}
// A fresh store over the same dir recovers the last value.
s2, err := New(dir)
if err != nil {
t.Fatalf("reopen: %v", err)
}
ch2 := make(chan datasource.Value, 1)
cancel2, _ := s2.Subscribe(ctx, "seq_state", ch2)
defer cancel2()
select {
case v := <-ch2:
if got := v.Data.(float64); got != 7.0 {
t.Fatalf("persisted value = %v, want 7", got)
}
case <-time.After(time.Second):
t.Fatal("persisted value not delivered")
}
}
@@ -9,6 +9,7 @@ type SignalDef struct {
Signal string `json:"signal"` // upstream signal name (or "" for constant)
Inputs []InputRef `json:"inputs"` // alternative multi-input format
Pipeline []NodeDef `json:"pipeline"` // ordered list of DSP nodes
Graph *Graph `json:"graph,omitempty"` // DAG form (preferred when present)
Meta MetaOverride `json:"meta"` // optional metadata overrides
// Visibility controls who sees this signal in the signal tree:
@@ -34,6 +35,37 @@ type NodeDef struct {
Params map[string]any `json:"params,omitempty"`
}
// Graph is the DAG form of a synthetic signal: a set of nodes (sources, ops and
// one output) wired together by explicit per-node ordered input lists. It
// supersedes the linear Inputs+Pipeline form: when SignalDef.Graph is set it is
// authoritative; otherwise the legacy linear fields are converted into an
// equivalent graph at load time (see toGraph).
type Graph struct {
Nodes []GraphNode `json:"nodes"`
Output string `json:"output"` // id of the output node
}
// GraphNode is one node in a Graph.
//
// kind=="source": carries DS+Signal; has no Inputs (a graph root).
// kind=="op": carries Op + Params; Inputs lists upstream node IDs in the
// order the op receives them (input 0, 1, … e.g. ab, a÷b).
// kind=="output": Inputs has a single upstream node whose value is the result.
//
// X/Y are the editor layout coordinates, persisted so a reloaded graph keeps its
// shape; they have no effect on evaluation.
type GraphNode struct {
ID string `json:"id"`
Kind string `json:"kind"`
Op string `json:"op,omitempty"`
Params map[string]any `json:"params,omitempty"`
DS string `json:"ds,omitempty"`
Signal string `json:"signal,omitempty"`
Inputs []string `json:"inputs,omitempty"`
X float64 `json:"x,omitempty"`
Y float64 `json:"y,omitempty"`
}
// MetaOverride allows the synthetic signal to override display metadata.
type MetaOverride struct {
Unit string `json:"unit,omitempty"`
+25 -14
View File
@@ -38,21 +38,32 @@ func stringParam(params map[string]any, key string) string {
return s
}
// BuildPipeline converts a []NodeDef (from JSON) into a []dsp.Node ready for
// execution. JSON numbers are float64, so all numeric params are handled as
// float64 regardless of the final type needed.
func BuildPipeline(defs []NodeDef) ([]dsp.Node, error) {
nodes := make([]dsp.Node, 0, len(defs))
for i, d := range defs {
n, err := buildNode(d)
if err != nil {
return nil, fmt.Errorf("pipeline node %d (%q): %w", i, d.Type, err)
}
nodes = append(nodes, n)
// stringSliceParam extracts a []string from params; JSON arrays decode to []any,
// so each element is coerced via its string value. Returns nil if missing.
func stringSliceParam(params map[string]any, key string) []string {
if params == nil {
return nil
}
return nodes, nil
v, ok := params[key]
if !ok {
return nil
}
arr, ok := v.([]any)
if !ok {
return nil
}
out := make([]string, 0, len(arr))
for _, e := range arr {
if s, ok := e.(string); ok && s != "" {
out = append(out, s)
}
}
return out
}
// buildNode converts a single NodeDef (from JSON) into a dsp.Node ready for
// execution. JSON numbers are float64, so all numeric params are handled as
// float64 regardless of the final type needed.
func buildNode(d NodeDef) (dsp.Node, error) {
p := d.Params
switch d.Type {
@@ -100,7 +111,7 @@ func buildNode(d NodeDef) (dsp.Node, error) {
}, nil
case "expr":
return &dsp.ExprNode{Expr: stringParam(p, "expr")}, nil
return &dsp.ExprNode{Expr: stringParam(p, "expr"), Vars: stringSliceParam(p, "vars")}, nil
case "lowpass":
order := int(floatParam(p, "order"))
@@ -113,7 +124,7 @@ func buildNode(d NodeDef) (dsp.Node, error) {
}, nil
case "lua":
return &dsp.LuaNode{Script: stringParam(p, "script")}, nil
return &dsp.LuaNode{Script: stringParam(p, "script"), Vars: stringSliceParam(p, "vars")}, nil
default:
return nil, fmt.Errorf("unknown node type %q", d.Type)
+199
View File
@@ -0,0 +1,199 @@
package synthetic
import (
"errors"
"fmt"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/dsp"
)
// runtimeGraph is the executable form of a synthetic signal's DAG. Nodes are
// held in topological order so a single forward pass computes every value with
// each node's inputs already resolved. Op-node state maps persist across
// evaluations (for stateful nodes like moving_average / lua).
type runtimeGraph struct {
order []*rtNode // topological order (sources first, output last)
sources []rtSource // source nodes, in topological order
outputID string // id of the output node
}
type rtNode struct {
id string
kind string // source | op | output
op dsp.Node // set for kind==op
state map[string]any // persistent per-node state (op only)
inputs []string // upstream node ids, in input order
}
type rtSource struct {
id string
ref broker.SignalRef
}
// sourceRefs returns the broker references for every source node, in a stable
// order matching rg.sources.
func (rg *runtimeGraph) sourceRefs() []broker.SignalRef {
refs := make([]broker.SignalRef, len(rg.sources))
for i, s := range rg.sources {
refs[i] = s.ref
}
return refs
}
// eval computes the output value given the latest value for each source node
// (keyed by source node id). Nodes are visited in topological order so every
// input is already present in vals by the time a node is processed.
func (rg *runtimeGraph) eval(sourceVals map[string]float64) (float64, error) {
vals := make(map[string]float64, len(rg.order))
for id, v := range sourceVals {
vals[id] = v
}
for _, n := range rg.order {
switch n.kind {
case "op":
in := make([]float64, len(n.inputs))
for i, id := range n.inputs {
in[i] = vals[id]
}
r, err := n.op.Process(in, n.state)
if err != nil {
return 0, fmt.Errorf("node %s (%s): %w", n.id, n.op.Type(), err)
}
vals[n.id] = r
case "output":
if len(n.inputs) > 0 {
vals[n.id] = vals[n.inputs[0]]
}
}
}
return vals[rg.outputID], nil
}
// compileGraph converts a SignalDef into an executable runtimeGraph. When the
// def carries an explicit Graph it is used directly; otherwise the legacy
// Inputs+Pipeline form is converted to an equivalent linear graph (see toGraph).
func compileGraph(def SignalDef) (*runtimeGraph, error) {
g := toGraph(def)
if g == nil || len(g.Nodes) == 0 {
return &runtimeGraph{}, nil
}
order, err := topoOrder(g)
if err != nil {
return nil, err
}
rg := &runtimeGraph{outputID: g.Output}
for _, gn := range order {
switch gn.Kind {
case "source":
rg.sources = append(rg.sources, rtSource{id: gn.ID, ref: broker.SignalRef{DS: gn.DS, Name: gn.Signal}})
case "op":
node, err := buildNode(NodeDef{Type: gn.Op, Params: gn.Params})
if err != nil {
return nil, fmt.Errorf("node %q: %w", gn.ID, err)
}
rg.order = append(rg.order, &rtNode{id: gn.ID, kind: "op", op: node, state: map[string]any{}, inputs: gn.Inputs})
case "output":
rg.outputID = gn.ID
rg.order = append(rg.order, &rtNode{id: gn.ID, kind: "output", inputs: gn.Inputs})
default:
return nil, fmt.Errorf("node %q: unknown kind %q", gn.ID, gn.Kind)
}
}
return rg, nil
}
// topoOrder returns the graph's nodes in a topological (dependency-first) order,
// treating each node's Inputs as its predecessors. It errors on dangling input
// references or cycles.
func topoOrder(g *Graph) ([]GraphNode, error) {
byID := make(map[string]GraphNode, len(g.Nodes))
for _, n := range g.Nodes {
byID[n.ID] = n
}
indeg := make(map[string]int, len(g.Nodes))
succ := make(map[string][]string, len(g.Nodes))
for _, n := range g.Nodes {
if _, ok := indeg[n.ID]; !ok {
indeg[n.ID] = 0
}
for _, in := range n.Inputs {
if _, ok := byID[in]; !ok {
return nil, fmt.Errorf("node %q references unknown input %q", n.ID, in)
}
indeg[n.ID]++
succ[in] = append(succ[in], n.ID)
}
}
// Seed the queue with roots, preserving the node slice order for determinism.
queue := make([]string, 0, len(g.Nodes))
for _, n := range g.Nodes {
if indeg[n.ID] == 0 {
queue = append(queue, n.ID)
}
}
order := make([]GraphNode, 0, len(g.Nodes))
for len(queue) > 0 {
id := queue[0]
queue = queue[1:]
order = append(order, byID[id])
for _, s := range succ[id] {
indeg[s]--
if indeg[s] == 0 {
queue = append(queue, s)
}
}
}
if len(order) != len(g.Nodes) {
return nil, errors.New("graph contains a cycle")
}
return order, nil
}
// toGraph returns the DAG for a SignalDef. If def.Graph is set it is returned
// as-is. Otherwise the legacy linear form is converted: each input signal
// becomes a source node, the pipeline becomes a chain of op nodes (the first op
// receiving every source, each later op the previous op's output), terminated by
// an output node. With no pipeline the output takes the first source directly,
// matching the old runPipeline behaviour.
func toGraph(def SignalDef) *Graph {
if def.Graph != nil && len(def.Graph.Nodes) > 0 {
return def.Graph
}
inputs := def.Inputs
if len(inputs) == 0 && def.DS != "" && def.Signal != "" {
inputs = []InputRef{{DS: def.DS, Signal: def.Signal}}
}
nodes := make([]GraphNode, 0, len(inputs)+len(def.Pipeline)+1)
srcIDs := make([]string, 0, len(inputs))
for i, inp := range inputs {
id := fmt.Sprintf("s%d", i)
nodes = append(nodes, GraphNode{ID: id, Kind: "source", DS: inp.DS, Signal: inp.Signal})
srcIDs = append(srcIDs, id)
}
opIDs := make([]string, 0, len(def.Pipeline))
for i, nd := range def.Pipeline {
id := fmt.Sprintf("p%d", i)
var ins []string
if i == 0 {
ins = srcIDs
} else {
ins = []string{opIDs[i-1]}
}
nodes = append(nodes, GraphNode{ID: id, Kind: "op", Op: nd.Type, Params: nd.Params, Inputs: ins})
opIDs = append(opIDs, id)
}
var outInputs []string
if len(opIDs) > 0 {
outInputs = []string{opIDs[len(opIDs)-1]}
} else if len(srcIDs) > 0 {
outInputs = []string{srcIDs[0]}
}
nodes = append(nodes, GraphNode{ID: "out", Kind: "output", Inputs: outInputs})
return &Graph{Nodes: nodes, Output: "out"}
}
+130
View File
@@ -0,0 +1,130 @@
package synthetic
import (
"math"
"testing"
)
// evalDef compiles a SignalDef and evaluates it against per-source values keyed
// by source node id.
func evalDef(t *testing.T, def SignalDef, srcVals map[string]float64) float64 {
t.Helper()
rg, err := compileGraph(def)
if err != nil {
t.Fatalf("compileGraph: %v", err)
}
out, err := rg.eval(srcVals)
if err != nil {
t.Fatalf("eval: %v", err)
}
return out
}
// TestGraphMultiInputDAG verifies that an intermediate op can take two
// independently-wired sources — the capability the old linear pipeline lacked.
func TestGraphMultiInputDAG(t *testing.T) {
def := SignalDef{
Name: "diff",
Graph: &Graph{
Output: "out",
Nodes: []GraphNode{
{ID: "a", Kind: "source", DS: "x", Signal: "left"},
{ID: "b", Kind: "source", DS: "x", Signal: "right"},
{ID: "sub", Kind: "op", Op: "subtract", Inputs: []string{"a", "b"}},
{ID: "out", Kind: "output", Inputs: []string{"sub"}},
},
},
}
got := evalDef(t, def, map[string]float64{"a": 10, "b": 3})
if got != 7 {
t.Errorf("subtract DAG: want 7, got %v", got)
}
}
// TestGraphExprNamedInputs verifies expr nodes bind arbitrary named inputs in
// wired order.
func TestGraphExprNamedInputs(t *testing.T) {
def := SignalDef{
Name: "formula",
Graph: &Graph{
Output: "out",
Nodes: []GraphNode{
{ID: "a", Kind: "source", DS: "x", Signal: "p"},
{ID: "b", Kind: "source", DS: "x", Signal: "q"},
{ID: "e", Kind: "op", Op: "expr", Inputs: []string{"a", "b"},
Params: map[string]any{"expr": "price * qty", "vars": []any{"price", "qty"}}},
{ID: "out", Kind: "output", Inputs: []string{"e"}},
},
},
}
got := evalDef(t, def, map[string]float64{"a": 4, "b": 2.5})
if math.Abs(got-10) > 1e-9 {
t.Errorf("expr named inputs: want 10, got %v", got)
}
}
// TestGraphFanInToExpr exercises a non-trivial DAG: two ops feeding one expr.
func TestGraphFanInToExpr(t *testing.T) {
def := SignalDef{
Name: "combo",
Graph: &Graph{
Output: "out",
Nodes: []GraphNode{
{ID: "a", Kind: "source", DS: "x", Signal: "p"},
{ID: "b", Kind: "source", DS: "x", Signal: "q"},
{ID: "g", Kind: "op", Op: "gain", Inputs: []string{"a"}, Params: map[string]any{"gain": 2.0}},
{ID: "o", Kind: "op", Op: "offset", Inputs: []string{"b"}, Params: map[string]any{"offset": 1.0}},
{ID: "e", Kind: "op", Op: "expr", Inputs: []string{"g", "o"}, Params: map[string]any{"expr": "a + b"}},
{ID: "out", Kind: "output", Inputs: []string{"e"}},
},
},
}
// g = 5*2 = 10 ; o = 4+1 = 5 ; a+b = 15
got := evalDef(t, def, map[string]float64{"a": 5, "b": 4})
if math.Abs(got-15) > 1e-9 {
t.Errorf("fan-in DAG: want 15, got %v", got)
}
}
// TestGraphLegacyConversion verifies the linear Inputs+Pipeline form still
// evaluates correctly via the graph runtime.
func TestGraphLegacyConversion(t *testing.T) {
def := SignalDef{
Name: "legacy",
DS: "x",
Signal: "p",
Pipeline: []NodeDef{{Type: "gain", Params: map[string]any{"gain": 3.0}}},
}
rg, err := compileGraph(def)
if err != nil {
t.Fatalf("compileGraph: %v", err)
}
if len(rg.sources) != 1 {
t.Fatalf("want 1 source, got %d", len(rg.sources))
}
got, err := rg.eval(map[string]float64{rg.sources[0].id: 4})
if err != nil {
t.Fatalf("eval: %v", err)
}
if got != 12 {
t.Errorf("legacy gain: want 12, got %v", got)
}
}
// TestGraphCycleRejected ensures a cyclic graph is refused at compile time.
func TestGraphCycleRejected(t *testing.T) {
def := SignalDef{
Name: "cyclic",
Graph: &Graph{
Output: "out",
Nodes: []GraphNode{
{ID: "a", Kind: "op", Op: "gain", Inputs: []string{"b"}, Params: map[string]any{"gain": 1.0}},
{ID: "b", Kind: "op", Op: "gain", Inputs: []string{"a"}, Params: map[string]any{"gain": 1.0}},
{ID: "out", Kind: "output", Inputs: []string{"a"}},
},
},
}
if _, err := compileGraph(def); err == nil {
t.Error("expected cycle to be rejected")
}
}
+39 -87
View File
@@ -13,16 +13,14 @@ import (
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/datasource"
"github.com/uopi/uopi/internal/dsp"
)
const definitionsFile = "synthetic.json"
// signalState holds everything needed to run one synthetic signal.
type signalState struct {
def SignalDef
nodes []dsp.Node
states []map[string]any // one map per node, persistent across calls
def SignalDef
rg *runtimeGraph // compiled DAG; op-node state persists across evaluations
// cancel stops the goroutine driving this signal.
cancel context.CancelFunc
@@ -123,19 +121,25 @@ func (s *Synthetic) Subscribe(ctx context.Context, signal string, ch chan<- data
return nil, datasource.ErrNotFound
}
// Collect the upstream references for this signal.
refs := upstreamRefs(st.def)
// Collect the source node references for this signal's DAG.
refs := st.rg.sourceRefs()
if len(refs) == 0 {
return nil, fmt.Errorf("synthetic: signal %q has no upstream inputs", signal)
}
// Source node ids, index-aligned with refs, so updates map to graph inputs.
srcIDs := make([]string, len(st.rg.sources))
for i, s := range st.rg.sources {
srcIDs[i] = s.id
}
ctx, cancel := context.WithCancel(ctx)
go func() {
defer cancel()
// Latest value per upstream input index.
latest := make([]float64, len(refs))
// Latest value and timestamp per source node id.
latest := make(map[string]float64, len(refs))
latestTs := make([]time.Time, len(refs))
ready := make([]bool, len(refs))
// Subscribe to every upstream ref via the broker.
@@ -184,7 +188,8 @@ func (s *Synthetic) Subscribe(ctx context.Context, signal string, ch chan<- data
return
case upd := <-updateCh:
latest[upd.idx] = upd.val
latest[srcIDs[upd.idx]] = upd.val
latestTs[upd.idx] = upd.ts
ready[upd.idx] = true
// Only compute once we have at least one value for every input.
@@ -199,7 +204,19 @@ func (s *Synthetic) Subscribe(ctx context.Context, signal string, ch chan<- data
continue
}
// Run the pipeline.
// The output is computed from the latest value of every input, so
// its timestamp is the most recent contributing sample time. Using
// the triggering update's timestamp instead would drag the output
// back in time whenever a slow/stale input fired, producing
// non-monotonic or duplicated timestamps on plots.
outTs := latestTs[0]
for _, ts := range latestTs[1:] {
if ts.After(outTs) {
outTs = ts
}
}
// Evaluate the DAG.
s.mu.RLock()
cur, stillExists := s.signals[signal]
s.mu.RUnlock()
@@ -207,14 +224,14 @@ func (s *Synthetic) Subscribe(ctx context.Context, signal string, ch chan<- data
return
}
result, err := runPipeline(cur.nodes, cur.states, latest)
result, err := cur.rg.eval(latest)
if err != nil {
s.log.Warn("synthetic: pipeline error", "signal", signal, "err", err)
continue
}
v := datasource.Value{
Timestamp: upd.ts,
Timestamp: outTs,
Data: result,
Quality: datasource.QualityGood,
}
@@ -246,9 +263,9 @@ func (s *Synthetic) AddSignal(def SignalDef) error {
return errors.New("signal name must not be empty")
}
nodes, err := BuildPipeline(def.Pipeline)
rg, err := compileGraph(def)
if err != nil {
return fmt.Errorf("build pipeline: %w", err)
return fmt.Errorf("compile graph: %w", err)
}
s.mu.Lock()
@@ -257,16 +274,7 @@ func (s *Synthetic) AddSignal(def SignalDef) error {
return fmt.Errorf("signal %q already exists", def.Name)
}
states := make([]map[string]any, len(nodes))
for i := range states {
states[i] = make(map[string]any)
}
st := &signalState{
def: def,
nodes: nodes,
states: states,
}
st := &signalState{def: def, rg: rg}
s.signals[def.Name] = st
s.mu.Unlock()
@@ -320,14 +328,9 @@ func (s *Synthetic) UpdateSignal(def SignalDef) error {
return errors.New("signal name must not be empty")
}
nodes, err := BuildPipeline(def.Pipeline)
rg, err := compileGraph(def)
if err != nil {
return fmt.Errorf("build pipeline: %w", err)
}
states := make([]map[string]any, len(nodes))
for i := range states {
states[i] = make(map[string]any)
return fmt.Errorf("compile graph: %w", err)
}
s.mu.Lock()
@@ -339,7 +342,7 @@ func (s *Synthetic) UpdateSignal(def SignalDef) error {
if old.cancel != nil {
old.cancel()
}
s.signals[def.Name] = &signalState{def: def, nodes: nodes, states: states}
s.signals[def.Name] = &signalState{def: def, rg: rg}
s.mu.Unlock()
if err := s.saveDefs(); err != nil {
@@ -402,73 +405,22 @@ func (s *Synthetic) saveDefs() error {
return os.WriteFile(s.defsFilePath(), data, 0o644)
}
// startSignal builds the pipeline for def and registers the signalState.
// startSignal compiles the DAG for def and registers the signalState.
// The actual goroutines are started lazily by Subscribe.
func (s *Synthetic) startSignal(def SignalDef) error {
nodes, err := BuildPipeline(def.Pipeline)
rg, err := compileGraph(def)
if err != nil {
return fmt.Errorf("build pipeline for %q: %w", def.Name, err)
}
states := make([]map[string]any, len(nodes))
for i := range states {
states[i] = make(map[string]any)
return fmt.Errorf("compile graph for %q: %w", def.Name, err)
}
s.mu.Lock()
s.signals[def.Name] = &signalState{
def: def,
nodes: nodes,
states: states,
}
s.signals[def.Name] = &signalState{def: def, rg: rg}
s.mu.Unlock()
s.log.Info("synthetic: signal registered", "name", def.Name)
return nil
}
// runPipeline executes all nodes in sequence. The output of node N becomes
// input[0] of node N+1. For the first node, inputs is the full upstream slice.
func runPipeline(nodes []dsp.Node, states []map[string]any, inputs []float64) (float64, error) {
if len(nodes) == 0 {
if len(inputs) == 0 {
return 0, nil
}
return inputs[0], nil
}
// First node receives all upstream inputs.
cur := inputs
var result float64
var err error
for i, node := range nodes {
result, err = node.Process(cur, states[i])
if err != nil {
return 0, fmt.Errorf("node %d (%s): %w", i, node.Type(), err)
}
// Subsequent nodes receive only the single output of the previous node.
cur = []float64{result}
}
return result, nil
}
// upstreamRefs returns the broker.SignalRef list for a SignalDef.
// If Inputs is set, those take precedence; otherwise DS+Signal is used.
func upstreamRefs(def SignalDef) []broker.SignalRef {
if len(def.Inputs) > 0 {
refs := make([]broker.SignalRef, len(def.Inputs))
for i, inp := range def.Inputs {
refs[i] = broker.SignalRef{DS: inp.DS, Name: inp.Signal}
}
return refs
}
if def.DS != "" && def.Signal != "" {
return []broker.SignalRef{{DS: def.DS, Name: def.Signal}}
}
return nil
}
// defToMetadata converts a SignalDef into a datasource.Metadata.
func defToMetadata(def SignalDef) datasource.Metadata {
return datasource.Metadata{
@@ -0,0 +1,154 @@
package synthetic
import (
"context"
"log/slog"
"os"
"testing"
"time"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/datasource"
)
// seqSource is a test DataSource that emits a fixed sequence of values, each
// carrying its own timestamp, so tests can control upstream sample times.
type seqSource struct {
name string
seq []datasource.Value
}
func (s *seqSource) Name() string { return s.name }
func (s *seqSource) Connect(context.Context) error { return nil }
func (s *seqSource) ListSignals(context.Context) ([]datasource.Metadata, error) { return nil, nil }
func (s *seqSource) GetMetadata(context.Context, string) (datasource.Metadata, error) {
return datasource.Metadata{Name: "x", Type: datasource.TypeFloat64}, nil
}
func (s *seqSource) Write(context.Context, string, any) error { return datasource.ErrNotWritable }
func (s *seqSource) History(context.Context, string, time.Time, time.Time, int) ([]datasource.Value, error) {
return nil, datasource.ErrHistoryUnavailable
}
func (s *seqSource) Subscribe(ctx context.Context, _ string, ch chan<- datasource.Value) (datasource.CancelFunc, error) {
go func() {
for _, v := range s.seq {
select {
case ch <- v:
case <-ctx.Done():
return
}
time.Sleep(8 * time.Millisecond)
}
}()
return func() {}, nil
}
// TestSubscribePreservesUpstreamTimestamp verifies a single-source synthetic
// emits each computed value with the upstream sample's timestamp.
func TestSubscribePreservesUpstreamTimestamp(t *testing.T) {
log := slog.New(slog.NewTextHandler(os.Stderr, nil))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
base := time.Date(2026, 6, 19, 10, 0, 0, 0, time.UTC)
src := &seqSource{name: "src", seq: []datasource.Value{
{Timestamp: base.Add(1 * time.Second), Data: 1.0, Quality: datasource.QualityGood},
{Timestamp: base.Add(2 * time.Second), Data: 2.0, Quality: datasource.QualityGood},
{Timestamp: base.Add(3 * time.Second), Data: 3.0, Quality: datasource.QualityGood},
}}
brk := broker.New(ctx, log)
brk.Register(src)
syn := New(t.TempDir(), brk, log)
if err := syn.Connect(ctx); err != nil {
t.Fatal(err)
}
if err := syn.AddSignal(SignalDef{
Name: "g", DS: "src", Signal: "x",
Pipeline: []NodeDef{{Type: "gain", Params: map[string]any{"gain": 10.0}}},
}); err != nil {
t.Fatal(err)
}
ch := make(chan datasource.Value, 8)
if _, err := syn.Subscribe(ctx, "g", ch); err != nil {
t.Fatal(err)
}
want := []time.Time{base.Add(1 * time.Second), base.Add(2 * time.Second), base.Add(3 * time.Second)}
for i, w := range want {
select {
case v := <-ch:
if !v.Timestamp.Equal(w) {
t.Errorf("emit #%d timestamp: want %s, got %s", i, w, v.Timestamp)
}
case <-time.After(2 * time.Second):
t.Fatalf("timeout waiting for emit #%d", i)
}
}
}
// TestSubscribeMultiSourceUsesLatestTimestamp verifies that a synthetic combining
// two independent sources stamps each output with the MOST RECENT contributing
// sample time — not the timestamp of whichever source happened to trigger the
// computation. A slow source carrying a stale timestamp must not drag the output
// backwards in time (which previously produced wrong/non-monotonic plot points).
func TestSubscribeMultiSourceUsesLatestTimestamp(t *testing.T) {
log := slog.New(slog.NewTextHandler(os.Stderr, nil))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
now := time.Date(2026, 6, 19, 12, 0, 0, 0, time.UTC)
// Fast source A with current timestamps.
a := &seqSource{name: "A", seq: []datasource.Value{
{Timestamp: now.Add(10 * time.Second), Data: 1.0, Quality: datasource.QualityGood},
{Timestamp: now.Add(11 * time.Second), Data: 2.0, Quality: datasource.QualityGood},
{Timestamp: now.Add(12 * time.Second), Data: 3.0, Quality: datasource.QualityGood},
{Timestamp: now.Add(13 * time.Second), Data: 4.0, Quality: datasource.QualityGood},
}}
// Slow source B: a single sample with a much older timestamp.
b := &seqSource{name: "B", seq: []datasource.Value{
{Timestamp: now.Add(1 * time.Second), Data: 100.0, Quality: datasource.QualityGood},
}}
brk := broker.New(ctx, log)
brk.Register(a)
brk.Register(b)
syn := New(t.TempDir(), brk, log)
if err := syn.Connect(ctx); err != nil {
t.Fatal(err)
}
if err := syn.AddSignal(SignalDef{
Name: "diff",
Graph: &Graph{Output: "out", Nodes: []GraphNode{
{ID: "sa", Kind: "source", DS: "A", Signal: "x"},
{ID: "sb", Kind: "source", DS: "B", Signal: "x"},
{ID: "sub", Kind: "op", Op: "subtract", Inputs: []string{"sa", "sb"}},
{ID: "out", Kind: "output", Inputs: []string{"sub"}},
}},
}); err != nil {
t.Fatal(err)
}
ch := make(chan datasource.Value, 16)
if _, err := syn.Subscribe(ctx, "diff", ch); err != nil {
t.Fatal(err)
}
var last time.Time
for i := 0; i < 4; i++ {
select {
case v := <-ch:
// The stale source-B timestamp (t=1s) must never be used: every output
// is stamped with the newest input time, so emits stay monotonic.
if v.Timestamp.Equal(now.Add(1 * time.Second)) {
t.Errorf("emit #%d used the stale source-B timestamp %s", i, v.Timestamp)
}
if !last.IsZero() && v.Timestamp.Before(last) {
t.Errorf("emit #%d went backwards: %s before previous %s", i, v.Timestamp, last)
}
last = v.Timestamp
case <-time.After(2 * time.Second):
t.Fatalf("timeout waiting for emit #%d", i)
}
}
}
+21 -10
View File
@@ -277,16 +277,28 @@ func (n *ThresholdNode) Process(inputs []float64, _ map[string]any) (float64, er
// ── ExprNode ──────────────────────────────────────────────────────────────────
// ExprNode evaluates a simple arithmetic expression with variables a, b, c, d
// bound to inputs[0..3]. It uses a hand-written recursive descent parser.
// ExprNode evaluates a simple arithmetic expression. Inputs are bound to named
// variables: Vars[i] -> inputs[i]. When Vars is empty it defaults to a, b, c, d
// (bound to inputs[0..3]) for backward compatibility. It uses a hand-written
// recursive descent parser.
type ExprNode struct {
Expr string
Vars []string
}
// defaultVarNames returns the variable names for an expr/lua node: the explicit
// list when set, otherwise the legacy a,b,c,d.
func defaultVarNames(vars []string) []string {
if len(vars) > 0 {
return vars
}
return []string{"a", "b", "c", "d"}
}
func (n *ExprNode) Type() string { return "expr" }
func (n *ExprNode) Process(inputs []float64, _ map[string]any) (float64, error) {
vars := map[string]float64{}
names := []string{"a", "b", "c", "d"}
names := defaultVarNames(n.Vars)
for i, name := range names {
if i < len(inputs) {
vars[name] = inputs[i]
@@ -507,13 +519,10 @@ func (p *exprParser) parseCall() (float64, error) {
}
}
// Not a function call — must be a single-letter variable.
if len(name) != 1 {
return 0, fmt.Errorf("unknown identifier %q (use ad for variables, or a known function name)", name)
}
// Not a function call — must be a declared input variable.
val, ok := p.vars[name]
if !ok {
return 0, fmt.Errorf("unknown variable %q (allowed: a, b, c, d)", name)
return 0, fmt.Errorf("unknown variable %q (declare it as a named input)", name)
}
return val, nil
}
@@ -628,10 +637,12 @@ func (n *LowPassNode) Process(inputs []float64, state map[string]any) (float64,
// ── LuaNode ───────────────────────────────────────────────────────────────────
// LuaNode runs a Lua script in a sandboxed gopher-lua VM.
// Inputs are bound to globals a, b, c, d. The script's return value is the output.
// Inputs are bound to globals named by Vars (Vars[i] -> inputs[i]); when Vars is
// empty it defaults to a, b, c, d. The script's return value is the output.
// The os, io, package, and debug libraries are disabled.
type LuaNode struct {
Script string
Vars []string
}
func (n *LuaNode) Type() string { return "lua" }
@@ -682,7 +693,7 @@ func (n *LuaNode) Process(inputs []float64, state map[string]any) (result float6
L.SetTop(0)
// Bind inputs.
names := []string{"a", "b", "c", "d"}
names := defaultVarNames(n.Vars)
for i, name := range names {
if i < len(inputs) {
L.SetGlobal(name, lua.LNumber(inputs[i]))
+199
View File
@@ -0,0 +1,199 @@
package server
import (
"context"
"encoding/json"
"log/slog"
"strconv"
"strings"
"sync"
"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"
)
// DialogHub fans control-logic dialog requests out to connected WebSocket
// clients whose identity matches the dialog's user/group filter, and routes
// input responses back to the dialog's target server variable.
//
// It implements controllogic.Notifier; the engine calls Notify when an
// action.dialog node runs. Input dialogs are remembered as pending so a later
// dialogResponse can be correlated by id and validated against its recipient
// filter — this is why panels can write the response target even though direct
// srv writes are otherwise gated to control-logic editors.
type DialogHub struct {
broker *broker.Broker
policy *access.Policy
audit audit.Recorder
log *slog.Logger
mu sync.Mutex
clients map[*wsClient]struct{}
pending map[string]controllogic.Dialog // input dialogs awaiting a response
}
// NewDialogHub builds an empty hub. rec is never nil after construction.
func NewDialogHub(brk *broker.Broker, policy *access.Policy, rec audit.Recorder, log *slog.Logger) *DialogHub {
if rec == nil {
rec = audit.Nop()
}
return &DialogHub{
broker: brk,
policy: policy,
audit: rec,
log: log,
clients: map[*wsClient]struct{}{},
pending: map[string]controllogic.Dialog{},
}
}
func (h *DialogHub) add(c *wsClient) {
h.mu.Lock()
h.clients[c] = struct{}{}
h.mu.Unlock()
}
func (h *DialogHub) remove(c *wsClient) {
h.mu.Lock()
delete(h.clients, c)
h.mu.Unlock()
}
// dialogOut is the client-bound JSON form of a dialog request.
type dialogOut struct {
Type string `json:"type"` // always "dialog"
ID string `json:"id"`
Kind string `json:"kind"` // "info" | "error" | "input"
Title string `json:"title,omitempty"`
Message string `json:"message,omitempty"`
}
// Notify implements controllogic.Notifier: serialise the dialog and push it to
// every connected client matching its user/group filter.
func (h *DialogHub) Notify(d controllogic.Dialog) {
b, err := json.Marshal(dialogOut{
Type: "dialog",
ID: d.ID,
Kind: d.Kind,
Title: d.Title,
Message: d.Message,
})
if err != nil {
return
}
h.mu.Lock()
if d.Kind == "input" && strings.TrimSpace(d.Target) != "" {
h.pending[d.ID] = d
}
var targets []*wsClient
for c := range h.clients {
if h.matches(c.user, d) {
targets = append(targets, c)
}
}
h.mu.Unlock()
for _, c := range targets {
select {
case c.outCh <- b:
default:
}
}
}
// matches reports whether user is a recipient of d. An empty user+group filter
// targets everyone; otherwise the user must be named or in a named group.
func (h *DialogHub) matches(user string, d controllogic.Dialog) bool {
if len(d.Users) == 0 && len(d.Groups) == 0 {
return true
}
if h.policy != nil {
user = h.policy.ResolveUser(user)
}
for _, u := range d.Users {
if u == user {
return true
}
}
if len(d.Groups) > 0 && h.policy != nil {
groups := map[string]bool{}
for _, g := range h.policy.GroupsOf(user) {
groups[g] = true
}
for _, g := range d.Groups {
if groups[g] {
return true
}
}
}
return false
}
// cancel drops a pending input dialog without writing (user dismissed it).
func (h *DialogHub) cancel(id string) {
h.mu.Lock()
delete(h.pending, id)
h.mu.Unlock()
}
// respond writes an input dialog's response to its target server variable. The
// dialog must be pending and the responding client must have been a recipient;
// this gate replaces the usual srv write-permission check for sanctioned
// control-logic responses.
func (h *DialogHub) respond(ctx context.Context, c *wsClient, id string, value float64) {
h.mu.Lock()
d, ok := h.pending[id]
if ok {
delete(h.pending, id)
}
h.mu.Unlock()
if !ok || !h.matches(c.user, d) {
return
}
ds, name, ok := parseDialogTarget(d.Target)
if !ok || ds == "local" {
return
}
src, ok := h.broker.Source(ds)
if !ok {
h.log.Warn("dialog response: unknown data source", "ds", ds, "target", d.Target)
return
}
ev := audit.Event{
Actor: c.user,
ActorType: audit.ActorUser,
Action: "signal.write",
DS: ds,
Signal: name,
Value: strconv.FormatFloat(value, 'g', -1, 64),
Detail: "control logic dialog response",
IP: c.ip,
Outcome: audit.OutcomeOK,
}
wctx := datasource.WithUser(ctx, c.user)
if err := src.Write(wctx, name, value); err != nil {
h.log.Warn("dialog response: write failed", "ds", ds, "signal", name, "err", err)
ev.Outcome = audit.OutcomeError
ev.Error = err.Error()
}
h.audit.Record(ev)
}
// parseDialogTarget splits a "ds:name" dialog target on the first ':'. A bare
// name (no ':') defaults to the persistent server-variable source "srv".
func parseDialogTarget(t string) (ds, name string, ok bool) {
t = strings.TrimSpace(t)
if t == "" {
return "", "", false
}
if i := strings.IndexByte(t, ':'); i >= 0 {
return t[:i], t[i+1:], true
}
return "srv", t, true
}
+7 -3
View File
@@ -10,6 +10,7 @@ import (
"github.com/uopi/uopi/internal/access"
"github.com/uopi/uopi/internal/api"
"github.com/uopi/uopi/internal/audit"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/controllogic"
"github.com/uopi/uopi/internal/datasource/synthetic"
@@ -27,7 +28,10 @@ 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, policy *access.Policy, acl *panelacl.Store, ctrlLogic *controllogic.Store, ctrlEngine *controllogic.Engine, channelFinderURL, archiverURL, trustedUserHeader string, log *slog.Logger) *Server {
func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Synthetic, store *storage.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 {
if rec == nil {
rec = audit.Nop()
}
mux := http.NewServeMux()
// Health check
@@ -37,7 +41,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})
mux.Handle("/ws", &wsHandler{broker: brk, log: log, userHeader: trustedUserHeader, policy: policy, audit: rec, dialogs: dialogs})
// Prometheus-format metrics
mux.HandleFunc("/metrics", metrics.Handler(brk.ActiveSubscriptions))
@@ -45,7 +49,7 @@ func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Syntheti
// REST API — registered on a dedicated mux so it can be wrapped with the
// access-control middleware (identity resolution + global level enforcement).
apiMux := http.NewServeMux()
api.New(brk, synth, store, policy, acl, ctrlLogic, ctrlEngine, channelFinderURL, archiverURL, log).Register(apiMux, apiPrefix)
api.New(brk, synth, store, policy, acl, ctrlLogic, ctrlEngine, rec, channelFinderURL, archiverURL, log).Register(apiMux, apiPrefix)
mux.Handle(apiPrefix+"/", accessMiddleware(policy, trustedUserHeader, apiMux))
// Embedded frontend — must be last (catch-all)
+131 -12
View File
@@ -5,7 +5,9 @@ import (
"encoding/json"
"errors"
"log/slog"
"net"
"net/http"
"strconv"
"strings"
"sync"
"time"
@@ -13,6 +15,7 @@ import (
"github.com/coder/websocket"
"github.com/uopi/uopi/internal/access"
"github.com/uopi/uopi/internal/audit"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/datasource"
"github.com/uopi/uopi/internal/metrics"
@@ -31,6 +34,9 @@ type inMsg struct {
Name string `json:"name,omitempty"`
Value json.RawMessage `json:"value,omitempty"`
// dialogResponse — id of the control-logic dialog being answered.
ID string `json:"id,omitempty"`
// history
Start time.Time `json:"start"`
End time.Time `json:"end"`
@@ -48,11 +54,13 @@ type outMsg struct {
Type string `json:"type"`
// update
DS string `json:"ds,omitempty"`
Name string `json:"name,omitempty"`
TS string `json:"ts,omitempty"`
Value any `json:"value,omitempty"`
Quality string `json:"quality,omitempty"`
DS string `json:"ds,omitempty"`
Name string `json:"name,omitempty"`
TS string `json:"ts,omitempty"`
Value any `json:"value,omitempty"`
Quality string `json:"quality,omitempty"`
Severity int `json:"severity,omitempty"` // raw EPICS alarm severity (0=NO_ALARM)
Status int `json:"status,omitempty"` // raw EPICS alarm status
// meta
Meta *metaPayload `json:"meta,omitempty"`
@@ -94,6 +102,10 @@ type wsHandler struct {
userHeader string
// policy enforces the global access level (blacklist) on signal writes.
policy *access.Policy
// audit records signal writes (never nil; audit.Nop when disabled).
audit audit.Recorder
// dialogs fans control-logic dialogs to clients and routes responses.
dialogs *DialogHub
}
func (h *wsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -103,6 +115,12 @@ func (h *wsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if h.userHeader != "" {
user = strings.TrimSpace(r.Header.Get(h.userHeader))
}
// Resolve to the configured default_user when the header is absent so the
// session carries a real identity (used for audit + EPICS write attribution).
if h.policy != nil {
user = h.policy.ResolveUser(user)
}
clientIP := clientIP(r)
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
// Permissive origin check for LAN / SSH-tunnel use; tighten when auth lands.
@@ -118,17 +136,31 @@ func (h *wsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
rec := h.audit
if rec == nil {
rec = audit.Nop()
}
c := &wsClient{
conn: conn,
broker: h.broker,
user: user,
ip: clientIP,
policy: h.policy,
audit: rec,
dialogs: h.dialogs,
outCh: make(chan []byte, 512),
updateCh: make(chan broker.Update, 1024),
subs: make(map[broker.SignalRef]func()),
log: h.log,
}
// Register for control-logic dialog pushes for this client's identity.
if h.dialogs != nil {
h.dialogs.add(c)
defer h.dialogs.remove(c)
}
var wg sync.WaitGroup
wg.Add(3)
go func() { defer wg.Done(); c.readLoop(ctx, cancel) }()
@@ -153,7 +185,10 @@ type wsClient struct {
broker *broker.Broker
log *slog.Logger
user string // end-user identity from the trusted proxy header ("" if none)
policy *access.Policy // global access-level enforcement
ip string // client address, for audit attribution
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)
outCh chan []byte // serialised outgoing messages
updateCh chan broker.Update // raw updates from the broker
@@ -190,12 +225,14 @@ func (c *wsClient) dispatchLoop(ctx context.Context) {
continue
}
msg := outMsg{
Type: "update",
DS: u.Ref.DS,
Name: u.Ref.Name,
TS: u.Value.Timestamp.UTC().Format(time.RFC3339Nano),
Value: u.Value.Data,
Quality: u.Value.Quality.String(),
Type: "update",
DS: u.Ref.DS,
Name: u.Ref.Name,
TS: u.Value.Timestamp.UTC().Format(time.RFC3339Nano),
Value: u.Value.Data,
Quality: u.Value.Quality.String(),
Severity: u.Value.Severity,
Status: u.Value.Status,
}
b, err := json.Marshal(msg)
if err != nil {
@@ -248,6 +285,8 @@ func (c *wsClient) handleMessage(ctx context.Context, data []byte) {
c.handleWrite(ctx, msg)
case "history":
c.handleHistory(ctx, msg)
case "dialogResponse":
c.handleDialogResponse(ctx, msg)
default:
c.sendError(ctx, "UNKNOWN_TYPE", "unknown message type: "+msg.Type)
}
@@ -303,6 +342,14 @@ func (c *wsClient) handleWrite(ctx context.Context, msg inMsg) {
return
}
// Server variables are read-any but write only for control-logic editors, so a
// panel cannot mutate sequence state unless its user owns control-logic access.
if msg.DS == "srv" && c.policy != nil && !c.policy.CanEditLogic(c.policy.ResolveUser(c.user)) {
c.log.Warn("write: server variable denied", "name", msg.Name, "user", c.user)
c.sendError(ctx, "ACCESS_DENIED", "writing server variables requires control-logic access")
return
}
ds, ok := c.broker.Source(msg.DS)
if !ok {
c.log.Warn("write: unknown data source", "ds", msg.DS, "name", msg.Name)
@@ -336,10 +383,44 @@ func (c *wsClient) handleWrite(ctx context.Context, msg inMsg) {
// Attribute the write to the connecting end-user so data sources (EPICS) can
// act under that identity rather than the server's.
ctx = datasource.WithUser(ctx, c.user)
ev := audit.Event{
Actor: c.user,
ActorType: audit.ActorUser,
Action: "signal.write",
DS: msg.DS,
Signal: msg.Name,
Value: formatAuditValue(value),
IP: c.ip,
Outcome: audit.OutcomeOK,
}
if err := ds.Write(ctx, msg.Name, value); err != nil {
c.log.Warn("write: ds.Write failed", "ds", msg.DS, "name", msg.Name, "err", err)
ev.Outcome = audit.OutcomeError
ev.Error = err.Error()
c.audit.Record(ev)
c.sendError(ctx, "WRITE_ERROR", err.Error())
return
}
c.audit.Record(ev)
}
// handleDialogResponse routes the answer to a control-logic input dialog. An
// empty/null value means the user dismissed the dialog (drop it without
// writing); otherwise the numeric value is written to the dialog's target.
func (c *wsClient) handleDialogResponse(ctx context.Context, msg inMsg) {
if c.dialogs == nil || msg.ID == "" {
return
}
if len(msg.Value) == 0 || string(msg.Value) == "null" {
c.dialogs.cancel(msg.ID)
return
}
var num float64
if err := json.Unmarshal(msg.Value, &num); err != nil {
c.dialogs.cancel(msg.ID)
return
}
c.dialogs.respond(ctx, c, msg.ID, num)
}
func (c *wsClient) handleHistory(ctx context.Context, msg inMsg) {
@@ -416,6 +497,44 @@ func (c *wsClient) sendError(ctx context.Context, code, message string) {
// ── Helpers ───────────────────────────────────────────────────────────────────
// clientIP returns the best-effort client address for audit attribution,
// preferring the X-Forwarded-For / X-Real-IP headers set by a reverse proxy and
// falling back to the TCP peer address.
func clientIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if i := strings.IndexByte(xff, ','); i >= 0 {
return strings.TrimSpace(xff[:i])
}
return strings.TrimSpace(xff)
}
if xr := r.Header.Get("X-Real-IP"); xr != "" {
return strings.TrimSpace(xr)
}
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
return host
}
return r.RemoteAddr
}
// formatAuditValue renders a written value as a compact string for the audit log.
func formatAuditValue(v any) string {
switch x := v.(type) {
case string:
return x
case float64:
return strconv.FormatFloat(x, 'g', -1, 64)
case bool:
return strconv.FormatBool(x)
case nil:
return ""
default:
if b, err := json.Marshal(v); err == nil {
return string(b)
}
return ""
}
}
func metadataToPayload(m datasource.Metadata) *metaPayload {
return &metaPayload{
Type: dataTypeName(m.Type),
+32 -5
View File
@@ -474,16 +474,43 @@ func setXMLAttribute(data []byte, attr, value string) ([]byte, error) {
return []byte(s[:valStart] + value + s[valStart+valEnd:]), nil
}
// Delete removes the interface with the given ID.
// Delete moves the interface with the given ID — and all of its version
// backups — into a timestamped trash folder so it can be recovered if needed.
// Nothing is destroyed; recovery is a matter of moving the files back.
func (s *Store) Delete(id string) error {
if err := validateID(id); err != nil {
return ErrNotFound
}
err := os.Remove(s.filePath(id))
if errors.Is(err, os.ErrNotExist) {
return ErrNotFound
if _, err := os.Stat(s.filePath(id)); err != nil {
if errors.Is(err, os.ErrNotExist) {
return ErrNotFound
}
return err
}
return err
trashDir := filepath.Join(s.rootDir, "trash", "interfaces", fmt.Sprintf("%s.%d", id, time.Now().UnixMilli()))
if err := os.MkdirAll(trashDir, 0o755); err != nil {
return fmt.Errorf("create trash dir: %w", err)
}
// Move the current file plus every versioned backup (id.vN.xml), preserving
// their original names so a restore is a straight move-back.
entries, err := os.ReadDir(s.dir)
if err != nil {
return err
}
for _, e := range entries {
name := e.Name()
if e.IsDir() {
continue
}
if name == id+".xml" || (strings.HasPrefix(name, id+".v") && isVersioned(name)) {
if err := os.Rename(filepath.Join(s.dir, name), filepath.Join(trashDir, name)); err != nil {
return fmt.Errorf("move %s to trash: %w", name, err)
}
}
}
return nil
}
// Clone duplicates an interface, assigning a new unique ID.
+2
View File
@@ -4,6 +4,7 @@ import { wsClient } from './lib/ws';
import ViewMode from './ViewMode';
import EditMode from './EditMode';
import FullscreenMode from './FullscreenMode';
import ControlDialogs from './ControlDialogs';
import { applyZoom, getStoredZoom } from './ZoomControl';
import { AuthContext, DEFAULT_ME, fetchMe, canRead } from './lib/auth';
import type { Interface, Me } from './lib/types';
@@ -69,6 +70,7 @@ export default function App() {
return (
<AuthContext.Provider value={me}>
<div class="app-root">
<ControlDialogs />
{wsStatus === 'disconnected' && (
<div class="connection-banner">WebSocket disconnected reconnecting</div>
)}
+169
View File
@@ -0,0 +1,169 @@
import { h } from 'preact';
import { useState, useEffect, useCallback } from 'preact/hooks';
// AuditEvent mirrors the JSON returned by GET /api/v1/audit (internal/audit.Event).
interface AuditEvent {
time: string;
actor: string;
actorType: string; // "user" | "system"
action: string;
ds?: string;
signal?: string;
value?: string;
detail?: string;
ip?: string;
outcome: string; // "ok" | "error"
error?: string;
}
interface Props { onClose: () => void; }
/** Convert an RFC3339/ISO timestamp to a compact local datetime string. */
function fmtTime(iso: string): string {
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
/** Convert a datetime-local input value (local time) to an RFC3339 string. */
function localToRFC3339(v: string): string {
if (!v) return '';
const d = new Date(v);
return isNaN(d.getTime()) ? '' : d.toISOString();
}
export default function AuditViewer({ onClose }: Props) {
const [events, setEvents] = useState<AuditEvent[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Filters
const [user, setUser] = useState('');
const [action, setAction] = useState('');
const [signal, setSignal] = useState('');
const [start, setStart] = useState('');
const [end, setEnd] = useState('');
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const q = new URLSearchParams();
if (user.trim()) q.set('user', user.trim());
if (action) q.set('action', action);
if (signal.trim()) q.set('signal', signal.trim());
const s = localToRFC3339(start);
const e = localToRFC3339(end);
if (s) q.set('start', s);
if (e) q.set('end', e);
const res = await fetch(`/api/v1/audit?${q.toString()}`);
if (res.status === 403) throw new Error('You are not permitted to view the audit log.');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
setEvents(Array.isArray(data) ? data : []);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setEvents([]);
} finally {
setLoading(false);
}
}, [user, action, signal, start, end]);
// Initial load.
useEffect(() => { load(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
function resetFilters() {
setUser(''); setAction(''); setSignal(''); setStart(''); setEnd('');
}
return (
<div class="cl-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div class="cl-modal audit-modal">
<header class="cl-header">
<span class="cl-title">Audit log</span>
<span class="hint cl-subtitle">Every system-affecting action (signal writes, control-logic and interface changes).</span>
<div class="cl-header-actions">
<button class="panel-btn" disabled={loading} onClick={load}>{loading ? 'Loading…' : 'Refresh'}</button>
<button class="panel-btn" onClick={onClose}>Close</button>
</div>
</header>
<div class="audit-filters">
<input class="audit-filter" type="text" placeholder="User" value={user}
onInput={(e) => setUser((e.target as HTMLInputElement).value)} />
<select class="audit-filter" value={action}
onChange={(e) => setAction((e.target as HTMLSelectElement).value)}>
<option value="">All actions</option>
<option value="signal.write">signal.write</option>
<option value="interface.create">interface.create</option>
<option value="interface.update">interface.update</option>
<option value="interface.delete">interface.delete</option>
<option value="controllogic.create">controllogic.create</option>
<option value="controllogic.update">controllogic.update</option>
<option value="controllogic.delete">controllogic.delete</option>
</select>
<input class="audit-filter" type="text" placeholder="Signal contains…" value={signal}
onInput={(e) => setSignal((e.target as HTMLInputElement).value)} />
<label class="audit-filter-label">From
<input class="audit-filter" type="datetime-local" value={start}
onInput={(e) => setStart((e.target as HTMLInputElement).value)} />
</label>
<label class="audit-filter-label">To
<input class="audit-filter" type="datetime-local" value={end}
onInput={(e) => setEnd((e.target as HTMLInputElement).value)} />
</label>
<button class="panel-btn" disabled={loading} onClick={load}>Apply</button>
<button class="panel-btn" onClick={resetFilters}>Reset</button>
</div>
{error && <div class="cl-error">{error}</div>}
<div class="audit-body">
{!error && events.length === 0 && !loading && (
<div class="hint audit-empty">No matching audit entries.</div>
)}
{events.length > 0 && (
<table class="audit-table">
<thead>
<tr>
<th>Time</th>
<th>Actor</th>
<th>Action</th>
<th>Source</th>
<th>Signal / target</th>
<th>Value</th>
<th>Detail</th>
<th>Client</th>
<th>Result</th>
</tr>
</thead>
<tbody>
{events.map((ev, i) => (
<tr key={i} class={ev.outcome === 'error' ? 'audit-row-error' : ''}>
<td class="audit-time">{fmtTime(ev.time)}</td>
<td>
<span class={`audit-actor-tag audit-actor-${ev.actorType}`}>{ev.actorType}</span>
{' '}{ev.actor || '—'}
</td>
<td>{ev.action}</td>
<td>{ev.ds || '—'}</td>
<td class="audit-signal" title={ev.signal}>{ev.signal || '—'}</td>
<td class="audit-value" title={ev.value}>{ev.value ?? '—'}</td>
<td class="audit-detail" title={ev.detail}>{ev.detail || '—'}</td>
<td>{ev.ip || '—'}</td>
<td>
{ev.outcome === 'error'
? <span class="audit-result-err" title={ev.error}>error</span>
: <span class="audit-result-ok">ok</span>}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
</div>
);
}
-95
View File
@@ -1,95 +0,0 @@
import { h } from 'preact';
import { useState, useRef, useEffect } from 'preact/hooks';
interface Props {
mode: 'view' | 'edit';
onOpenManual: (section?: string) => void;
}
const TIPS: Record<string, Array<{ text: string; section?: string }>> = {
view: [
{ text: 'Click any interface in the left panel to load it on the canvas.', section: 'view' },
{ text: 'Right-click a widget to view signal info or export data as CSV.', section: 'view' },
{ text: 'The ● Live button shows live streaming is active. Use the date pickers to load historical data.', section: 'history' },
{ text: 'Click Edit (top-right) to open the drag-and-drop panel builder.', section: 'edit' },
{ text: 'Use the Share button on a panel to grant access to users or groups.', section: 'sharing' },
{ text: 'The ⚙ Control logic button opens server-side automation that runs even with no panel open.', section: 'control' },
{ text: 'The dot in the status chip turns green when the WebSocket is connected.', section: 'view' },
],
edit: [
{ text: 'Drag a signal from the left tree onto the canvas to create a widget.', section: 'edit' },
{ text: 'Click a widget to select it, then adjust its properties on the right.', section: 'edit' },
{ text: 'Ctrl+click to select multiple widgets; then use the align toolbar.', section: 'edit' },
{ text: 'Ctrl+Z to undo, Ctrl+Y to redo. Up to 50 steps are remembered.', section: 'shortcuts' },
{ text: 'Use "+ Synthetic" in the signal tree to create computed/filtered signals.', section: 'signals' },
{ text: 'Switch to the Logic tab to add interactive behaviour with a node-graph flow editor.', section: 'logic' },
{ text: 'Add Local variables for set-points and counters that live inside the panel.', section: 'edit' },
{ text: 'Arrow keys nudge the selected widget by 1 px (Shift for grid size).', section: 'shortcuts' },
],
};
export default function ContextualHelp({ mode, onOpenManual }: Props) {
const [open, setOpen] = useState(false);
const [tipIndex, setTipIndex] = useState(0);
const ref = useRef<HTMLDivElement>(null);
const tips = TIPS[mode];
// Close on outside click
useEffect(() => {
if (!open) return;
function handler(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open]);
// Cycle to a new random tip each time the popover opens
function handleOpen() {
setTipIndex(t => (t + 1) % tips.length);
setOpen(o => !o);
}
const tip = tips[tipIndex];
return (
<div class="ctx-help" ref={ref}>
<button
class="ctx-help-btn"
onClick={handleOpen}
title="Contextual help"
aria-label="Open contextual help"
aria-expanded={open}
>
?
</button>
{open && (
<div class="ctx-help-popover" role="tooltip">
<div class="ctx-help-tip">
<span class="ctx-help-icon">💡</span>
<span>{tip.text}</span>
</div>
<div class="ctx-help-footer">
<button
class="ctx-help-nav"
onClick={() => setTipIndex(t => (t + 1) % tips.length)}
title="Next tip"
>
Next tip
</button>
<button
class="ctx-help-link"
onClick={() => { setOpen(false); onOpenManual(tip.section); }}
>
Open manual
</button>
</div>
</div>
)}
</div>
);
}
+60
View File
@@ -0,0 +1,60 @@
import { h, Fragment } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import { wsClient } from './lib/ws';
import { controlDialogs, dismissControlDialog, type ControlDialog } from './lib/controldialogs';
// Renders dialogs pushed by server-side control-logic action.dialog nodes over
// the WebSocket. Mounted once globally (App) since these are addressed to the
// connected user, independent of which panel is open. info/error dialogs are
// acknowledgements; input dialogs send a numeric response back to the server.
export default function ControlDialogs() {
const [reqs, setReqs] = useState<ControlDialog[]>([]);
useEffect(() => controlDialogs.subscribe(setReqs), []);
if (reqs.length === 0) return null;
return <Fragment>{reqs.map(r => <ControlDialogModal key={r.id} req={r} />)}</Fragment>;
}
function ControlDialogModal({ req }: { req: ControlDialog; key?: string }) {
const isInput = req.kind === 'input';
const [val, setVal] = useState('');
function ok() {
if (isInput) {
const n = parseFloat(val);
wsClient.sendDialogResponse(req.id, Number.isFinite(n) ? n : null);
}
dismissControlDialog(req.id);
}
function cancel() {
if (isInput) wsClient.sendDialogResponse(req.id, null);
dismissControlDialog(req.id);
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Enter') { e.preventDefault(); ok(); }
if (e.key === 'Escape') { e.preventDefault(); cancel(); }
}
return (
<div class="logic-dialog-backdrop" onKeyDown={onKey}>
<div class={`logic-dialog logic-dialog-${req.kind}`} role="dialog">
<div class="logic-dialog-header">
{req.title || (req.kind === 'error' ? 'Error' : isInput ? 'Input required' : 'Info')}
</div>
{req.message && <div class="logic-dialog-message">{req.message}</div>}
{isInput && (
<div class="logic-dialog-field">
<input class="prop-input logic-dialog-input" type="number"
autoFocus value={val}
onInput={(e) => setVal((e.target as HTMLInputElement).value)}
onKeyDown={onKey} />
</div>
)}
<div class="logic-dialog-actions">
{isInput && <button class="panel-btn" onClick={cancel}>Cancel</button>}
<button class="panel-btn panel-btn-primary" onClick={ok}>OK</button>
</div>
</div>
</div>
);
}
+134 -54
View File
@@ -1,6 +1,6 @@
import { h, Fragment } from 'preact';
import { useState, useEffect, useRef } from 'preact/hooks';
import SearchableSelect from './SearchableSelect';
import SignalPicker, { SignalOption } from './SignalPicker';
import LuaEditor from './LuaEditor';
import { checkExpr } from './lib/expr';
@@ -18,7 +18,8 @@ type CLNodeKind =
| 'action.write'
| 'action.delay'
| 'action.log'
| 'action.lua';
| 'action.lua'
| 'action.dialog';
interface CLNode {
id: string;
@@ -55,6 +56,11 @@ const NODE_W = 11.5 * REM;
const PORT_TOP = 2 * REM;
const PORT_GAP = 1.375 * REM;
const PORT_R = 0.375 * REM;
// Ports sit inside the node's padding box, offset from the node border-box origin
// by the node borders (3px colored top accent, 1px sides). Anchors add the same
// offsets so wires meet the port centers, not their top edges.
const BORDER = 1;
const BORDER_TOP = 3;
interface PaletteEntry { kind: CLNodeKind; label: string; params: Record<string, string>; }
@@ -71,6 +77,7 @@ const PALETTE: PaletteEntry[] = [
{ kind: 'action.delay', label: 'Delay', params: { ms: '500' } },
{ kind: 'action.log', label: 'Log', params: { expr: '', label: '' } },
{ kind: 'action.lua', label: 'Lua script', params: { script: '-- get("ds:name") reads, set("ds:name", v) writes,\n-- log("msg") logs to the server.\n' } },
{ kind: 'action.dialog', label: 'Dialog', params: { kind: 'info', title: '', message: '', users: '', groups: '', target: '' } },
];
const KIND_LABEL: Record<CLNodeKind, string> = {
@@ -86,6 +93,7 @@ const KIND_LABEL: Record<CLNodeKind, string> = {
'action.delay': 'Delay',
'action.log': 'Log',
'action.lua': 'Lua script',
'action.dialog': 'Dialog',
};
const THRESHOLD_OPS = ['>', '<', '>=', '<=', '==', '!='] as const;
@@ -117,11 +125,11 @@ function nodeHeight(kind: CLNodeKind): number { return PORT_TOP + outputs(kind).
function genId(): string { return 'n_' + Math.random().toString(36).slice(2, 9); }
function inAnchor(n: CLNode) { return { x: n.x, y: n.y + PORT_TOP }; }
function inAnchor(n: CLNode) { return { x: n.x + BORDER, y: n.y + BORDER_TOP + PORT_TOP }; }
function outAnchor(n: CLNode, port: string) {
const ports = outputs(n.kind);
const i = Math.max(0, ports.findIndex(p => p.id === port));
return { x: n.x + NODE_W, y: n.y + PORT_TOP + i * PORT_GAP };
return { x: n.x + NODE_W - BORDER, y: n.y + BORDER_TOP + PORT_TOP + i * PORT_GAP };
}
function wirePathStr(x1: number, y1: number, x2: number, y2: number): string {
const dx = Math.max(40, Math.abs(x2 - x1) / 2);
@@ -139,6 +147,16 @@ export default function ControlLogicEditor({ onClose }: Props) {
const [dirty, setDirty] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showCloseConfirm, setShowCloseConfirm] = useState(false);
// Closing with unsaved changes prompts instead of silently discarding them.
function requestClose() {
if (dirty) { setShowCloseConfirm(true); return; }
onClose();
}
async function saveAndClose() {
if (await saveGraph()) { setShowCloseConfirm(false); onClose(); }
}
async function reload() {
try {
@@ -180,8 +198,8 @@ export default function ControlLogicEditor({ onClose }: Props) {
}
}
async function saveGraph() {
if (!graph) return;
async function saveGraph(): Promise<boolean> {
if (!graph) return false;
setBusy(true);
setError(null);
try {
@@ -191,8 +209,10 @@ export default function ControlLogicEditor({ onClose }: Props) {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
setDirty(false);
await reload();
return true;
} catch (err) {
setError(`Save failed: ${err instanceof Error ? err.message : err}`);
return false;
} finally {
setBusy(false);
}
@@ -240,7 +260,7 @@ export default function ControlLogicEditor({ onClose }: Props) {
}
return (
<div class="cl-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div class="cl-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) requestClose(); }}>
<div class="cl-modal">
<header class="cl-header">
<span class="cl-title">Control logic</span>
@@ -248,7 +268,7 @@ export default function ControlLogicEditor({ onClose }: Props) {
<div class="cl-header-actions">
{graph && dirty && <span class="cl-dirty">unsaved</span>}
{graph && <button class="panel-btn" disabled={busy || !dirty} onClick={saveGraph}>Save</button>}
<button class="panel-btn" onClick={onClose}>Close</button>
<button class="panel-btn" onClick={requestClose}>Close</button>
</div>
</header>
@@ -299,6 +319,21 @@ export default function ControlLogicEditor({ onClose }: Props) {
)}
</div>
</div>
{showCloseConfirm && (
<div class="cl-confirm-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) setShowCloseConfirm(false); }}>
<div class="cl-confirm">
<div class="cl-confirm-title">Unsaved changes</div>
<p class="hint">This graph has unsaved changes. What would you like to do?</p>
<div class="cl-confirm-actions">
<button class="panel-btn" onClick={() => setShowCloseConfirm(false)}>Cancel</button>
<button class="panel-btn" disabled={busy}
onClick={() => { setShowCloseConfirm(false); onClose(); }}>Close without saving</button>
<button class="panel-btn panel-btn-primary" disabled={busy} onClick={saveAndClose}>Save and close</button>
</div>
</div>
</div>
)}
</div>
</div>
);
@@ -341,12 +376,15 @@ function FlowEditor({ graph, onChange }: {
} catch {}
}
// Bare-name write targets are graph-local variables; offer them under 'local'.
// Graph-local write targets (bare name, or an explicit "local:" prefix) become
// the suggestions offered when the target data source is 'local'.
const localNames = Array.from(new Set(
nodes.filter(n => n.kind === 'action.write')
.map(n => n.params.target ?? '')
.map(t => t.startsWith('local:') ? t.slice('local:'.length) : t)
.filter(t => t && !t.includes(':'))
));
// 'srv' is a registered data source, so it already appears in dataSources.
const dsOptions = ['local', 'sys', ...dataSources];
function signalOptions(ds: string): string[] {
if (ds === 'local') return localNames;
@@ -354,6 +392,15 @@ function FlowEditor({ graph, onChange }: {
return dsSignals[ds] ?? [];
}
// Flatten every known ds:name into a single list for the fuzzy SignalPicker,
// and a hook to lazily load all data sources' signals when a picker opens.
function allSignalOptions(): SignalOption[] {
const out: SignalOption[] = [];
for (const ds of dsOptions) for (const name of signalOptions(ds)) out.push({ ds, name });
return out;
}
function openAllSignals() { dsOptions.forEach(ds => loadSignals(ds)); }
const selected = nodes.find(n => n.id === selectedNode) ?? null;
useEffect(() => {
@@ -573,19 +620,13 @@ function FlowEditor({ graph, onChange }: {
<div class="wizard-section-title">{KIND_LABEL[selected.kind]}</div>
{(selected.kind === 'trigger.threshold' || selected.kind === 'trigger.change' || selected.kind === 'trigger.alarm') && (() => {
const { ds, name } = splitRef(selected.params.signal ?? '');
return (
<Fragment>
<div class="wizard-field">
<label>Data source</label>
<SearchableSelect value={ds} options={dataSources}
onSelect={(newDs) => { loadSignals(newDs); patchParams(selected.id, { signal: `${newDs}:` }); }} />
</div>
<div class="wizard-field">
<label>Signal</label>
<SearchableSelect value={name} options={signalOptions(ds)}
onSelect={(sig) => patchParams(selected.id, { signal: `${ds}:${sig}` })}
placeholder={ds ? 'Search signals…' : 'Select data source first'} />
<SignalPicker value={selected.params.signal ?? ''} options={allSignalOptions()}
onOpen={openAllSignals}
onChange={(ref) => patchParams(selected.id, { signal: ref })} />
</div>
{selected.kind === 'trigger.threshold' && (
<div class="wizard-field wizard-field-row">
@@ -661,7 +702,7 @@ function FlowEditor({ graph, onChange }: {
<ExprField label="Condition" value={selected.params.cond ?? ''}
onChange={(v) => patchParams(selected.id, { cond: v })}
onInsert={(ds, sig) => insertRef(selected.id, 'cond', ds, sig)}
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals}
allSignals={allSignalOptions()} onOpenSignals={openAllSignals}
hint="True → 'then' port, false → 'else'. e.g. {stub:level} > 5 && temp < 100. {sys:time}=epoch seconds, {sys:dt}=seconds since the trigger last fired." />
)}
@@ -685,36 +726,30 @@ function FlowEditor({ graph, onChange }: {
<ExprField label="While condition" value={selected.params.cond ?? ''}
onChange={(v) => patchParams(selected.id, { cond: v })}
onInsert={(ds, sig) => insertRef(selected.id, 'cond', ds, sig)}
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals}
allSignals={allSignalOptions()} onOpenSignals={openAllSignals}
hint="Loops the 'body' port while true (capped). e.g. {stub:level} < 90" />
)}
<p class="hint">Runs the <b>body</b> port repeatedly, then continues on <b>done</b>.</p>
</Fragment>
)}
{selected.kind === 'action.write' && (() => {
const { ds, name } = splitRef(selected.params.target ?? '');
return (
<Fragment>
<div class="wizard-field">
<label>Target data source</label>
<SearchableSelect value={ds} options={dsOptions}
onSelect={(newDs) => { loadSignals(newDs); patchParams(selected.id, { target: `${newDs}:` }); }} />
</div>
<div class="wizard-field">
<label>Target signal</label>
<SearchableSelect value={name} options={signalOptions(ds)}
onSelect={(sig) => patchParams(selected.id, { target: `${ds}:${sig}` })}
placeholder={ds ? 'Search signals' : 'Select data source first'} />
</div>
<ExprField label="Value (expression)" value={selected.params.expr ?? ''}
onChange={(v) => patchParams(selected.id, { expr: v })}
onInsert={(ds2, sig) => insertRef(selected.id, 'expr', ds2, sig)}
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals}
hint="Write to a data source signal, or a bare name to store a graph-local var. e.g. ({stub:a} - {stub:b}) / {sys:dt}." />
</Fragment>
);
})()}
{selected.kind === 'action.write' && (
<Fragment>
<div class="wizard-field">
<label>Target signal / variable</label>
<SignalPicker value={selected.params.target ?? ''} options={allSignalOptions()}
onOpen={openAllSignals} allowFreeform
onChange={(ref) => patchParams(selected.id, { target: ref })} />
<p class="hint">Pick a signal, or type <code>local:name</code> for a graph-local var
or <code>srv:name</code> for a server variable that panels can read.</p>
</div>
<ExprField label="Value (expression)" value={selected.params.expr ?? ''}
onChange={(v) => patchParams(selected.id, { expr: v })}
onInsert={(ds2, sig) => insertRef(selected.id, 'expr', ds2, sig)}
allSignals={allSignalOptions()} onOpenSignals={openAllSignals}
hint="Write to a data source signal, a bare local var, or srv:name for a server variable panels can read. e.g. ({stub:a} - {stub:b}) / {sys:dt}." />
</Fragment>
)}
{selected.kind === 'action.delay' && (
<div class="wizard-field">
@@ -735,7 +770,7 @@ function FlowEditor({ graph, onChange }: {
<ExprField label="Value (expression)" value={selected.params.expr ?? ''}
onChange={(v) => patchParams(selected.id, { expr: v })}
onInsert={(ds2, sig) => insertRef(selected.id, 'expr', ds2, sig)}
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals}
allSignals={allSignalOptions()} onOpenSignals={openAllSignals}
hint="Logs this value to the server log — handy for debugging a flow." />
</Fragment>
)}
@@ -753,6 +788,54 @@ function FlowEditor({ graph, onChange }: {
</div>
)}
{selected.kind === 'action.dialog' && (
<Fragment>
<div class="wizard-field">
<label>Type</label>
<select class="prop-input" value={selected.params.kind ?? 'info'}
onChange={(e) => patchParams(selected.id, { kind: (e.target as HTMLSelectElement).value })}>
<option value="info">Info</option>
<option value="error">Error</option>
<option value="input">Input (ask for a value)</option>
</select>
</div>
<div class="wizard-field">
<label>Title</label>
<input class="prop-input" value={selected.params.title ?? ''}
placeholder="dialog title"
onInput={(e) => patchParams(selected.id, { title: (e.target as HTMLInputElement).value })} />
</div>
<div class="wizard-field">
<label>Message</label>
<textarea class="prop-input" rows={3} value={selected.params.message ?? ''}
placeholder="shown to the user"
onInput={(e) => patchParams(selected.id, { message: (e.target as HTMLTextAreaElement).value })} />
</div>
{selected.params.kind === 'input' && (
<div class="wizard-field">
<label>Response variable</label>
<input class="prop-input" value={selected.params.target ?? ''}
placeholder="srv:approved"
onInput={(e) => patchParams(selected.id, { target: (e.target as HTMLInputElement).value })} />
<p class="hint">The user's number is written here (e.g. <code>srv:approved</code>); read it on a later activation.</p>
</div>
)}
<div class="wizard-field">
<label>Users (optional)</label>
<input class="prop-input" value={selected.params.users ?? ''}
placeholder="alice, bob"
onInput={(e) => patchParams(selected.id, { users: (e.target as HTMLInputElement).value })} />
</div>
<div class="wizard-field">
<label>Groups (optional)</label>
<input class="prop-input" value={selected.params.groups ?? ''}
placeholder="operators"
onInput={(e) => patchParams(selected.id, { groups: (e.target as HTMLInputElement).value })} />
<p class="hint">Comma-separated. Leave both empty to show the dialog to every connected user.</p>
</div>
</Fragment>
)}
<button class="panel-btn" style="margin-top:1rem;" onClick={() => deleteNode(selected.id)}>Delete node</button>
</Fragment>
)}
@@ -774,17 +857,15 @@ function FlowEditor({ graph, onChange }: {
}
}
function ExprField({ label, value, onChange, onInsert, dsOptions, signalOptions, loadSignals, hint }: {
function ExprField({ label, value, onChange, onInsert, allSignals, onOpenSignals, hint }: {
label: string;
value: string;
onChange: (v: string) => void;
onInsert: (ds: string, sig: string) => void;
dsOptions: string[];
signalOptions: (ds: string) => string[];
loadSignals: (ds: string) => void;
allSignals: SignalOption[];
onOpenSignals: () => void;
hint: string;
}) {
const [insDs, setInsDs] = useState('');
const err = checkExpr(value);
return (
<div class="wizard-field">
@@ -794,11 +875,9 @@ function ExprField({ label, value, onChange, onInsert, dsOptions, signalOptions,
onInput={(e) => onChange((e.target as HTMLInputElement).value)} />
{err && <p class="wizard-error">{err}</p>}
<div class="flow-expr-insert">
<SearchableSelect value={insDs} options={dsOptions}
onSelect={(ds) => { setInsDs(ds); loadSignals(ds); }} placeholder="ds…" />
<SearchableSelect value="" options={signalOptions(insDs)}
onSelect={(sig) => onInsert(insDs, sig)}
placeholder={insDs ? '+ insert signal' : 'pick ds first'} />
<SignalPicker value="" options={allSignals} onOpen={onOpenSignals}
placeholder="+ insert signal"
onChange={(ref) => { const s = splitRef(ref); onInsert(s.ds, s.name); }} />
</div>
<p class="hint">{hint}</p>
</div>
@@ -821,6 +900,7 @@ function nodeSummary(n: CLNode): string {
case 'action.delay': return `wait ${n.params.ms || '0'} ms`;
case 'action.log': return `log ${n.params.label ? n.params.label + ': ' : ''}${n.params.expr || ''}`;
case 'action.lua': return 'Lua script';
case 'action.dialog': return `${n.params.kind || 'info'} dialog${n.params.title ? ': ' + n.params.title : ''}`;
default: return '';
}
}
-2
View File
@@ -8,7 +8,6 @@ import EditCanvas, { genWidgetId, DEFAULT_SIZES } from './EditCanvas';
import PlotPanelCanvas from './PlotPanelCanvas';
import LogicEditor from './LogicEditor';
import PropertiesPane from './PropertiesPane';
import ContextualHelp from './ContextualHelp';
import HelpModal from './HelpModal';
import ZoomControl from './ZoomControl';
import { useAuth, canWrite } from './lib/auth';
@@ -626,7 +625,6 @@ export default function EditMode({ initial, onDone }: Props) {
<div class="toolbar-right">
<ZoomControl />
<ContextualHelp mode="edit" onOpenManual={openHelp} />
<button class="icon-btn help-manual-btn" onClick={() => openHelp('edit')} title="Open user manual">📖</button>
<button class="toolbar-btn" onClick={handleImport}>Import</button>
<button class="toolbar-btn" onClick={handleExport}>Export</button>
+107 -5
View File
@@ -403,6 +403,7 @@ function DiagramSignalTree() {
const SECTIONS = [
{ id: 'start', label: 'Getting Started' },
{ id: 'tips', label: 'Quick Tips' },
{ id: 'view', label: 'View Mode' },
{ id: 'edit', label: 'Edit Mode' },
{ id: 'widgets', label: 'Widgets' },
@@ -411,10 +412,68 @@ const SECTIONS = [
{ id: 'logic', label: 'Panel Logic' },
{ id: 'control', label: 'Control Logic' },
{ id: 'sharing', label: 'Sharing & Access' },
{ id: 'audit', label: 'Audit Log' },
{ id: 'history', label: 'Historical Data' },
{ id: 'shortcuts', label: 'Keyboard Shortcuts' },
];
// Quick tips, grouped by the mode they apply to. Each links to the manual
// section that covers it in depth. (Previously surfaced via a floating
// contextual-help popover; now consolidated here in the manual.)
const TIPS: Record<'view' | 'edit', Array<{ text: string; section: string }>> = {
view: [
{ text: 'Click any interface in the left panel to load it on the canvas.', section: 'view' },
{ text: 'Right-click a widget to view signal info or export data as CSV.', section: 'view' },
{ text: 'The ● Live button shows live streaming is active. Use the date pickers to load historical data.', section: 'history' },
{ text: 'Click Edit (top-right) to open the drag-and-drop panel builder.', section: 'edit' },
{ text: 'Use the Share button on a panel to grant access to users or groups.', section: 'sharing' },
{ text: 'The ⚙ Control logic button opens server-side automation that runs even with no panel open.', section: 'control' },
{ text: 'The 🛡 Audit button (for permitted users) opens the log of system-affecting actions.', section: 'audit' },
{ text: 'The status chip turns green and shows your username when the WebSocket is connected.', section: 'view' },
],
edit: [
{ text: 'Drag a signal from the left tree onto the canvas to create a widget.', section: 'edit' },
{ text: 'Click a widget to select it, then adjust its properties on the right.', section: 'edit' },
{ text: 'Ctrl+click to select multiple widgets; then use the align toolbar.', section: 'edit' },
{ text: 'Ctrl+Z to undo, Ctrl+Y to redo. Up to 50 steps are remembered.', section: 'shortcuts' },
{ text: 'Use "+ Synthetic" in the signal tree to create computed/filtered signals.', section: 'signals' },
{ text: 'Switch to the Logic tab to add interactive behaviour with a node-graph flow editor.', section: 'logic' },
{ text: 'Add Local variables for set-points and counters that live inside the panel.', section: 'edit' },
{ text: 'Arrow keys nudge the selected widget by 1 px (Shift for grid size).', section: 'shortcuts' },
],
};
function SectionTips({ onNavigate }: { onNavigate: (section: string) => void }) {
const groups: Array<['view' | 'edit', string]> = [
['view', 'In View mode'],
['edit', 'In Edit mode'],
];
return (
<div>
<p class="help-lead">
A handful of quick pointers to get the most out of uopi. Click any tip to jump to the
section that explains it in detail.
</p>
{groups.map(([mode, title]) => (
<div key={mode}>
<h3 class="help-h3">{title}</h3>
<ul class="help-tips-list">
{TIPS[mode].map(tip => (
<li key={tip.text}>
<button class="help-tip-item" onClick={() => onNavigate(tip.section)}>
<span class="help-tip-icon">💡</span>
<span class="help-tip-text">{tip.text}</span>
<span class="help-tip-go"></span>
</button>
</li>
))}
</ul>
</div>
))}
</div>
);
}
function SectionStart() {
return (
<div>
@@ -481,8 +540,10 @@ function SectionView() {
<h3 class="help-h3">Connection status</h3>
<p>
The coloured chip in the top-right shows the WebSocket connection state.
A red <em>"disconnected"</em> banner appears at the top while reconnecting live values will resume automatically.
The coloured chip in the top-right shows the WebSocket connection state. When connected it
reads <em>"connected as &lt;your username&gt;"</em>, so you can confirm the identity your
actions are attributed to in the audit log. A red <em>"disconnected"</em> banner appears at
the top while reconnecting live values will resume automatically.
</p>
</div>
);
@@ -677,6 +738,44 @@ function SectionSharing() {
);
}
function SectionAudit() {
return (
<div>
<p class="help-lead">
When the server is configured with <code>[audit] enabled = true</code>, every
<strong> system-affecting action</strong> is appended to an append-only log so it can be
reviewed later. Permitted users open it with the <strong>🛡 Audit</strong> button in the
view-mode toolbar.
</p>
<h3 class="help-h3">What gets recorded</h3>
<ul class="help-list">
<li><strong>Signal writes</strong> — both user-initiated (set-value widgets, buttons, panel
logic) and automated writes from the control-logic engine.</li>
<li><strong>Interface changes</strong> — panels created, updated, or deleted.</li>
<li><strong>Control-logic changes</strong> — graphs created, updated, or deleted.</li>
</ul>
<p>Each entry records the time, the actor (username, or the graph name for automated
actions), the action, the affected data source / signal and value, the client address, and
whether it succeeded.</p>
<h3 class="help-h3">Viewing the log</h3>
<ul class="help-list">
<li>The viewer lists the most recent events first.</li>
<li>Filter by user, action, signal, or a start/end time range.</li>
<li>System (automated) actions are tagged distinctly from user actions.</li>
</ul>
<p class="help-callout">
Access to the audit log is gated by an allowlist (<code>audit.readers</code> in the config),
following the same model as logic editing: an empty list means every user may view it,
otherwise only the listed users or groups can. The 🛡 Audit button is hidden for users who
are not permitted.
</p>
</div>
);
}
function SectionWidgets() {
return (
<div>
@@ -858,8 +957,9 @@ function SectionShortcuts() {
);
}
const SECTION_COMPONENTS: Record<string, () => JSX.Element> = {
const SECTION_COMPONENTS: Record<string, (props: { onNavigate: (section: string) => void }) => JSX.Element> = {
start: SectionStart,
tips: SectionTips,
view: SectionView,
edit: SectionEdit,
widgets: SectionWidgets,
@@ -868,6 +968,7 @@ const SECTION_COMPONENTS: Record<string, () => JSX.Element> = {
logic: SectionLogic,
control: SectionControl,
sharing: SectionSharing,
audit: SectionAudit,
history: SectionHistory,
shortcuts: SectionShortcuts,
};
@@ -877,7 +978,8 @@ const SECTION_COMPONENTS: Record<string, () => JSX.Element> = {
export default function HelpModal({ initialSection = 'start', onClose }: Props) {
const [active, setActive] = useState(initialSection);
const Content = SECTION_COMPONENTS[active] ?? SectionStart;
const Content: (props: { onNavigate: (section: string) => void }) => JSX.Element =
SECTION_COMPONENTS[active] ?? SectionStart;
// Close on backdrop click
function handleBackdrop(e: MouseEvent) {
@@ -915,7 +1017,7 @@ export default function HelpModal({ initialSection = 'start', onClose }: Props)
{/* Content area */}
<div class="help-content">
<h2 class="help-h2">{SECTIONS.find(s => s.id === active)?.label}</h2>
<Content />
<Content onNavigate={setActive} />
</div>
</div>
</div>
+54 -61
View File
@@ -1,6 +1,6 @@
import { h, Fragment } from 'preact';
import { useState, useEffect, useRef } from 'preact/hooks';
import SearchableSelect from './SearchableSelect';
import SignalPicker, { SignalOption } from './SignalPicker';
import { checkExpr } from './lib/expr';
import type { LogicGraph, LogicNode, LogicNodeKind, LogicWire, StateVar, Widget } from './lib/types';
@@ -37,6 +37,11 @@ const NODE_W = 11.5 * REM; // node width
const PORT_TOP = 2 * REM; // y of the first port row, relative to node top
const PORT_GAP = 1.375 * REM; // vertical spacing between stacked output ports
const PORT_R = 0.375 * REM; // port radius (half the 0.75rem dot)
// Ports sit inside the node's padding box, offset from the node border-box origin
// by the node borders (3px colored top accent, 1px sides). Anchors add the same
// offsets so wires meet the port centers, not their top edges.
const BORDER = 1;
const BORDER_TOP = 3;
interface PaletteEntry {
kind: LogicNodeKind;
@@ -114,11 +119,11 @@ function nodeHeight(kind: LogicNodeKind): number {
function genId(): string { return 'n_' + Math.random().toString(36).slice(2, 9); }
// Port anchors in canvas coordinates.
function inAnchor(n: LogicNode) { return { x: n.x, y: n.y + PORT_TOP }; }
function inAnchor(n: LogicNode) { return { x: n.x + BORDER, y: n.y + BORDER_TOP + PORT_TOP }; }
function outAnchor(n: LogicNode, port: string) {
const ports = outputs(n.kind);
const i = Math.max(0, ports.findIndex(p => p.id === port));
return { x: n.x + NODE_W, y: n.y + PORT_TOP + i * PORT_GAP };
return { x: n.x + NODE_W - BORDER, y: n.y + BORDER_TOP + PORT_TOP + i * PORT_GAP };
}
function wirePathStr(x1: number, y1: number, x2: number, y2: number): string {
@@ -180,6 +185,15 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
return dsSignals[ds] ?? [];
}
// Flatten every known ds:name into a single list for the fuzzy SignalPicker,
// and a hook to lazily load all data sources' signals when a picker opens.
function allSignalOptions(): SignalOption[] {
const out: SignalOption[] = [];
for (const ds of dsOptions) for (const name of signalOptions(ds)) out.push({ ds, name });
return out;
}
function openAllSignals() { dsOptions.forEach(ds => loadSignals(ds)); }
const selected = nodes.find(n => n.id === selectedNode) ?? null;
useEffect(() => {
@@ -520,19 +534,13 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
)}
{(selected.kind === 'trigger.threshold' || selected.kind === 'trigger.change') && (() => {
const { ds, name } = splitRef(selected.params.signal ?? '');
return (
<Fragment>
<div class="wizard-field">
<label>Data source</label>
<SearchableSelect value={ds} options={dsOptions}
onSelect={(newDs) => { loadSignals(newDs); patchParams(selected.id, { signal: `${newDs}:` }); }} />
</div>
<div class="wizard-field">
<label>Signal</label>
<SearchableSelect value={name} options={signalOptions(ds)}
onSelect={(sig) => patchParams(selected.id, { signal: `${ds}:${sig}` })}
placeholder={ds ? 'Search signals' : 'Select data source first'} />
<SignalPicker value={selected.params.signal ?? ''} options={allSignalOptions()}
onOpen={openAllSignals}
onChange={(ref) => patchParams(selected.id, { signal: ref })} />
</div>
{selected.kind === 'trigger.threshold' && (
<div class="wizard-field wizard-field-row">
@@ -573,7 +581,7 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
<ExprField label="Condition" value={selected.params.cond ?? ''}
onChange={(v) => patchParams(selected.id, { cond: v })}
onInsert={(ds, sig) => insertRef(selected.id, 'cond', ds, sig)}
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals}
allSignals={allSignalOptions()} onOpenSignals={openAllSignals}
hint="True → 'then' port, false → 'else' port. e.g. {stub:level} > 5 && temp < 100. {sys:time}=epoch seconds, {sys:dt}=seconds since the trigger last fired." />
)}
@@ -597,36 +605,29 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
<ExprField label="While condition" value={selected.params.cond ?? ''}
onChange={(v) => patchParams(selected.id, { cond: v })}
onInsert={(ds, sig) => insertRef(selected.id, 'cond', ds, sig)}
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals}
allSignals={allSignalOptions()} onOpenSignals={openAllSignals}
hint="Loops the 'body' port while true (capped). e.g. {stub:level} < 90" />
)}
<p class="hint">Runs the <b>body</b> port repeatedly, then continues on <b>done</b>.</p>
</Fragment>
)}
{selected.kind === 'action.write' && (() => {
const { ds, name } = splitRef(selected.params.target ?? '');
return (
<Fragment>
<div class="wizard-field">
<label>Target data source</label>
<SearchableSelect value={ds} options={dsOptions}
onSelect={(newDs) => { loadSignals(newDs); patchParams(selected.id, { target: `${newDs}:` }); }} />
</div>
<div class="wizard-field">
<label>Target signal</label>
<SearchableSelect value={name} options={signalOptions(ds)}
onSelect={(sig) => patchParams(selected.id, { target: `${ds}:${sig}` })}
placeholder={ds ? 'Search signals…' : 'Select data source first'} />
</div>
<ExprField label="Value (expression)" value={selected.params.expr ?? ''}
onChange={(v) => patchParams(selected.id, { expr: v })}
onInsert={(ds2, sig) => insertRef(selected.id, 'expr', ds2, sig)}
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals}
hint="e.g. ({stub:a} - {stub:b}) / {sys:dt} for a rate — or a constant like 42. {sys:time}=epoch seconds, {sys:dt}=seconds since the trigger last fired." />
</Fragment>
);
})()}
{selected.kind === 'action.write' && (
<Fragment>
<div class="wizard-field">
<label>Target signal / variable</label>
<SignalPicker value={selected.params.target ?? ''} options={allSignalOptions()}
onOpen={openAllSignals} allowFreeform
onChange={(ref) => patchParams(selected.id, { target: ref })} />
<p class="hint">Pick a signal, or type <code>local:name</code> to write a panel-local variable.</p>
</div>
<ExprField label="Value (expression)" value={selected.params.expr ?? ''}
onChange={(v) => patchParams(selected.id, { expr: v })}
onInsert={(ds2, sig) => insertRef(selected.id, 'expr', ds2, sig)}
allSignals={allSignalOptions()} onOpenSignals={openAllSignals}
hint="e.g. ({stub:a} - {stub:b}) / {sys:dt} for a rate — or a constant like 42. {sys:time}=epoch seconds, {sys:dt}=seconds since the trigger last fired." />
</Fragment>
)}
{selected.kind === 'action.delay' && (
<div class="wizard-field">
@@ -647,7 +648,7 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
<ExprField label="Value (expression)" value={selected.params.expr ?? ''}
onChange={(v) => patchParams(selected.id, { expr: v })}
onInsert={(ds2, sig) => insertRef(selected.id, 'expr', ds2, sig)}
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals}
allSignals={allSignalOptions()} onOpenSignals={openAllSignals}
hint="Appends this value (with a timestamp) to the array. e.g. {stub:level}, or {sys:time} for the clock." />
</Fragment>
)}
@@ -683,7 +684,7 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
<ExprField label="Value (expression)" value={selected.params.expr ?? ''}
onChange={(v) => patchParams(selected.id, { expr: v })}
onInsert={(ds2, sig) => insertRef(selected.id, 'expr', ds2, sig)}
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals}
allSignals={allSignalOptions()} onOpenSignals={openAllSignals}
hint="Logs this value to the browser console — handy for debugging a flow." />
</Fragment>
)}
@@ -747,7 +748,7 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
<DialogFieldsEditor allowInput={false}
fields={parseDialogFields(selected)}
onChange={(f) => patchParams(selected.id, { fields: JSON.stringify(f) })}
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals} />
allSignals={allSignalOptions()} onOpenSignals={openAllSignals} />
</Fragment>
)}
@@ -768,7 +769,7 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
<DialogFieldsEditor allowInput={true}
fields={parseDialogFields(selected)}
onChange={(f) => patchParams(selected.id, { fields: JSON.stringify(f) })}
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals} />
allSignals={allSignalOptions()} onOpenSignals={openAllSignals} />
<p class="hint">Each <b>input</b> prompts the operator and writes the entered number to its
target on OK; each <b>display</b> shows a live value. Cancel aborts the flow.</p>
</Fragment>
@@ -796,17 +797,15 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
}
// Expression input with a signal-insert helper and live validation.
function ExprField({ label, value, onChange, onInsert, dsOptions, signalOptions, loadSignals, hint }: {
function ExprField({ label, value, onChange, onInsert, allSignals, onOpenSignals, hint }: {
label: string;
value: string;
onChange: (v: string) => void;
onInsert: (ds: string, sig: string) => void;
dsOptions: string[];
signalOptions: (ds: string) => string[];
loadSignals: (ds: string) => void;
allSignals: SignalOption[];
onOpenSignals: () => void;
hint: string;
}) {
const [insDs, setInsDs] = useState('');
const err = checkExpr(value);
return (
<div class="wizard-field">
@@ -816,11 +815,9 @@ function ExprField({ label, value, onChange, onInsert, dsOptions, signalOptions,
onInput={(e) => onChange((e.target as HTMLInputElement).value)} />
{err && <p class="wizard-error">{err}</p>}
<div class="flow-expr-insert">
<SearchableSelect value={insDs} options={dsOptions}
onSelect={(ds) => { setInsDs(ds); loadSignals(ds); }} placeholder="ds…" />
<SearchableSelect value="" options={signalOptions(insDs)}
onSelect={(sig) => onInsert(insDs, sig)}
placeholder={insDs ? '+ insert signal' : 'pick ds first'} />
<SignalPicker value="" options={allSignals} onOpen={onOpenSignals}
placeholder="+ insert signal"
onChange={(ref) => { const s = splitRef(ref); onInsert(s.ds, s.name); }} />
</div>
<p class="hint">{hint}</p>
</div>
@@ -934,13 +931,12 @@ function parseDialogFields(n: LogicNode): DialogFieldSpec[] {
return [];
}
function DialogFieldsEditor({ allowInput, fields, onChange, dsOptions, signalOptions, loadSignals }: {
function DialogFieldsEditor({ allowInput, fields, onChange, allSignals, onOpenSignals }: {
allowInput: boolean;
fields: DialogFieldSpec[];
onChange: (fields: DialogFieldSpec[]) => void;
dsOptions: string[];
signalOptions: (ds: string) => string[];
loadSignals: (ds: string) => void;
allSignals: SignalOption[];
onOpenSignals: () => void;
}) {
function patch(i: number, patch: Partial<DialogFieldSpec>) {
onChange(fields.map((f, j) => (j === i ? { ...f, ...patch } : f)));
@@ -953,7 +949,6 @@ function DialogFieldsEditor({ allowInput, fields, onChange, dsOptions, signalOpt
<label>{allowInput ? 'Fields' : 'Displayed values'}</label>
{fields.length === 0 && <p class="hint">None yet.</p>}
{fields.map((f, i) => {
const { ds, name } = splitRef(f.target ?? '');
return (
<div key={i} class="flow-field-edit">
<div class="flow-field-head">
@@ -972,11 +967,9 @@ function DialogFieldsEditor({ allowInput, fields, onChange, dsOptions, signalOpt
{f.type === 'input' ? (
<Fragment>
<div class="flow-row-edit">
<SearchableSelect value={ds} options={dsOptions}
onSelect={(newDs) => { loadSignals(newDs); patch(i, { target: `${newDs}:` }); }} placeholder="ds…" />
<SearchableSelect value={name} options={signalOptions(ds)}
onSelect={(sig) => patch(i, { target: `${ds}:${sig}` })}
placeholder={ds ? 'signal…' : 'pick ds'} />
<SignalPicker value={f.target ?? ''} options={allSignals}
onOpen={onOpenSignals} allowFreeform placeholder="target signal…"
onChange={(ref) => patch(i, { target: ref })} />
</div>
<input class={`prop-input${checkExpr(f.default ?? '') ? ' prop-input-error' : ''}`}
value={f.default ?? ''} placeholder="default value (expression, optional)"
+113
View File
@@ -0,0 +1,113 @@
import { h } from 'preact';
import { useState, useMemo } from 'preact/hooks';
// A single fuzzy-finder input for picking a signal as one "ds:name" reference,
// replacing the old stacked "Data source" + "Signal" select pair. The caller
// supplies a flattened option list (every ds:name it knows about) and, via
// onOpen, a hook to lazily load signals for all data sources when the dropdown
// is first opened. When allowFreeform is set, typing a value that matches no
// option (e.g. a new srv:/local: variable) can still be committed.
export interface SignalOption { ds: string; name: string; }
export default function SignalPicker({
value,
options,
onChange,
onOpen,
placeholder = 'Search signals…',
allowFreeform = false,
}: {
value: string; // current "ds:name" (or "")
options: SignalOption[];
onChange: (ref: string) => void;
onOpen?: () => void;
placeholder?: string;
allowFreeform?: boolean;
}) {
const [open, setOpen] = useState(false);
const [filter, setFilter] = useState('');
const filtered = useMemo(() => {
const tokens = filter.toLowerCase().split(/\s+/).filter(Boolean);
const scored = options
.map(o => ({ o, label: `${o.ds}:${o.name}` }))
.filter(({ label }) => {
const l = label.toLowerCase();
return tokens.every(t => l.includes(t));
});
// Prefer matches where the signal name (not the ds prefix) leads.
const f = filter.toLowerCase();
scored.sort((a, b) => {
const an = a.o.name.toLowerCase().startsWith(f) ? 0 : 1;
const bn = b.o.name.toLowerCase().startsWith(f) ? 0 : 1;
if (an !== bn) return an - bn;
return a.label.localeCompare(b.label);
});
return scored.slice(0, 200);
}, [options, filter]);
function openDropdown() {
setOpen(true);
setFilter('');
onOpen?.();
}
function commit(ref: string) {
onChange(ref);
setOpen(false);
setFilter('');
}
// A freeform entry is offered when the typed text looks like a usable
// reference and is not already an exact option.
const freeform = allowFreeform && filter.trim() !== ''
&& !filtered.some(({ label }) => label === filter.trim());
return (
<div class="search-select">
<div class="search-select-trigger" onClick={() => (open ? setOpen(false) : openDropdown())}>
{value || <span class="search-select-placeholder">{placeholder}</span>}
<span class="search-select-arrow">{open ? '▴' : '▾'}</span>
</div>
{open && (
<div class="search-select-dropdown">
<input
class="prop-input"
autoFocus
placeholder="Type to search… (ds:name)"
value={filter}
onInput={(e) => setFilter((e.target as HTMLInputElement).value)}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e: KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
if (filtered.length > 0) commit(`${filtered[0].o.ds}:${filtered[0].o.name}`);
else if (freeform) commit(filter.trim());
}
if (e.key === 'Escape') { e.preventDefault(); setOpen(false); }
}}
/>
<div class="search-select-list">
{freeform && (
<div class="search-select-item" onClick={() => commit(filter.trim())}>
Use {filter.trim()}
</div>
)}
{filtered.length === 0 && !freeform && <div class="search-select-item hint">No matches</div>}
{filtered.map(({ o, label }) => (
<div
key={label}
class={`search-select-item${label === value ? ' search-select-item-selected' : ''}`}
onClick={() => commit(`${o.ds}:${o.name}`)}
>
{label}
</div>
))}
</div>
</div>
)}
{open && <div class="search-select-backdrop" onClick={() => setOpen(false)} />}
</div>
);
}
+17 -22
View File
@@ -1,10 +1,10 @@
import { h, Fragment } from 'preact';
import { useState, useEffect, useRef, useMemo } from 'preact/hooks';
import type { SignalRef, StateVar } from './lib/types';
import SyntheticWizard from './SyntheticWizard';
import SyntheticGraphEditor from './SyntheticGraphEditor';
import GroupsTree from './GroupsTree';
import ChannelFinderModal from './ChannelFinderModal';
import SignalPicker from './SignalPicker';
type TreeTab = 'sources' | 'groups';
type GroupBy = 'datasource' | 'area' | 'system' | 'ioc';
@@ -491,10 +491,11 @@ export default function SignalTree({ onDragStart, width, panelId, statevars, onS
)}
{showWizard && (
<SyntheticWizard
currentIfaceId={panelId}
<SyntheticGraphEditor
create
panelId={panelId}
onClose={() => setShowWizard(false)}
onCreated={loadAllSignals}
onSaved={loadAllSignals}
/>
)}
@@ -523,24 +524,18 @@ export default function SignalTree({ onDragStart, width, panelId, statevars, onS
</div>
<div class="wizard-body">
<div class="wizard-field">
<label>Data Source</label>
<select class="prop-select" value={manualDs} onChange={e => setManualDs((e.target as HTMLSelectElement).value)}>
{Array.from(new Set(allSignals.map(s => s.ds))).map(ds => (
<option key={ds} value={ds}>{ds}</option>
))}
{!allSignals.some(s => s.ds === 'epics') && <option value="epics">epics</option>}
</select>
</div>
<div class="wizard-field">
<label>Signal / PV Name</label>
<input
class="prop-input"
autoFocus
placeholder="e.g. MY:PV:NAME"
value={manualName}
onInput={e => setManualName((e.target as HTMLInputElement).value)}
onKeyDown={e => e.key === 'Enter' && handleManualAdd()}
/>
<label>Signal</label>
<SignalPicker
value={manualName ? `${manualDs}:${manualName}` : ''}
options={allSignals}
allowFreeform
placeholder="Search or type ds:NAME…"
onChange={(ref) => {
const i = ref.indexOf(':');
if (i < 0) { setManualDs(ref); setManualName(''); }
else { setManualDs(ref.slice(0, i)); setManualName(ref.slice(i + 1)); }
}} />
<p class="hint">Pick an existing signal, or type <code>epics:MY:PV:NAME</code> to add a new one.</p>
</div>
</div>
<div class="wizard-footer">
+455 -156
View File
@@ -1,54 +1,69 @@
import { h, Fragment } from 'preact';
import { useState, useEffect, useRef } from 'preact/hooks';
import SearchableSelect from './SearchableSelect';
import SignalPicker, { SignalOption } from './SignalPicker';
import LuaEditor from './LuaEditor';
import type { InputRef, PipelineNode, SignalDef } from './lib/types';
import type { SignalDef, SynthGraph, SynthGraphNode } from './lib/types';
interface DataSource { name: string; }
interface SignalInfo { name: string; }
interface Props {
name: string;
// Existing signal name (edit mode). Omitted/empty in create mode.
name?: string;
// When true the editor starts blank and POSTs a new signal on save.
create?: boolean;
// Interface id used to bind panel-scoped signals when creating.
panelId?: string;
onClose: () => void;
onSaved: () => void;
}
// ── Node-type catalogue ──────────────────────────────────────────────────────
// Mirrors the backend dsp node set (internal/dsp/nodes.go). The combine nodes
// (add/subtract/…) and expr take several inputs, which only the FIRST node of a
// pipeline receives — see the compile() note below.
// Mirrors the backend dsp node set (internal/dsp/nodes.go). `arity` describes how
// many input ports an op exposes:
// fixed n — exactly n inputs (gain=1, subtract/divide=2, …)
// min n — at least n inputs; one spare port appears past the wired count so the
// user can keep adding (add/multiply)
// named — the user names each input (expr/lua); ports follow params.vars
type InArity = { kind: 'fixed'; n: number } | { kind: 'min'; n: number } | { kind: 'named' };
interface NodeParam { label: string; key: string; type: 'number' | 'text' | 'lua'; default: string; }
interface OpDef { type: string; label: string; params: NodeParam[]; }
interface OpDef { type: string; label: string; arity: InArity; params: NodeParam[]; }
const OPS: OpDef[] = [
{ type: 'gain', label: 'Gain', params: [{ label: 'Factor', key: 'gain', type: 'number', default: '1' }] },
{ type: 'offset', label: 'Offset', params: [{ label: 'Offset', key: 'offset', type: 'number', default: '0' }] },
{ type: 'add', label: 'Add (Σ inputs)', params: [] },
{ type: 'subtract', label: 'Subtract (ab)', params: [] },
{ type: 'multiply', label: 'Multiply (Π)', params: [] },
{ type: 'divide', label: 'Divide (a÷b)', params: [] },
{ type: 'moving_average', label: 'Moving Average', params: [{ label: 'Window (n)', key: 'window', type: 'number', default: '10' }] },
{ type: 'rms', label: 'RMS', params: [{ label: 'Window (n)', key: 'window', type: 'number', default: '10' }] },
{ type: 'lowpass', label: 'Low-pass', params: [
{ type: 'gain', label: 'Gain', arity: { kind: 'fixed', n: 1 }, params: [{ label: 'Factor', key: 'gain', type: 'number', default: '1' }] },
{ type: 'offset', label: 'Offset', arity: { kind: 'fixed', n: 1 }, params: [{ label: 'Offset', key: 'offset', type: 'number', default: '0' }] },
{ type: 'add', label: 'Add (Σ inputs)', arity: { kind: 'min', n: 2 }, params: [] },
{ type: 'subtract', label: 'Subtract (ab)', arity: { kind: 'fixed', n: 2 }, params: [] },
{ type: 'multiply', label: 'Multiply (Π)', arity: { kind: 'min', n: 2 }, params: [] },
{ type: 'divide', label: 'Divide (a÷b)', arity: { kind: 'fixed', n: 2 }, params: [] },
{ type: 'moving_average', label: 'Moving Average', arity: { kind: 'fixed', n: 1 }, params: [{ label: 'Window (n)', key: 'window', type: 'number', default: '10' }] },
{ type: 'rms', label: 'RMS', arity: { kind: 'fixed', n: 1 }, params: [{ label: 'Window (n)', key: 'window', type: 'number', default: '10' }] },
{ type: 'lowpass', label: 'Low-pass', arity: { kind: 'fixed', n: 1 }, params: [
{ label: 'Cutoff freq (Hz)', key: 'freq', type: 'number', default: '1' },
{ label: 'Order (18)', key: 'order', type: 'number', default: '1' },
]},
{ type: 'derivative', label: 'Derivative', params: [] },
{ type: 'integrate', label: 'Integrate', params: [] },
{ type: 'clamp', label: 'Clamp', params: [{ label: 'Min', key: 'min', type: 'number', default: '0' }, { label: 'Max', key: 'max', type: 'number', default: '100' }] },
{ type: 'threshold', label: 'Threshold', params: [
{ type: 'derivative', label: 'Derivative', arity: { kind: 'fixed', n: 1 }, params: [] },
{ type: 'integrate', label: 'Integrate', arity: { kind: 'fixed', n: 1 }, params: [] },
{ type: 'clamp', label: 'Clamp', arity: { kind: 'fixed', n: 1 }, params: [{ label: 'Min', key: 'min', type: 'number', default: '0' }, { label: 'Max', key: 'max', type: 'number', default: '100' }] },
{ type: 'threshold', label: 'Threshold', arity: { kind: 'fixed', n: 1 }, params: [
{ label: 'Threshold', key: 'threshold', type: 'number', default: '0' },
{ label: 'High output', key: 'high', type: 'number', default: '1' },
{ label: 'Low output', key: 'low', type: 'number', default: '0' },
]},
{ type: 'expr', label: 'Formula', params: [{ label: 'Expression (a,b,c,d = inputs)', key: 'expr', type: 'text', default: 'a * 1.0' }] },
{ type: 'lua', label: 'Lua Script', params: [{ label: 'Script (a,b,c,d = inputs)', key: 'script', type: 'lua', default: 'return a' }] },
{ type: 'expr', label: 'Formula', arity: { kind: 'named' }, params: [{ label: 'Expression', key: 'expr', type: 'text', default: 'a + b' }] },
{ type: 'lua', label: 'Lua Script', arity: { kind: 'named' }, params: [{ label: 'Script', key: 'script', type: 'lua', default: 'return a + b' }] },
];
const OP_BY_TYPE = new Map(OPS.map(o => [o.type, o]));
function opLabel(type: string): string { return OP_BY_TYPE.get(type)?.label ?? type; }
function opParamDefs(type: string): NodeParam[] { return OP_BY_TYPE.get(type)?.params ?? []; }
function opArity(type?: string): InArity { return OP_BY_TYPE.get(type ?? '')?.arity ?? { kind: 'fixed', n: 1 }; }
// ── Graph model (UI only — compiled to SignalDef on save) ───────────────────
// Default input names a,b,c,… for named (expr/lua) nodes.
function letters(n: number): string[] {
return Array.from({ length: Math.max(1, n) }, (_, i) => String.fromCharCode(97 + i));
}
// ── Graph model (UI only — compiled to SynthGraph on save) ──────────────────
type NodeKind = 'source' | 'op' | 'output';
interface GNode {
id: string;
@@ -58,9 +73,10 @@ interface GNode {
ds?: string; // source
signal?: string; // source
op?: string; // op type
params?: Record<string, any>; // op
params?: Record<string, any>; // op (named ops carry params.vars: string[])
}
interface GWire { from: string; to: string; }
// A wire terminates on a specific input port (index) of the target node.
interface GWire { from: string; to: string; toPort: number; }
interface Graph { nodes: GNode[]; wires: GWire[]; }
// ── Geometry (rem-based, like the logic editor) ─────────────────────────────
@@ -69,23 +85,75 @@ const REM = (() => {
return parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
})();
const NODE_W = 10 * REM;
const PORT_TOP = 1.4 * REM;
const PORT_TOP = 1.7 * REM; // y of the first input/output port row
const PORT_GAP = 1.25 * REM; // vertical spacing between stacked input ports
const PORT_R = 0.375 * REM;
// Ports are absolutely positioned inside the node's padding box, which is offset
// from the node's border-box origin (node.x/node.y) by the node borders: a 3px
// colored accent on top and 1px on the sides. Wire anchors must add the same
// offsets or they land at the top edge of the port circle instead of its center.
const BORDER = 1;
const BORDER_TOP = 3;
function genId(): string { return 'g_' + Math.random().toString(36).slice(2, 9); }
function hasInput(k: NodeKind): boolean { return k !== 'source'; }
function hasOutput(k: NodeKind): boolean { return k !== 'output'; }
function inAnchor(n: GNode) { return { x: n.x, y: n.y + PORT_TOP }; }
function outAnchor(n: GNode) { return { x: n.x + NODE_W, y: n.y + PORT_TOP }; }
// Number of input ports a node currently shows.
function inPortCount(n: GNode, wires: GWire[]): number {
if (n.kind === 'source') return 0;
if (n.kind === 'output') return 1;
const ar = opArity(n.op);
if (ar.kind === 'fixed') return ar.n;
if (ar.kind === 'named') return Math.max(1, (n.params?.vars as string[] | undefined)?.length ?? 0);
// min: expose one spare port beyond the highest wired index so the user can add more.
const wired = wires.filter(w => w.to === n.id).length;
return Math.max(ar.n, wired + 1);
}
function inAnchor(n: GNode, port: number) {
return { x: n.x + BORDER, y: n.y + BORDER_TOP + PORT_TOP + port * PORT_GAP };
}
function outAnchor(n: GNode) { return { x: n.x + NODE_W - BORDER, y: n.y + BORDER_TOP + PORT_TOP }; }
function nodeMinHeight(n: GNode, wires: GWire[]): number {
const nIn = Math.max(1, inPortCount(n, wires));
return BORDER_TOP + PORT_TOP + nIn * PORT_GAP;
}
function wirePathStr(x1: number, y1: number, x2: number, y2: number): string {
const dx = Math.max(40, Math.abs(x2 - x1) / 2);
return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`;
}
// Build an initial graph by laying out an existing SignalDef: sources stacked on
// the left, the linear pipeline as a row of op nodes, output on the right.
// Label shown beside an input port (named ops show their var name).
function portLabel(n: GNode, port: number): string {
if (opArity(n.op).kind === 'named') {
const vars = (n.params?.vars as string[] | undefined) ?? [];
return vars[port] ?? '';
}
return '';
}
// Build an initial graph from an existing SignalDef. Prefer the stored DAG; fall
// back to laying out the legacy linear Inputs+Pipeline form.
function buildInitial(def: SignalDef): Graph {
const inputs: InputRef[] = def.inputs && def.inputs.length > 0
if (def.graph && def.graph.nodes.length > 0) {
const nodes: GNode[] = def.graph.nodes.map((n, i) => ({
id: n.id,
kind: n.kind,
x: n.x ?? (2 + (i % 3) * 12) * REM,
y: n.y ?? (2 + Math.floor(i / 3) * 5) * REM,
ds: n.ds,
signal: n.signal,
op: n.op,
params: n.params ? { ...n.params } : undefined,
}));
const wires: GWire[] = [];
for (const n of def.graph.nodes) {
(n.inputs ?? []).forEach((from, port) => wires.push({ from, to: n.id, toPort: port }));
}
return { nodes, wires };
}
// Legacy linear layout: sources stacked left, pipeline a row of op nodes, output right.
const inputs = def.inputs && def.inputs.length > 0
? def.inputs
: (def.ds && def.signal ? [{ ds: def.ds, signal: def.signal }] : []);
const pipeline = def.pipeline ?? [];
@@ -99,57 +167,108 @@ function buildInitial(def: SignalDef): Graph {
});
const opIds = pipeline.map((nd, i) => {
const id = genId();
nodes.push({ id, kind: 'op', x: (15 + i * 12) * REM, y: 3 * REM, op: nd.type, params: { ...nd.params } });
const params: Record<string, any> = { ...nd.params };
// The head op received every source as a,b,c,…; later ops a single input.
if (opArity(nd.type).kind === 'named') params.vars = letters(i === 0 ? srcIds.length : 1);
nodes.push({ id, kind: 'op', x: (15 + i * 12) * REM, y: 3 * REM, op: nd.type, params });
return id;
});
const outId = genId();
nodes.push({ id: outId, kind: 'output', x: (15 + pipeline.length * 12) * REM, y: 3 * REM });
const headId = opIds[0] ?? outId;
srcIds.forEach(s => wires.push({ from: s, to: headId }));
for (let i = 0; i < opIds.length - 1; i++) wires.push({ from: opIds[i], to: opIds[i + 1] });
if (opIds.length > 0) wires.push({ from: opIds[opIds.length - 1], to: outId });
srcIds.forEach((s, i) => wires.push({ from: s, to: headId, toPort: i }));
for (let i = 0; i < opIds.length - 1; i++) wires.push({ from: opIds[i], to: opIds[i + 1], toPort: 0 });
if (opIds.length > 0) wires.push({ from: opIds[opIds.length - 1], to: outId, toPort: 0 });
return { nodes, wires };
}
// Compile the visual graph back into the backend's linear form. The pipeline is
// a single chain ending at the output; the head node receives every source
// signal (inputs[0]=a, inputs[1]=b, …), every later node the previous output.
function compile(g: Graph): { inputs?: InputRef[]; pipeline?: PipelineNode[]; error?: string } {
// Compile the visual graph into the backend DAG form. Each op/output node's
// inputs are its wires ordered by target port index.
function compile(g: Graph): SynthGraph {
const output = g.nodes.find(n => n.kind === 'output');
if (!output) return { error: 'missing output node' };
const byId = new Map(g.nodes.map(n => [n.id, n]));
const upstream = (id: string) => g.wires.filter(w => w.to === id).map(w => byId.get(w.from)).filter(Boolean) as GNode[];
const nodes: SynthGraphNode[] = g.nodes.map(n => {
const inputs = g.wires
.filter(w => w.to === n.id)
.slice()
.sort((a, b) => a.toPort - b.toPort)
.map(w => w.from);
if (n.kind === 'source') return { id: n.id, kind: 'source', ds: n.ds, signal: n.signal, x: n.x, y: n.y };
if (n.kind === 'output') return { id: n.id, kind: 'output', inputs, x: n.x, y: n.y };
return { id: n.id, kind: 'op', op: n.op, params: n.params, inputs, x: n.x, y: n.y };
});
return { nodes, output: output?.id ?? '' };
}
const chain: GNode[] = [];
const seen = new Set<string>();
let cur: GNode = output;
for (;;) {
if (seen.has(cur.id)) return { error: 'the graph contains a cycle' };
seen.add(cur.id);
const ups = upstream(cur.id);
const opUps = ups.filter(n => n.kind === 'op');
const srcUps = ups.filter(n => n.kind === 'source');
if (opUps.length > 1) return { error: 'a node may have only one upstream node — the pipeline must be a single chain' };
if (opUps.length === 1) {
if (srcUps.length > 0) return { error: 'only the first processing node may take input signals directly' };
chain.unshift(opUps[0]);
cur = opUps[0];
// Detect a cycle in the graph (Kahn count over input edges).
function hasCycle(g: Graph): boolean {
const indeg = new Map<string, number>();
const succ = new Map<string, string[]>();
for (const n of g.nodes) indeg.set(n.id, 0);
for (const w of g.wires) {
if (!indeg.has(w.to) || !indeg.has(w.from)) continue;
indeg.set(w.to, (indeg.get(w.to) ?? 0) + 1);
succ.set(w.from, [...(succ.get(w.from) ?? []), w.to]);
}
const queue = g.nodes.filter(n => (indeg.get(n.id) ?? 0) === 0).map(n => n.id);
let visited = 0;
while (queue.length) {
const id = queue.shift()!;
visited++;
for (const s of succ.get(id) ?? []) {
indeg.set(s, (indeg.get(s) ?? 0) - 1);
if ((indeg.get(s) ?? 0) === 0) queue.push(s);
}
}
return visited !== g.nodes.length;
}
// Validate the graph; return a per-node error map plus the first message found.
function validate(g: Graph): { errors: Map<string, string>; first?: string } {
const errors = new Map<string, string>();
const set = (id: string, msg: string) => { if (!errors.has(id)) errors.set(id, msg); };
if (!g.nodes.some(n => n.kind === 'output')) {
return { errors, first: 'missing output node' };
}
for (const n of g.nodes) {
const ports = new Set(g.wires.filter(w => w.to === n.id).map(w => w.toPort));
if (n.kind === 'source') {
if (!n.ds || !n.signal) set(n.id, 'pick a data source and signal');
continue;
}
// Reached the head of the chain: its source upstreams become the inputs.
if (srcUps.length === 0) {
return { error: cur.kind === 'output' ? 'connect at least one signal to the output' : 'the first node has no input signals' };
if (n.kind === 'output') {
if (!ports.has(0)) set(n.id, 'connect a value to the output');
continue;
}
const ar = opArity(n.op);
if (ar.kind === 'fixed' || ar.kind === 'named') {
const cnt = ar.kind === 'fixed' ? ar.n : ((n.params?.vars as string[] | undefined)?.length ?? 0);
if (cnt === 0) { set(n.id, 'add at least one named input'); continue; }
for (let i = 0; i < cnt; i++) {
if (!ports.has(i)) { set(n.id, 'all inputs must be connected'); break; }
}
} else {
// min: needs ≥ ar.n contiguous inputs starting at port 0.
const cnt = ports.size;
if (cnt < ar.n) { set(n.id, `needs at least ${ar.n} inputs`); continue; }
for (let i = 0; i < cnt; i++) {
if (!ports.has(i)) { set(n.id, 'inputs have a gap — reconnect them'); break; }
}
}
const inputs = srcUps
.slice()
.sort((a, b) => a.y - b.y)
.map(n => ({ ds: n.ds || '', signal: n.signal || '' }));
if (inputs.some(i => !i.ds || !i.signal)) return { error: 'every input signal needs a data source and a signal' };
const pipeline = chain.map(n => ({ type: n.op!, params: n.params ?? {} }));
return { inputs, pipeline };
}
let first: string | undefined;
if (hasCycle(g)) first = 'the graph contains a cycle';
if (!first) {
for (const n of g.nodes) {
const m = errors.get(n.id);
if (m) { first = `${n.kind === 'op' ? opLabel(n.op ?? '') : n.kind}: ${m}`; break; }
}
}
return { errors, first };
}
function nodeSummary(n: GNode): string {
@@ -170,7 +289,7 @@ function nodeSummary(n: GNode): string {
}
}
export default function SyntheticGraphEditor({ name, onClose, onSaved }: Props) {
export default function SyntheticGraphEditor({ name, create, panelId, onClose, onSaved }: Props) {
const [def, setDef] = useState<SignalDef | null>(null);
const [graph, setGraph] = useState<Graph>({ nodes: [], wires: [] });
const [selected, setSelected] = useState<string | null>(null);
@@ -179,6 +298,15 @@ export default function SyntheticGraphEditor({ name, onClose, onSaved }: Props)
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
// Identity fields — editable only when creating a new signal.
const [newName, setNewName] = useState('');
const [visibility, setVisibility] = useState<'panel' | 'user' | 'global'>(panelId ? 'panel' : 'user');
const [unit, setUnit] = useState('');
const [desc, setDesc] = useState('');
const [dispLow, setDispLow] = useState('0');
const [dispHigh, setDispHigh] = useState('100');
const sigName = create ? newName.trim() : (name ?? '');
const [dataSources, setDataSources] = useState<string[]>([]);
const [dsSignals, setDsSignals] = useState<Record<string, string[]>>({});
@@ -214,18 +342,38 @@ export default function SyntheticGraphEditor({ name, onClose, onSaved }: Props)
} catch {}
}
// Flatten every known ds:name into a single list for the fuzzy SignalPicker,
// and a hook to lazily load all data sources' signals when a picker opens.
function allSignalOptions(): SignalOption[] {
const out: SignalOption[] = [];
for (const ds of dataSources) for (const name of (dsSignals[ds] ?? [])) out.push({ ds, name });
return out;
}
function openAllSignals() { dataSources.forEach(ds => loadSignals(ds)); }
useEffect(() => {
fetch(`/api/v1/synthetic/${encodeURIComponent(name)}`)
if (create) {
const blank: SignalDef = { name: '', inputs: [], pipeline: [], meta: {} };
setDef(blank);
setGraph(buildInitial(blank));
setLoading(false);
return;
}
fetch(`/api/v1/synthetic/${encodeURIComponent(name ?? '')}`)
.then(r => r.ok ? r.json() : Promise.reject(r.statusText))
.then((d: SignalDef) => {
setDef(d);
setUnit(d.meta?.unit ?? '');
setDesc(d.meta?.description ?? '');
setDispLow(String(d.meta?.displayLow ?? '0'));
setDispHigh(String(d.meta?.displayHigh ?? '100'));
const g = buildInitial(d);
setGraph(g);
g.nodes.forEach(n => { if (n.kind === 'source' && n.ds) loadSignals(n.ds); });
})
.catch(e => setError(String(e)))
.finally(() => setLoading(false));
}, [name]);
}, [name, create]);
// ── History ────────────────────────────────────────────────────────────────
function pushUndo() {
@@ -263,6 +411,7 @@ export default function SyntheticGraphEditor({ name, onClose, onSaved }: Props)
function addOp(op: OpDef, x?: number, y?: number) {
const params: Record<string, any> = {};
for (const p of op.params) params[p.key] = p.type === 'number' ? parseFloat(p.default) : p.default;
if (op.arity.kind === 'named') params.vars = ['a', 'b'];
const node: GNode = { id: genId(), kind: 'op', op: op.type, params, x: x ?? (14 + (graph.nodes.length % 4) * 3) * REM, y: y ?? (3 + (graph.nodes.length % 4) * 2) * REM };
commit({ nodes: [...graph.nodes, node], wires: graph.wires });
setSelected(node.id); setSelectedWire(null);
@@ -281,8 +430,45 @@ export default function SyntheticGraphEditor({ name, onClose, onSaved }: Props)
wires: graph.wires,
});
}
// Named-input (expr/lua) variable list editing.
function addNamedVar(id: string) {
commit({
nodes: graph.nodes.map(n => {
if (n.id !== id) return n;
const vars = [...((n.params?.vars as string[] | undefined) ?? [])];
vars.push(letters(vars.length + 1)[vars.length]);
return { ...n, params: { ...n.params, vars } };
}),
wires: graph.wires,
});
}
function renameNamedVar(id: string, idx: number, value: string) {
commit({
nodes: graph.nodes.map(n => {
if (n.id !== id) return n;
const vars = [...((n.params?.vars as string[] | undefined) ?? [])];
vars[idx] = value;
return { ...n, params: { ...n.params, vars } };
}),
wires: graph.wires,
});
}
function removeNamedVar(id: string, idx: number) {
const nodes = graph.nodes.map(n => {
if (n.id !== id) return n;
const vars = [...((n.params?.vars as string[] | undefined) ?? [])];
vars.splice(idx, 1);
return { ...n, params: { ...n.params, vars } };
});
// Drop the wire at the removed port and shift higher ports down by one.
const wires = graph.wires
.filter(w => !(w.to === id && w.toPort === idx))
.map(w => (w.to === id && w.toPort > idx ? { ...w, toPort: w.toPort - 1 } : w));
commit({ nodes, wires });
}
function moveNode(id: string, x: number, y: number, record: boolean) {
commit({ nodes: graph.nodes.map(n => (n.id === id ? { ...n, x, y } : n)), wires: graph.wires }, record);
const g = graphRef.current;
commit({ nodes: g.nodes.map(n => (n.id === id ? { ...n, x, y } : n)), wires: g.wires }, record);
}
function deleteNode(id: string) {
const n = graph.nodes.find(x => x.id === id);
@@ -290,19 +476,29 @@ export default function SyntheticGraphEditor({ name, onClose, onSaved }: Props)
commit({ nodes: graph.nodes.filter(x => x.id !== id), wires: graph.wires.filter(w => w.from !== id && w.to !== id) });
if (selected === id) setSelected(null);
}
function addWire(from: string, to: string) {
function addWire(from: string, to: string, toPort: number) {
if (from === to) return;
const fn = graph.nodes.find(n => n.id === from);
const tn = graph.nodes.find(n => n.id === to);
if (!fn || !tn || !hasOutput(fn.kind) || !hasInput(tn.kind)) return;
if (graph.wires.some(w => w.from === from && w.to === to)) return;
commit({ nodes: graph.nodes, wires: [...graph.wires, { from, to }] });
if (!fn || !tn || !hasOutput(fn.kind) || tn.kind === 'source') return;
// One wire per input port: replace any existing wire on that port.
const wires = graph.wires.filter(w => !(w.to === to && w.toPort === toPort));
if (wires.some(w => w.from === from && w.to === to && w.toPort === toPort)) return;
commit({ nodes: graph.nodes, wires: [...wires, { from, to, toPort }] });
}
function deleteWire(idx: number) {
commit({ nodes: graph.nodes, wires: graph.wires.filter((_, i) => i !== idx) });
if (selectedWire === idx) setSelectedWire(null);
}
// The lowest input port of `target` not yet wired (for node-body drops).
function firstFreePort(target: GNode): number {
const used = new Set(graphRef.current.wires.filter(w => w.to === target.id).map(w => w.toPort));
const cnt = inPortCount(target, graphRef.current.wires);
for (let i = 0; i < cnt; i++) if (!used.has(i)) return i;
return cnt; // all full — append (min-arity nodes grow)
}
// ── Pointer / drag / wire ──────────────────────────────────────────────────
function toCanvas(e: MouseEvent) {
const el = canvasRef.current!;
@@ -314,48 +510,52 @@ export default function SyntheticGraphEditor({ name, onClose, onSaved }: Props)
const p = toCanvas(e);
dragNode.current = { id: node.id, dx: p.x - node.x, dy: p.y - node.y, pushed: false };
setSelected(node.id); setSelectedWire(null);
window.addEventListener('mousemove', onNodeDragMove);
window.addEventListener('mouseup', onNodeDragUp);
}
function onNodeDragMove(e: MouseEvent) {
const d = dragNode.current;
if (!d) return;
const p = toCanvas(e);
const record = !d.pushed;
d.pushed = true;
moveNode(d.id, Math.max(0, p.x - d.dx), Math.max(0, p.y - d.dy), record);
}
function onNodeDragUp() {
dragNode.current = null;
window.removeEventListener('mousemove', onNodeDragMove);
window.removeEventListener('mouseup', onNodeDragUp);
}
function startWire(e: MouseEvent, node: GNode) {
e.stopPropagation();
const p = toCanvas(e);
setPendingWire({ from: node.id, x: p.x, y: p.y });
window.addEventListener('mousemove', onWireMove);
window.addEventListener('mouseup', onWireUp);
}
function onWireMove(e: MouseEvent) {
const cur = pendingRef.current;
if (!cur) return;
const p = toCanvas(e);
setPendingWire({ ...cur, x: p.x, y: p.y });
}
function endWire() {
pendingRef.current = null;
window.removeEventListener('mousemove', onWireMove);
window.removeEventListener('mouseup', onWireUp);
setPendingWire(null);
}
function onWireUp() { endWire(); }
function finishWire(target: GNode) {
function finishWire(target: GNode, port: number) {
const cur = pendingRef.current;
if (cur && hasInput(target.kind)) addWire(cur.from, target.id);
if (cur && target.kind !== 'source') addWire(cur.from, target.id, port);
endWire();
}
// Single mount-time pointer handler. Reads live drag/wire state from refs so it
// never goes stale across re-renders — fixes the "can't drop a dragged node" bug.
useEffect(() => {
function onMove(e: MouseEvent) {
const d = dragNode.current;
if (d) {
const p = toCanvas(e);
const record = !d.pushed;
d.pushed = true;
moveNode(d.id, Math.max(0, p.x - d.dx), Math.max(0, p.y - d.dy), record);
return;
}
const cur = pendingRef.current;
if (cur) {
const p = toCanvas(e);
setPendingWire({ ...cur, x: p.x, y: p.y });
}
}
function onUp() {
dragNode.current = null;
if (pendingRef.current) endWire();
}
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
return () => {
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
};
}, []);
// ── Palette drag-and-drop ──────────────────────────────────────────────────
const DRAG_MIME = 'application/x-uopi-synth-op';
function onCanvasDragOver(e: DragEvent) {
@@ -392,20 +592,44 @@ export default function SyntheticGraphEditor({ name, onClose, onSaved }: Props)
const byId = new Map(graph.nodes.map(n => [n.id, n]));
const sel = graph.nodes.find(n => n.id === selected) ?? null;
const compiled = compile(graph);
const { errors: nodeErrors, first: validationError } = validate(graph);
async function handleSave() {
if (!def) return;
if (compiled.error) { setError(compiled.error); return; }
if (create && !sigName) { setError('Enter a name for the new signal.'); return; }
if (validationError) { setError(validationError); return; }
setSaving(true);
setError('');
try {
const body = { ...def, inputs: compiled.inputs, pipeline: compiled.pipeline, ds: undefined, signal: undefined };
const res = await fetch(`/api/v1/synthetic/${encodeURIComponent(name)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const meta = {
...def.meta,
unit: unit || undefined,
description: desc || undefined,
displayLow: parseFloat(dispLow),
displayHigh: parseFloat(dispHigh),
};
const body: any = {
...def,
name: sigName,
graph: compile(graph),
meta,
inputs: undefined,
pipeline: undefined,
ds: undefined,
signal: undefined,
};
if (create) {
body.visibility = visibility;
if (visibility === 'panel' && panelId) body.panel = panelId;
}
const res = await fetch(
create ? '/api/v1/synthetic' : `/api/v1/synthetic/${encodeURIComponent(sigName)}`,
{
method: create ? 'POST' : 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
},
);
if (!res.ok) {
const j = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(j.error ?? res.statusText);
@@ -421,14 +645,15 @@ export default function SyntheticGraphEditor({ name, onClose, onSaved }: Props)
function nodeClass(n: GNode): string {
const cat = n.kind === 'source' ? 'trigger' : n.kind === 'output' ? 'action' : 'flow';
return `flow-node flow-node-${cat}${selected === n.id ? ' flow-node-selected' : ''}`;
const err = nodeErrors.has(n.id) ? ' flow-node-error' : '';
return `flow-node flow-node-${cat}${selected === n.id ? ' flow-node-selected' : ''}${err}`;
}
return (
<div class="wizard-backdrop" onClick={onClose}>
<div class="synth-graph" onClick={(e) => e.stopPropagation()}>
<div class="wizard-header">
<span>Synthetic Signal {name}</span>
<span>Synthetic Signal{create ? ' — new' : `${name}`}</span>
<button class="icon-btn" onClick={onClose}></button>
</div>
@@ -452,8 +677,8 @@ export default function SyntheticGraphEditor({ name, onClose, onSaved }: Props)
onClick={() => addOp(op)}>{op.label}</button>
))}
<div class="flow-palette-hint hint">
Wire input signals into the first operation, chain operations, and connect the
last one to <b>Output</b>. The first node receives every input as <code>a,b,c,d</code>.
Wire signals into each operation's input ports, then connect the result
to <b>Output</b>. Formula / Lua nodes name their own inputs.
</div>
</div>
@@ -466,7 +691,7 @@ export default function SyntheticGraphEditor({ name, onClose, onSaved }: Props)
{graph.wires.map((w, idx) => {
const a = byId.get(w.from); const b = byId.get(w.to);
if (!a || !b) return null;
const p1 = outAnchor(a); const p2 = inAnchor(b);
const p1 = outAnchor(a); const p2 = inAnchor(b, w.toPort);
return (
<path key={idx}
class={`flow-wire${selectedWire === idx ? ' flow-wire-selected' : ''}`}
@@ -482,54 +707,113 @@ export default function SyntheticGraphEditor({ name, onClose, onSaved }: Props)
})()}
</svg>
{graph.nodes.map(node => (
<div key={node.id}
class={nodeClass(node)}
style={`left:${node.x}px; top:${node.y}px; width:${NODE_W}px;`}
onMouseDown={(e) => startNodeDrag(e, node)}
onMouseUp={() => { if (pendingRef.current) finishWire(node); }}>
<div class="flow-node-header">
<span class="flow-node-title">{node.kind === 'source' ? 'Signal' : node.kind === 'output' ? 'Output' : opLabel(node.op ?? '')}</span>
{node.kind !== 'output' && (
<button class="flow-node-del" title="Delete node"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); deleteNode(node.id); }}></button>
{graph.nodes.map(node => {
const nIn = inPortCount(node, graph.wires);
return (
<div key={node.id}
class={nodeClass(node)}
style={`left:${node.x}px; top:${node.y}px; width:${NODE_W}px; min-height:${nodeMinHeight(node, graph.wires)}px;`}
onMouseDown={(e) => startNodeDrag(e, node)}
onMouseUp={() => { if (pendingRef.current) finishWire(node, firstFreePort(node)); }}>
<div class="flow-node-header">
<span class="flow-node-title">{node.kind === 'source' ? 'Signal' : node.kind === 'output' ? 'Output' : opLabel(node.op ?? '')}</span>
{node.kind !== 'output' && (
<button class="flow-node-del" title="Delete node"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); deleteNode(node.id); }}>✕</button>
)}
</div>
<div class="flow-node-body hint">{nodeSummary(node)}</div>
{Array.from({ length: nIn }, (_, port) => {
const label = portLabel(node, port);
return (
<Fragment key={port}>
<div class="flow-port flow-port-in" title={label || `Input ${port + 1}`}
style={`top:${PORT_TOP + port * PORT_GAP - PORT_R}px; left:${-PORT_R}px;`}
onMouseDown={(e) => e.stopPropagation()}
onMouseUp={(e) => { e.stopPropagation(); finishWire(node, port); }} />
{label && (
<span class="flow-port-label-in"
style={`top:${PORT_TOP + port * PORT_GAP - 0.6 * REM}px;`}>{label}</span>
)}
</Fragment>
);
})}
{hasOutput(node.kind) && (
<div class="flow-port flow-port-out" title="Output"
style={`top:${PORT_TOP - PORT_R}px; right:${-PORT_R}px;`}
onMouseDown={(e) => startWire(e, node)} />
)}
</div>
<div class="flow-node-body hint">{nodeSummary(node)}</div>
{hasInput(node.kind) && (
<div class="flow-port flow-port-in" title="Input"
style={`top:${PORT_TOP - PORT_R}px; left:${-PORT_R}px;`}
onMouseDown={(e) => e.stopPropagation()}
onMouseUp={(e) => { e.stopPropagation(); finishWire(node); }} />
)}
{hasOutput(node.kind) && (
<div class="flow-port flow-port-out" title="Output"
style={`top:${PORT_TOP - PORT_R}px; right:${-PORT_R}px;`}
onMouseDown={(e) => startWire(e, node)} />
)}
</div>
))}
);
})}
</div>
</div>
<div class="flow-inspector">
{!sel && <div class="hint">Select a node to edit it, or add one from the palette.</div>}
{!sel && (
<Fragment>
<div class="wizard-section-title">Signal</div>
{create ? (
<Fragment>
<div class="wizard-field">
<label>Name</label>
<input class="prop-input" type="text" value={newName}
placeholder="my_signal"
onInput={(e) => setNewName((e.target as HTMLInputElement).value)} />
</div>
<div class="wizard-field">
<label>Visibility</label>
<select class="prop-input" value={visibility}
onChange={(e) => setVisibility((e.target as HTMLSelectElement).value as any)}>
{panelId && <option value="panel">This panel only</option>}
<option value="user">My signals</option>
<option value="global">Global (all users)</option>
</select>
</div>
</Fragment>
) : (
<p class="hint">Editing <b>{name}</b>.</p>
)}
<div class="wizard-field">
<label>Unit</label>
<input class="prop-input" type="text" value={unit}
onInput={(e) => setUnit((e.target as HTMLInputElement).value)} />
</div>
<div class="wizard-field">
<label>Description</label>
<input class="prop-input" type="text" value={desc}
onInput={(e) => setDesc((e.target as HTMLInputElement).value)} />
</div>
<div class="wizard-field">
<label>Display low</label>
<input class="prop-input" type="number" value={dispLow}
onInput={(e) => setDispLow((e.target as HTMLInputElement).value)} />
</div>
<div class="wizard-field">
<label>Display high</label>
<input class="prop-input" type="number" value={dispHigh}
onInput={(e) => setDispHigh((e.target as HTMLInputElement).value)} />
</div>
<div class="flow-palette-hint hint">Select a node to edit it, or add one from the palette.</div>
</Fragment>
)}
{sel?.kind === 'source' && (
<Fragment>
<div class="wizard-section-title">Input signal</div>
<div class="wizard-field">
<label>Data source</label>
<SearchableSelect value={sel.ds ?? ''} options={dataSources}
onSelect={(ds) => { loadSignals(ds); patchNode(sel.id, { ds, signal: '' }); }} />
</div>
<div class="wizard-field">
<label>Signal</label>
<SearchableSelect value={sel.signal ?? ''} options={dsSignals[sel.ds ?? ''] ?? []}
onSelect={(signal) => patchNode(sel.id, { signal })}
placeholder={sel.ds ? 'Search signals…' : 'Select data source first'} />
<SignalPicker
value={sel.signal ? `${sel.ds ?? ''}:${sel.signal}` : ''}
options={allSignalOptions()}
onOpen={openAllSignals}
onChange={(ref) => {
const i = ref.indexOf(':');
if (i < 0) patchNode(sel.id, { ds: ref, signal: '' });
else patchNode(sel.id, { ds: ref.slice(0, i), signal: ref.slice(i + 1) });
}} />
</div>
<button class="panel-btn" style="margin-top:1rem;" onClick={() => deleteNode(sel.id)}>Delete node</button>
</Fragment>
@@ -538,8 +822,23 @@ export default function SyntheticGraphEditor({ name, onClose, onSaved }: Props)
{sel?.kind === 'op' && (
<Fragment>
<div class="wizard-section-title">{opLabel(sel.op ?? '')}</div>
{opParamDefs(sel.op ?? '').length === 0 && (
<p class="hint">No parameters this operation transforms its input directly.</p>
{opArity(sel.op).kind === 'named' && (
<div class="wizard-field">
<label>Inputs</label>
{((sel.params?.vars as string[] | undefined) ?? []).map((v, i) => (
<div key={i} class="synth-var-row">
<input class="prop-input" type="text" value={v}
onInput={(e) => renameNamedVar(sel.id, i, (e.target as HTMLInputElement).value)} />
<button class="icon-btn" title="Remove input"
onClick={() => removeNamedVar(sel.id, i)}>✕</button>
</div>
))}
<button class="panel-btn" style="margin-top:0.4rem;" onClick={() => addNamedVar(sel.id)}>+ Input</button>
<p class="hint" style="margin-top:0.4rem;">Use these names in the {sel.op === 'lua' ? 'script' : 'formula'} below.</p>
</div>
)}
{opParamDefs(sel.op ?? '').length === 0 && opArity(sel.op).kind !== 'named' && (
<p class="hint">No parameters — this operation transforms its inputs directly.</p>
)}
{opParamDefs(sel.op ?? '').map(pd => (
<div key={pd.key} class="wizard-field">
@@ -570,9 +869,9 @@ export default function SyntheticGraphEditor({ name, onClose, onSaved }: Props)
<div class="wizard-footer">
{error && <span class="wizard-error" style="flex:1;">{error}</span>}
{!error && compiled.error && <span class="hint" style="flex:1;">{compiled.error}</span>}
{!error && validationError && <span class="hint" style="flex:1;">{validationError}</span>}
<button class="toolbar-btn" onClick={onClose}>Cancel</button>
<button class="toolbar-btn toolbar-btn-primary" onClick={handleSave} disabled={saving || loading || !!compiled.error}>
<button class="toolbar-btn toolbar-btn-primary" onClick={handleSave} disabled={saving || loading || !!validationError}>
{saving ? 'Saving' : 'Save Signal'}
</button>
</div>
-279
View File
@@ -1,279 +0,0 @@
import { h } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import LuaEditor from './LuaEditor';
import SearchableSelect from './SearchableSelect';
import type { InputRef, SignalDef } from './lib/types';
interface Props {
onClose: () => void;
onCreated: () => void;
// Interface id the wizard was opened from — used to bind panel-scoped signals.
currentIfaceId?: string;
}
interface NodeParam {
label: string;
key: string;
type: 'number' | 'text' | 'lua';
default: string;
}
const NODE_TYPES: Array<{ type: string; label: string; params: NodeParam[] }> = [
{ type: 'gain', label: 'Gain', params: [{ label: 'Factor', key: 'gain', type: 'number', default: '1' }] },
{ type: 'offset', label: 'Offset', params: [{ label: 'Offset', key: 'offset', type: 'number', default: '0' }] },
{ type: 'moving_average', label: 'Moving Average', params: [{ label: 'Window (n)', key: 'window', type: 'number', default: '10' }] },
{ type: 'rms', label: 'RMS', params: [{ label: 'Window (n)', key: 'window', type: 'number', default: '10' }] },
{ type: 'lowpass', label: 'Low-pass Filter', params: [
{ label: 'Cutoff freq (Hz)', key: 'freq', type: 'number', default: '1' },
{ label: 'Order (18)', key: 'order', type: 'number', default: '1' },
]},
{ type: 'derivative', label: 'Derivative', params: [] },
{ type: 'integrate', label: 'Integrate', params: [] },
{ type: 'clamp', label: 'Clamp', params: [{ label: 'Min', key: 'min', type: 'number', default: '0' }, { label: 'Max', key: 'max', type: 'number', default: '100' }] },
{ type: 'expr', label: 'Formula', params: [{ label: 'Expression (a,b,c,d = inputs)', key: 'expr', type: 'text', default: 'a * 1.0' }] },
{ type: 'lua', label: 'Lua Script', params: [{ label: 'Script (a,b,c,d = inputs)', key: 'script', type: 'lua', default: 'return a' }] },
];
interface DataSource {
name: string;
}
interface SignalInfo {
name: string;
description?: string;
}
// ── Main Wizard ──────────────────────────────────────────────────────────────
export default function SyntheticWizard({ onClose, onCreated, currentIfaceId }: Props) {
const [name, setName] = useState('');
const [inputs, setInputs] = useState<InputRef[]>([{ ds: 'stub', signal: 'sine_1hz' }]);
const [nodeType, setNodeType] = useState('gain');
const [params, setParams] = useState<Record<string, string>>({});
// Visibility scope. Panel scope requires an interface to bind to, so when the
// wizard is opened outside a saved panel it falls back to 'user'.
const [visibility, setVisibility] = useState<'panel' | 'user' | 'global'>(currentIfaceId ? 'panel' : 'user');
const [unit, setUnit] = useState('');
const [desc, setDesc] = useState('');
const [dispLow, setDispLow] = useState('0');
const [dispHigh, setDispHigh] = useState('100');
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
// Loaded data
const [dataSources, setDataSources] = useState<string[]>([]);
const [dsSignals, setDsSignals] = useState<Record<string, string[]>>({});
useEffect(() => {
fetch('/api/v1/datasources')
.then(r => r.ok ? r.json() : [])
.then((ds: DataSource[]) => setDataSources(ds.map(d => d.name)))
.catch(() => {});
}, []);
async function loadSignals(ds: string) {
if (dsSignals[ds]) return;
try {
const res = await fetch(`/api/v1/signals?ds=${encodeURIComponent(ds)}`);
if (!res.ok) return;
const sigs: SignalInfo[] = await res.json();
setDsSignals(prev => ({ ...prev, [ds]: sigs.map(s => s.name) }));
} catch {}
}
const nodeDef = NODE_TYPES.find(n => n.type === nodeType)!;
function getParam(key: string, def: string): string {
return params[key] ?? def;
}
function setParam(key: string, val: string) {
setParams(p => ({ ...p, [key]: val }));
}
function updateInput(idx: number, patch: Partial<InputRef>) {
setInputs(prev => prev.map((inp, i) => {
if (i !== idx) return inp;
const next = { ...inp, ...patch };
if (patch.ds) {
next.signal = '';
loadSignals(patch.ds);
}
return next;
}));
}
function addInput() {
setInputs(prev => [...prev, { ds: dataSources[0] || '', signal: '' }]);
}
function removeInput(idx: number) {
setInputs(prev => prev.filter((_, i) => i !== idx));
}
async function handleCreate() {
if (!name.trim()) { setError('Name is required'); return; }
if (inputs.some(i => !i.ds || !i.signal)) { setError('All inputs must have a data source and signal'); return; }
const nodeParams: Record<string, any> = {};
for (const p of nodeDef.params) {
const raw = getParam(p.key, p.default);
nodeParams[p.key] = p.type === 'number' ? parseFloat(raw) : raw;
}
const def: SignalDef = {
name: name.trim(),
inputs,
pipeline: [{ type: nodeType, params: nodeParams }],
meta: {
unit: unit || undefined,
description: desc || undefined,
displayLow: parseFloat(dispLow) || 0,
displayHigh: parseFloat(dispHigh) || 100,
},
visibility,
...(visibility === 'panel' && currentIfaceId ? { panel: currentIfaceId } : {}),
};
setSaving(true);
setError('');
try {
const res = await fetch('/api/v1/synthetic', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(def),
});
if (!res.ok) {
const j = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(j.error ?? res.statusText);
}
onCreated();
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setSaving(false);
}
}
return (
<div class="wizard-backdrop" onClick={onClose}>
<div class="wizard" onClick={(e) => e.stopPropagation()}>
<div class="wizard-header">
<span>New Synthetic Signal</span>
<button class="icon-btn" onClick={onClose}></button>
</div>
<div class="wizard-body">
{error && <p class="wizard-error">{error}</p>}
<div class="wizard-section-title">Signal identity</div>
<div class="wizard-field">
<label>Name</label>
<input class="prop-input" value={name}
placeholder="e.g. smoothed_pressure"
onInput={(e) => setName((e.target as HTMLInputElement).value)} />
</div>
<div class="wizard-field">
<label>Visibility</label>
<select class="prop-select" value={visibility}
onChange={(e) => setVisibility((e.target as HTMLSelectElement).value as any)}>
<option value="panel" disabled={!currentIfaceId}>
This panel only{currentIfaceId ? '' : ' (save panel first)'}
</option>
<option value="user">All my panels</option>
<option value="global">All panels (global)</option>
</select>
</div>
<div class="wizard-section-title">Input signals</div>
{inputs.map((inp, idx) => (
<div key={idx} class="wizard-field wizard-field-row" style="align-items: flex-end; gap: 0.5rem; margin-bottom: 0.5rem;">
<div class="wizard-field" style="flex:1;">
<label>Data source</label>
<SearchableSelect
value={inp.ds}
options={dataSources}
onSelect={(ds) => updateInput(idx, { ds })}
/>
</div>
<div class="wizard-field" style="flex:2;">
<label>Signal</label>
<SearchableSelect
value={inp.signal}
options={dsSignals[inp.ds] || []}
onSelect={(signal) => updateInput(idx, { signal })}
placeholder={inp.ds ? 'Search signals…' : 'Select data source first'}
/>
</div>
<button
class="icon-btn"
style="margin-bottom: 0.25rem;"
title="Remove input"
onClick={() => removeInput(idx)}
disabled={inputs.length === 1}
></button>
</div>
))}
<button class="panel-btn" style="margin-top: 0.5rem;" onClick={addInput}>+ Add input</button>
<div class="wizard-section-title" style="margin-top: 1.5rem;">Processing</div>
<div class="wizard-field">
<label>Node type</label>
<select class="prop-select" value={nodeType}
onChange={(e) => { setNodeType((e.target as HTMLSelectElement).value); setParams({}); }}>
{NODE_TYPES.map(n => <option key={n.type} value={n.type}>{n.label}</option>)}
</select>
</div>
{nodeDef.params.map(p => (
<div key={p.key} class="wizard-field">
<label>{p.label}</label>
{p.type === 'lua' ? (
<LuaEditor
value={getParam(p.key, p.default)}
onChange={(v) => setParam(p.key, v)}
/>
) : (
<input class="prop-input" type={p.type === 'number' ? 'number' : 'text'}
value={getParam(p.key, p.default)}
onInput={(e) => setParam(p.key, (e.target as HTMLInputElement).value)} />
)}
</div>
))}
<div class="wizard-section-title">Metadata (optional)</div>
<div class="wizard-field wizard-field-row">
<div class="wizard-field">
<label>Unit</label>
<input class="prop-input" value={unit} placeholder="m/s"
onInput={(e) => setUnit((e.target as HTMLInputElement).value)} />
</div>
<div class="wizard-field">
<label>Display low</label>
<input class="prop-input" type="number" value={dispLow}
onInput={(e) => setDispLow((e.target as HTMLInputElement).value)} />
</div>
<div class="wizard-field">
<label>Display high</label>
<input class="prop-input" type="number" value={dispHigh}
onInput={(e) => setDispHigh((e.target as HTMLInputElement).value)} />
</div>
</div>
<div class="wizard-field">
<label>Description</label>
<input class="prop-input" value={desc} placeholder="Optional description"
onInput={(e) => setDesc((e.target as HTMLInputElement).value)} />
</div>
</div>
<div class="wizard-footer">
<button class="toolbar-btn" onClick={onClose}>Cancel</button>
<button class="toolbar-btn toolbar-btn-primary" onClick={handleCreate} disabled={saving}>
{saving ? 'Creating…' : 'Create Signal'}
</button>
</div>
</div>
</div>
);
}
+20 -8
View File
@@ -7,10 +7,10 @@ import { parseInterface } from './lib/xml';
import { newPlotPanel } from './lib/templates';
import { useAuth, canWrite } from './lib/auth';
import type { Interface } from './lib/types';
import ContextualHelp from './ContextualHelp';
import HelpModal from './HelpModal';
import ZoomControl from './ZoomControl';
import ControlLogicEditor from './ControlLogicEditor';
import AuditViewer from './AuditViewer';
interface Props {
onEdit?: (iface?: Interface) => void;
@@ -33,6 +33,7 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) {
const [helpSection, setHelpSection] = useState('start');
const [showTimeNav, setShowTimeNav] = useState(false);
const [showControlLogic, setShowControlLogic] = useState(false);
const [showAudit, setShowAudit] = useState(false);
const [leftW, setLeftW] = useState(220);
const [listCollapsed, setListCollapsed] = useState(false);
const me = useAuth();
@@ -137,9 +138,13 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) {
)}
</div>
<div class="toolbar-right">
<div class={`status-chip ${wsStatus}`}>
<div
class={`status-chip ${wsStatus}`}
title={me.user ? `Signed in as ${me.user}${writable ? '' : ' (read-only)'}` : undefined}
>
<span class="status-dot"></span>
{wsStatus === 'connected' && me.user ? `connected as ${me.user}` : wsStatus}
{me.user && !writable && ' (read-only)'}
</div>
<button
class={`toolbar-btn${showTimeNav ? ' toolbar-btn-active' : ''}`}
@@ -148,7 +153,6 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) {
>
History
</button>
<ContextualHelp mode="view" onOpenManual={openHelp} />
<button
class="icon-btn help-manual-btn"
onClick={() => openHelp('start')}
@@ -166,6 +170,15 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) {
Control logic
</button>
)}
{me.canViewAudit && (
<button
class="toolbar-btn"
onClick={() => setShowAudit(true)}
title="View the audit log"
>
🛡 Audit
</button>
)}
{writable && (
<button
class="btn-edit"
@@ -175,11 +188,6 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) {
Edit
</button>
)}
{me.user && (
<span class="user-chip" title={`Signed in as ${me.user}${writable ? '' : ' (read-only)'}`}>
{me.user}{!writable && ' (read-only)'}
</span>
)}
</div>
</header>
@@ -257,6 +265,10 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) {
{showControlLogic && (
<ControlLogicEditor onClose={() => setShowControlLogic(false)} />
)}
{showAudit && (
<AuditViewer onClose={() => setShowAudit(false)} />
)}
</div>
);
}
+2 -1
View File
@@ -4,7 +4,7 @@ import type { Me, AccessLevel } from './types';
// Default identity used before /api/v1/me resolves (or if it fails): assume a
// trusted LAN user with full access, matching the backend default.
export const DEFAULT_ME: Me = { user: '', level: 'write', groups: [], canEditLogic: true };
export const DEFAULT_ME: Me = { user: '', level: 'write', groups: [], canEditLogic: true, canViewAudit: true };
// AuthContext carries the resolved identity + global access level for the
// current user throughout the app.
@@ -37,6 +37,7 @@ export async function fetchMe(): Promise<Me> {
level: (data.level as AccessLevel) ?? 'write',
groups: Array.isArray(data.groups) ? data.groups : [],
canEditLogic: data.canEditLogic !== false,
canViewAudit: data.canViewAudit !== false,
};
} catch {
return DEFAULT_ME;
+24
View File
@@ -0,0 +1,24 @@
import { writable } from './store';
// A dialog request pushed by a server-side control-logic action.dialog node and
// delivered over the WebSocket. Unlike panel logic dialogs (lib/logic.ts) these
// originate on the server and are addressed to the connected user by identity.
export interface ControlDialog {
id: string;
kind: 'info' | 'error' | 'input';
title?: string;
message?: string;
}
// Active control-logic dialogs awaiting the user's acknowledgement / input.
export const controlDialogs = writable<ControlDialog[]>([]);
// pushControlDialog is invoked by the WS client on a "dialog" message.
export function pushControlDialog(d: ControlDialog): void {
controlDialogs.update(list => (list.some(x => x.id === d.id) ? list : [...list, d]));
}
// dismissControlDialog removes a dialog once answered or cancelled.
export function dismissControlDialog(id: string): void {
controlDialogs.update(list => list.filter(d => d.id !== id));
}
+28 -1
View File
@@ -9,6 +9,9 @@ export interface SignalValue {
value: any;
quality: 'good' | 'uncertain' | 'bad' | 'unknown';
ts: string | null;
// Raw EPICS alarm fields (absent/0 = NO_ALARM). severity: 1=MINOR,2=MAJOR,3=INVALID.
severity?: number;
status?: number;
}
// Metadata for a signal (received once on subscribe)
@@ -209,6 +212,9 @@ export interface Me {
// Whether the user may add/edit panel logic and server-side control logic.
// False only when a logic-editors allowlist is configured and excludes them.
canEditLogic: boolean;
// Whether the user may view the audit log. False only when an audit-readers
// allowlist is configured and excludes them, or auditing is disabled.
canViewAudit: boolean;
}
// Effective permission a user holds on a single panel or folder.
@@ -269,12 +275,33 @@ export interface PipelineNode {
params: Record<string, any>;
}
// DAG form of a synthetic signal (mirrors backend synthetic.Graph). A node is a
// source (ds:signal root), an op (DSP node with ordered `inputs`), or the single
// output. When present it supersedes the legacy inputs+pipeline linear form.
export type SynthNodeKind = 'source' | 'op' | 'output';
export interface SynthGraphNode {
id: string;
kind: SynthNodeKind;
op?: string;
params?: Record<string, any>;
ds?: string;
signal?: string;
inputs?: string[]; // upstream node ids, in input order
x?: number;
y?: number;
}
export interface SynthGraph {
nodes: SynthGraphNode[];
output: string;
}
export interface SignalDef {
name: string;
ds?: string; // legacy single input
signal?: string; // legacy single input
inputs?: InputRef[];
pipeline: PipelineNode[];
pipeline?: PipelineNode[];
graph?: SynthGraph; // DAG form (preferred when present)
meta: {
unit?: string;
description?: string;
+21
View File
@@ -1,5 +1,6 @@
import { writable, type Readable } from './store';
import { writeLocalState } from './localstate';
import { pushControlDialog } from './controldialogs';
import type { SignalRef, SignalValue, SignalMeta, HistoryPoint } from './types';
// ── Types ────────────────────────────────────────────────────────────────────
@@ -130,6 +131,8 @@ class WsClient {
value: msg.value,
quality: msg.quality ?? 'unknown',
ts: msg.ts ?? null,
severity: msg.severity ?? 0,
status: msg.status ?? 0,
};
for (const s of subs) s.onUpdate(val);
break;
@@ -161,6 +164,16 @@ class WsClient {
}
break;
}
case 'dialog': {
// Server-side control logic requesting a user notification / input.
pushControlDialog({
id: String(msg.id),
kind: msg.kind === 'error' || msg.kind === 'input' ? msg.kind : 'info',
title: msg.title,
message: msg.message,
});
break;
}
case 'error':
// Resolve any pending history callbacks with empty data on error
if (key && this.histCallbacks.has(key)) {
@@ -268,6 +281,14 @@ class WsClient {
console.log('[ws] write', ref.ds, ref.name, value, 'ws state:', this.ws?.readyState);
this._send({ type: 'write', ds: ref.ds, name: ref.name, value });
}
/**
* Answer a control-logic input dialog. `value === null` cancels (dismisses)
* the dialog without writing its target server variable.
*/
sendDialogResponse(id: string, value: number | null): void {
this._send({ type: 'dialogResponse', id, value });
}
}
// Singleton instance
+203 -81
View File
@@ -123,6 +123,8 @@ body {
color: #94a3b8;
border: 1px solid #334155;
text-transform: capitalize;
white-space: nowrap;
flex-shrink: 0;
}
.status-chip.connected {
@@ -165,18 +167,6 @@ body {
}
/* ── User identity chip ──────────────────────────────────────────────────── */
.user-chip {
display: flex;
align-items: center;
font-size: 0.75rem;
padding: 0.2rem 0.6rem;
border-radius: 9999px;
background: #1e293b;
color: #cbd5e1;
border: 1px solid #334155;
white-space: nowrap;
}
/* ── Access denied screen ────────────────────────────────────────────────── */
.access-denied {
display: flex;
@@ -1273,6 +1263,8 @@ body {
.flow-node-flow { border-top: 3px solid #10b981; }
.flow-node-action { border-top: 3px solid #3b82f6; }
.flow-node-selected { border-color: #3b82f6; box-shadow: 0 0 0 2px #3b82f680; }
.flow-node-error { border-color: #ef4444; box-shadow: 0 0 0 2px #ef444455; }
.flow-node-error.flow-node-selected { box-shadow: 0 0 0 2px #ef4444aa; }
.flow-node-header {
display: flex;
@@ -1319,6 +1311,24 @@ body {
pointer-events: none;
}
/* Named input ports (Formula/Lua) show their variable name beside the port. */
.flow-port-label-in {
position: absolute;
left: 0.55rem;
font-size: 0.6rem;
color: #94a3b8;
pointer-events: none;
}
/* Named-input editor row in the inspector */
.synth-var-row {
display: flex;
gap: 0.35rem;
align-items: center;
margin-bottom: 0.3rem;
}
.synth-var-row > input { flex: 1; min-width: 0; }
/* Expression field: signal-insert helper row */
.flow-expr-insert {
display: flex;
@@ -2746,90 +2756,50 @@ kbd {
color: #e2e8f0;
}
/* ── Contextual Help ─────────────────────────────────────────────────────── */
/* ── Manual: Quick Tips list ─────────────────────────────────────────────── */
.ctx-help {
position: relative;
display: flex;
align-items: center;
}
.ctx-help-btn {
width: 22px;
height: 22px;
border-radius: 50%;
border: 1.5px solid #4a5568;
background: #1a2236;
color: #94a3b8;
font-size: 0.78rem;
font-weight: 700;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: border-color 0.15s, color 0.15s;
flex-shrink: 0;
line-height: 1;
.help-tips-list {
list-style: none;
margin: 0 0 1rem;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.ctx-help-btn:hover { border-color: #4a9eff; color: #4a9eff; }
.ctx-help-popover {
position: absolute;
top: calc(100% + 8px);
right: 0;
z-index: 400;
.help-tip-item {
display: flex;
align-items: center;
gap: 0.6rem;
width: 100%;
text-align: left;
background: #1a1f2e;
border: 1px solid #2d3748;
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
width: 280px;
padding: 0.75rem;
}
.ctx-help-tip {
display: flex;
gap: 0.5rem;
font-size: 0.82rem;
border-radius: 6px;
padding: 0.55rem 0.7rem;
color: #cbd5e1;
line-height: 1.5;
margin-bottom: 0.6rem;
font-size: 0.85rem;
line-height: 1.45;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
}
.help-tip-item:hover {
border-color: #4a9eff;
background: #1e2540;
}
.ctx-help-icon {
font-size: 1rem;
.help-tip-icon {
flex-shrink: 0;
margin-top: 0.05rem;
font-size: 1rem;
}
.ctx-help-footer {
display: flex;
justify-content: space-between;
align-items: center;
border-top: 1px solid #2d3748;
padding-top: 0.5rem;
gap: 0.5rem;
}
.help-tip-text { flex: 1; }
.ctx-help-nav {
font-size: 0.75rem;
color: #64748b;
background: none;
border: none;
cursor: pointer;
padding: 0;
}
.ctx-help-nav:hover { color: #94a3b8; }
.ctx-help-link {
font-size: 0.75rem;
.help-tip-go {
flex-shrink: 0;
color: #4a9eff;
background: none;
border: none;
cursor: pointer;
padding: 0;
font-weight: 700;
}
.ctx-help-link:hover { text-decoration: underline; }
/* ── Historical time-nav bar (below main toolbar, hidden by default) ──────── */
@@ -3446,6 +3416,37 @@ kbd {
overflow: hidden;
}
.cl-confirm-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
display: flex;
align-items: center;
justify-content: center;
z-index: 650;
}
.cl-confirm {
background: #1a1f2e;
border: 1px solid #2d3748;
border-radius: 10px;
padding: 1.25rem 1.5rem;
width: min(440px, 92vw);
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.6);
}
.cl-confirm-title {
font-weight: 600;
margin-bottom: 0.5rem;
}
.cl-confirm-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 1.25rem;
}
.cl-header {
display: flex;
align-items: center;
@@ -3455,6 +3456,127 @@ kbd {
flex-shrink: 0;
}
/* ── Audit log viewer ──────────────────────────────────────────────────── */
.audit-modal {
width: min(1300px, 98vw);
}
.audit-filters {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.6rem 1.25rem;
border-bottom: 1px solid #2d3748;
flex-shrink: 0;
}
.audit-filter {
background: #0f1420;
border: 1px solid #2d3748;
border-radius: 5px;
color: #e2e8f0;
padding: 0.3rem 0.5rem;
font-size: 0.8rem;
}
.audit-filter-label {
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.75rem;
color: #94a3b8;
}
.audit-body {
flex: 1;
overflow: auto;
padding: 0 0 0.5rem;
}
.audit-empty {
padding: 1.5rem 1.25rem;
}
.audit-table {
width: 100%;
border-collapse: collapse;
font-size: 0.8rem;
}
.audit-table th,
.audit-table td {
text-align: left;
padding: 0.35rem 0.6rem;
border-bottom: 1px solid #232b3a;
white-space: nowrap;
}
.audit-table th {
position: sticky;
top: 0;
background: #161c2a;
color: #94a3b8;
font-weight: 600;
z-index: 1;
}
.audit-table tbody tr:hover {
background: #1d2433;
}
.audit-row-error td {
background: rgba(220, 38, 38, 0.08);
}
.audit-time {
color: #94a3b8;
font-variant-numeric: tabular-nums;
}
.audit-signal,
.audit-detail {
max-width: 18rem;
overflow: hidden;
text-overflow: ellipsis;
}
.audit-value {
max-width: 10rem;
overflow: hidden;
text-overflow: ellipsis;
font-family: ui-monospace, monospace;
}
.audit-actor-tag {
display: inline-block;
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.03em;
padding: 0.05rem 0.35rem;
border-radius: 3px;
vertical-align: middle;
}
.audit-actor-user {
background: #1e3a5f;
color: #93c5fd;
}
.audit-actor-system {
background: #4a2d6b;
color: #d8b4fe;
}
.audit-result-ok {
color: #4ade80;
}
.audit-result-err {
color: #f87171;
font-weight: 600;
}
.cl-title {
font-size: 1rem;
font-weight: 700;
+27 -1
View File
@@ -90,6 +90,17 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
// ── uPlot (timeseries) ──────────────────────────────────────────────────
let uplot: uPlot | null = null;
// Newest sample timestamp across all series (seconds). Anchoring the live
// x-axis to this — rather than wall-clock Date.now() — avoids client/server
// clock skew clipping the latest point, and keeps the right edge on real data.
function latestSampleTs(): number {
let m = -Infinity;
for (const b of buffers) {
if (b.timestamps.length) m = Math.max(m, b.timestamps[b.timestamps.length - 1]);
}
return isFinite(m) ? m : Date.now() / 1000;
}
// windowSec: apply rolling window for live mode; 0 = use full buffer (historical)
function uplotData(windowSec = 0): uPlot.AlignedData {
if (signals.length === 0) return [[]];
@@ -165,7 +176,22 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
{ stroke: '#94a3b8', grid: { stroke: '#2d3748' }, ticks: { stroke: '#475569' } },
yAxis,
],
scales: { x: { time: true }, y: { ...scaleY, range: scaleYRange } },
scales: {
x: {
time: true,
// Live mode with a finite window: pin the x-axis to a rolling
// [newest window, newest] span so a single old/outlier sample
// can't stretch the axis and compress real points to the right.
// Historical mode (timeRange) keeps uPlot's data-driven auto-range.
...(!timeRange && timeWindow > 0
? { range: (): [number, number] => {
const r = latestSampleTs();
return [r - timeWindow, r];
} }
: {}),
},
y: { ...scaleY, range: scaleYRange },
},
};
}
+22 -1
View File
@@ -1,10 +1,31 @@
{
"panels": {},
"panels": {
"epics_test": {
"owner": ""
},
"new-plot-panel": {
"owner": "",
"folder": "fld-c5396468072d4f69"
},
"test": {
"owner": "martino",
"order": 1
},
"test-1781729251317": {
"owner": "",
"order": 3
}
},
"folders": {
"fld-68b0bb9c23c9fd3b": {
"id": "fld-68b0bb9c23c9fd3b",
"name": "Test",
"owner": ""
},
"fld-c5396468072d4f69": {
"id": "fld-c5396468072d4f69",
"name": "Plots",
"owner": "martino"
}
}
}
Binary file not shown.
Binary file not shown.
View File
+1
View File
@@ -0,0 +1 @@
[]
@@ -0,0 +1,7 @@
{
"id": "cl-0f99598ca4d0ea15",
"name": "New control logic",
"enabled": false,
"nodes": [],
"wires": []
}