Implementing more advanced feature: audit and more
This commit is contained in:
+45
-3
@@ -9,14 +9,18 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"os/user"
|
||||||
|
"path/filepath"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
"github.com/uopi/uopi/internal/access"
|
"github.com/uopi/uopi/internal/access"
|
||||||
|
"github.com/uopi/uopi/internal/audit"
|
||||||
"github.com/uopi/uopi/internal/broker"
|
"github.com/uopi/uopi/internal/broker"
|
||||||
"github.com/uopi/uopi/internal/config"
|
"github.com/uopi/uopi/internal/config"
|
||||||
"github.com/uopi/uopi/internal/controllogic"
|
"github.com/uopi/uopi/internal/controllogic"
|
||||||
"github.com/uopi/uopi/internal/datasource/epics"
|
"github.com/uopi/uopi/internal/datasource/epics"
|
||||||
"github.com/uopi/uopi/internal/datasource/pva"
|
"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/stub"
|
||||||
"github.com/uopi/uopi/internal/datasource/synthetic"
|
"github.com/uopi/uopi/internal/datasource/synthetic"
|
||||||
"github.com/uopi/uopi/internal/panelacl"
|
"github.com/uopi/uopi/internal/panelacl"
|
||||||
@@ -41,6 +45,16 @@ func main() {
|
|||||||
os.Exit(1)
|
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 {
|
if err := os.MkdirAll(cfg.Server.StorageDir, 0o755); err != nil {
|
||||||
log.Error("failed to create storage dir", "dir", cfg.Server.StorageDir, "err", err)
|
log.Error("failed to create storage dir", "dir", cfg.Server.StorageDir, "err", err)
|
||||||
os.Exit(1)
|
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
|
// Build the global access policy from config: every user is trusted with
|
||||||
// full write access unless downgraded by the blacklist; groups are named
|
// full write access unless downgraded by the blacklist; groups are named
|
||||||
// sets of users referenced by per-panel sharing.
|
// sets of users referenced by per-panel sharing.
|
||||||
@@ -133,7 +156,26 @@ func main() {
|
|||||||
for _, g := range cfg.Groups {
|
for _, g := range cfg.Groups {
|
||||||
groups[g.Name] = g.Members
|
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
|
// 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
|
// context, independent of any panel. The store is loaded from disk and the
|
||||||
@@ -143,10 +185,10 @@ func main() {
|
|||||||
log.Error("failed to open control logic store", "err", err)
|
log.Error("failed to open control logic store", "err", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
ctrlEngine := controllogic.NewEngine(ctx, brk, ctrlStore, log)
|
ctrlEngine := controllogic.NewEngine(ctx, brk, ctrlStore, recorder, log)
|
||||||
ctrlEngine.Reload()
|
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, recorder, cfg.Datasource.EPICS.ChannelFinderURL, cfg.Datasource.EPICS.ArchiveURL, cfg.Server.TrustedUserHeader, log)
|
||||||
|
|
||||||
if err := srv.Start(ctx); err != nil {
|
if err := srv.Start(ctx); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "server error: %v\n", err)
|
fmt.Fprintf(os.Stderr, "server error: %v\n", err)
|
||||||
|
|||||||
@@ -9,4 +9,16 @@ require (
|
|||||||
github.com/yuin/gopher-lua v1.1.2
|
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
|
||||||
|
)
|
||||||
|
|||||||
@@ -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/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 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
|
||||||
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
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 h1:V96ghtc5p5JnNUQIUsc5H3kr+AcFcMqOJll2ZmJW6Lo=
|
||||||
github.com/evanw/esbuild v0.28.0/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48=
|
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 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA=
|
||||||
github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8=
|
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 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ=
|
||||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
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=
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ func TestCanEditLogic(t *testing.T) {
|
|||||||
groups := map[string][]string{"ops": {"carol"}}
|
groups := map[string][]string{"ops": {"carol"}}
|
||||||
|
|
||||||
// No allowlist configured: everyone with write access may edit logic.
|
// 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"} {
|
for _, u := range []string{"", "alice", "carol"} {
|
||||||
if !open.CanEditLogic(u) {
|
if !open.CanEditLogic(u) {
|
||||||
t.Errorf("unrestricted: CanEditLogic(%q) = false, want true", 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.
|
// 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() {
|
if !p.LogicRestricted() {
|
||||||
t.Error("LogicRestricted() = false with allowlist set")
|
t.Error("LogicRestricted() = false with allowlist set")
|
||||||
}
|
}
|
||||||
|
|||||||
+100
-5
@@ -8,12 +8,15 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/uopi/uopi/internal/access"
|
"github.com/uopi/uopi/internal/access"
|
||||||
|
"github.com/uopi/uopi/internal/audit"
|
||||||
"github.com/uopi/uopi/internal/broker"
|
"github.com/uopi/uopi/internal/broker"
|
||||||
"github.com/uopi/uopi/internal/controllogic"
|
"github.com/uopi/uopi/internal/controllogic"
|
||||||
"github.com/uopi/uopi/internal/datasource"
|
"github.com/uopi/uopi/internal/datasource"
|
||||||
@@ -31,13 +34,18 @@ type Handler struct {
|
|||||||
acl *panelacl.Store
|
acl *panelacl.Store
|
||||||
ctrlLogic *controllogic.Store
|
ctrlLogic *controllogic.Store
|
||||||
ctrlEngine *controllogic.Engine
|
ctrlEngine *controllogic.Engine
|
||||||
channelFinderURL string // empty if not configured
|
audit audit.Recorder // never nil; audit.Nop when disabled
|
||||||
archiverURL string // empty if not configured
|
channelFinderURL string // empty if not configured
|
||||||
|
archiverURL string // empty if not configured
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates an API Handler. synth may be nil if the synthetic DS is disabled.
|
// 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{
|
return &Handler{
|
||||||
broker: b,
|
broker: b,
|
||||||
synthetic: synth,
|
synthetic: synth,
|
||||||
@@ -46,6 +54,7 @@ func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, pol
|
|||||||
acl: acl,
|
acl: acl,
|
||||||
ctrlLogic: ctrlLogic,
|
ctrlLogic: ctrlLogic,
|
||||||
ctrlEngine: ctrlEngine,
|
ctrlEngine: ctrlEngine,
|
||||||
|
audit: rec,
|
||||||
channelFinderURL: channelFinderURL,
|
channelFinderURL: channelFinderURL,
|
||||||
archiverURL: archiverURL,
|
archiverURL: archiverURL,
|
||||||
log: log,
|
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.
|
// Requires Go 1.22+ for method+path routing.
|
||||||
func (h *Handler) Register(mux *http.ServeMux, prefix string) {
|
func (h *Handler) Register(mux *http.ServeMux, prefix string) {
|
||||||
mux.HandleFunc("GET "+prefix+"/me", h.getMe)
|
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+"/datasources", h.listDataSources)
|
||||||
mux.HandleFunc("GET "+prefix+"/signals", h.listSignals)
|
mux.HandleFunc("GET "+prefix+"/signals", h.listSignals)
|
||||||
mux.HandleFunc("GET "+prefix+"/signals/search", h.searchSignals)
|
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(),
|
"level": h.policy.Level(user).String(),
|
||||||
"groups": groups,
|
"groups": groups,
|
||||||
"canEditLogic": h.policy.CanEditLogic(user),
|
"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 ────────────────────────────────────────────────────────────
|
// ── access helpers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// caller returns the resolved end-user identity for the request (set by the
|
// caller returns the resolved end-user identity for the request (set by the
|
||||||
// access middleware).
|
// access middleware).
|
||||||
func caller(r *http.Request) string { return access.UserFrom(r.Context()) }
|
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
|
// 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
|
// access level allows: read-only users can never exceed read, no-access users
|
||||||
// get nothing.
|
// 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.log.Error("record panel ownership", "id", id, "err", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
h.recordMutation(r, "interface.create", id)
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusCreated)
|
w.WriteHeader(http.StatusCreated)
|
||||||
_ = json.NewEncoder(w).Encode(map[string]string{"id": id})
|
_ = json.NewEncoder(w).Encode(map[string]string{"id": id})
|
||||||
@@ -580,6 +669,7 @@ func (h *Handler) updateInterface(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
h.recordMutation(r, "interface.update", id)
|
||||||
w.WriteHeader(http.StatusNoContent)
|
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 {
|
if err := h.acl.DeletePanel(id); err != nil {
|
||||||
h.log.Error("delete panel ACL", "id", id, "err", err)
|
h.log.Error("delete panel ACL", "id", id, "err", err)
|
||||||
}
|
}
|
||||||
|
h.recordMutation(r, "interface.delete", id)
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1123,6 +1214,7 @@ func (h *Handler) createControlLogic(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.ctrlEngine.Reload()
|
h.ctrlEngine.Reload()
|
||||||
|
h.recordMutation(r, "controllogic.create", g.ID+" "+g.Name)
|
||||||
w.WriteHeader(http.StatusCreated)
|
w.WriteHeader(http.StatusCreated)
|
||||||
jsonOK(w, g)
|
jsonOK(w, g)
|
||||||
}
|
}
|
||||||
@@ -1152,6 +1244,7 @@ func (h *Handler) updateControlLogic(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.ctrlEngine.Reload()
|
h.ctrlEngine.Reload()
|
||||||
|
h.recordMutation(r, "controllogic.update", g.ID+" "+g.Name)
|
||||||
jsonOK(w, g)
|
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")
|
jsonError(w, http.StatusForbidden, "you are not permitted to edit logic")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := h.ctrlLogic.Delete(r.PathValue("id")); err != nil {
|
id := r.PathValue("id")
|
||||||
jsonError(w, http.StatusNotFound, "control logic graph not found: "+r.PathValue("id"))
|
if err := h.ctrlLogic.Delete(id); err != nil {
|
||||||
|
jsonError(w, http.StatusNotFound, "control logic graph not found: "+id)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.ctrlEngine.Reload()
|
h.ctrlEngine.Reload()
|
||||||
|
h.recordMutation(r, "controllogic.delete", id)
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
|
|
||||||
"github.com/uopi/uopi/internal/access"
|
"github.com/uopi/uopi/internal/access"
|
||||||
"github.com/uopi/uopi/internal/api"
|
"github.com/uopi/uopi/internal/api"
|
||||||
|
"github.com/uopi/uopi/internal/audit"
|
||||||
"github.com/uopi/uopi/internal/broker"
|
"github.com/uopi/uopi/internal/broker"
|
||||||
"github.com/uopi/uopi/internal/controllogic"
|
"github.com/uopi/uopi/internal/controllogic"
|
||||||
"github.com/uopi/uopi/internal/datasource/stub"
|
"github.com/uopi/uopi/internal/datasource/stub"
|
||||||
@@ -50,10 +51,10 @@ func setup(t *testing.T) (*httptest.Server, func()) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal("controllogic.NewStore:", err)
|
t.Fatal("controllogic.NewStore:", err)
|
||||||
}
|
}
|
||||||
clEngine := controllogic.NewEngine(ctx, brk, clStore, log)
|
clEngine := controllogic.NewEngine(ctx, brk, clStore, audit.Nop(), log)
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
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)
|
srv := httptest.NewServer(mux)
|
||||||
return srv, func() {
|
return srv, func() {
|
||||||
@@ -214,7 +215,9 @@ func TestSearchSignals(t *testing.T) {
|
|||||||
resp := get(t, srv, "/api/v1/signals/search?q=sine")
|
resp := get(t, srv, "/api/v1/signals/search?q=sine")
|
||||||
assertStatus(t, resp, http.StatusOK)
|
assertStatus(t, resp, http.StatusOK)
|
||||||
|
|
||||||
var signals []struct{ Name string `json:"name"` }
|
var signals []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
readJSON(t, resp, &signals)
|
readJSON(t, resp, &signals)
|
||||||
|
|
||||||
for _, s := range signals {
|
for _, s := range signals {
|
||||||
@@ -259,7 +262,9 @@ func TestInterfaceCRUD(t *testing.T) {
|
|||||||
// Create
|
// Create
|
||||||
resp = postRaw(t, srv, "/api/v1/interfaces", "application/xml", []byte(sampleXML))
|
resp = postRaw(t, srv, "/api/v1/interfaces", "application/xml", []byte(sampleXML))
|
||||||
assertStatus(t, resp, http.StatusCreated)
|
assertStatus(t, resp, http.StatusCreated)
|
||||||
var created struct{ ID string `json:"id"` }
|
var created struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
readJSON(t, resp, &created)
|
readJSON(t, resp, &created)
|
||||||
if created.ID == "" {
|
if created.ID == "" {
|
||||||
t.Fatal("expected non-empty ID from create")
|
t.Fatal("expected non-empty ID from create")
|
||||||
@@ -303,7 +308,9 @@ func TestInterfaceCRUD(t *testing.T) {
|
|||||||
t.Fatal("clone:", err)
|
t.Fatal("clone:", err)
|
||||||
}
|
}
|
||||||
assertStatus(t, resp, http.StatusCreated)
|
assertStatus(t, resp, http.StatusCreated)
|
||||||
var cloned struct{ ID string `json:"id"` }
|
var cloned struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
readJSON(t, resp, &cloned)
|
readJSON(t, resp, &cloned)
|
||||||
if cloned.ID == created.ID {
|
if cloned.ID == created.ID {
|
||||||
t.Error("clone produced same ID as original")
|
t.Error("clone produced same ID as original")
|
||||||
|
|||||||
@@ -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{} }
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -84,10 +84,10 @@ type StubConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type EPICSConfig struct {
|
type EPICSConfig struct {
|
||||||
Enabled bool `toml:"enabled"`
|
Enabled bool `toml:"enabled"`
|
||||||
CAAddrList string `toml:"ca_addr_list"`
|
CAAddrList string `toml:"ca_addr_list"`
|
||||||
ArchiveURL string `toml:"archive_url"`
|
ArchiveURL string `toml:"archive_url"`
|
||||||
ChannelFinderURL string `toml:"channel_finder_url"`
|
ChannelFinderURL string `toml:"channel_finder_url"`
|
||||||
AutoSyncFilter string `toml:"auto_sync_filter"`
|
AutoSyncFilter string `toml:"auto_sync_filter"`
|
||||||
AutoSyncFromArchiver bool `toml:"auto_sync_from_archiver"`
|
AutoSyncFromArchiver bool `toml:"auto_sync_from_archiver"`
|
||||||
PVNames []string `toml:"pv_names"`
|
PVNames []string `toml:"pv_names"`
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/uopi/uopi/internal/audit"
|
||||||
"github.com/uopi/uopi/internal/broker"
|
"github.com/uopi/uopi/internal/broker"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -25,23 +26,29 @@ const (
|
|||||||
type Engine struct {
|
type Engine struct {
|
||||||
broker *broker.Broker
|
broker *broker.Broker
|
||||||
store *Store
|
store *Store
|
||||||
|
audit audit.Recorder
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
root context.Context
|
root context.Context
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
cancel context.CancelFunc // cancels the current generation
|
cancel context.CancelFunc // cancels the current generation
|
||||||
wg *sync.WaitGroup // tracks the current generation's goroutines
|
wg *sync.WaitGroup // tracks the current generation's goroutines
|
||||||
|
|
||||||
// Shared live signal cache for the current generation (key "ds\0name").
|
// Shared live signal cache for the current generation (key "ds\0name").
|
||||||
liveMu sync.RWMutex
|
liveMu sync.RWMutex
|
||||||
live map[string]float64
|
live map[string]float64
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewEngine creates an engine bound to root. Call Reload to start it.
|
// NewEngine creates an engine bound to root. Call Reload to start it. rec records
|
||||||
func NewEngine(root context.Context, brk *broker.Broker, store *Store, log *slog.Logger) *Engine {
|
// 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{
|
return &Engine{
|
||||||
broker: brk,
|
broker: brk,
|
||||||
store: store,
|
store: store,
|
||||||
|
audit: rec,
|
||||||
log: log,
|
log: log,
|
||||||
root: root,
|
root: root,
|
||||||
live: map[string]float64{},
|
live: map[string]float64{},
|
||||||
@@ -227,9 +234,22 @@ func (e *Engine) write(cg *compiledGraph, target string, val float64) {
|
|||||||
e.log.Warn("control logic: write to unknown data source", "ds", ds, "signal", name)
|
e.log.Warn("control logic: write to unknown data source", "ds", ds, "signal", name)
|
||||||
return
|
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 {
|
if err := src.Write(e.root, name, val); err != nil {
|
||||||
e.log.Warn("control logic: write failed", "ds", ds, "signal", name, "err", err)
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── compiled graph ─────────────────────────────────────────────────────────────
|
// ── compiled graph ─────────────────────────────────────────────────────────────
|
||||||
@@ -245,11 +265,11 @@ type compiledGraph struct {
|
|||||||
genCtx context.Context
|
genCtx context.Context
|
||||||
wg *sync.WaitGroup
|
wg *sync.WaitGroup
|
||||||
|
|
||||||
name string
|
name string
|
||||||
byId map[string]Node
|
byId map[string]Node
|
||||||
out map[string][]wireOut
|
out map[string][]wireOut
|
||||||
inc map[string][]string // incoming source ids per node (for gates)
|
inc map[string][]string // incoming source ids per node (for gates)
|
||||||
refs map[string]RefLite // unique signals to subscribe (excl. sys/local)
|
refs map[string]RefLite // unique signals to subscribe (excl. sys/local)
|
||||||
|
|
||||||
watchers map[string][]string // signal key → trigger node ids
|
watchers map[string][]string // signal key → trigger node ids
|
||||||
luaNodes map[string]*luaRuntime
|
luaNodes map[string]*luaRuntime
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const definitionsFile = "controllogic.json"
|
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.
|
// 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.
|
// Writes are atomic (tmp file + rename); all access is mutex-guarded.
|
||||||
type Store struct {
|
type Store struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
path string
|
path string
|
||||||
items map[string]Graph
|
trashDir string
|
||||||
|
items map[string]Graph
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewStore opens (or initialises) the control-logic store under storageDir.
|
// NewStore opens (or initialises) the control-logic store under storageDir.
|
||||||
func NewStore(storageDir string) (*Store, error) {
|
func NewStore(storageDir string) (*Store, error) {
|
||||||
s := &Store{
|
s := &Store{
|
||||||
path: filepath.Join(storageDir, definitionsFile),
|
path: filepath.Join(storageDir, definitionsFile),
|
||||||
items: map[string]Graph{},
|
trashDir: filepath.Join(storageDir, "trash", "controllogic"),
|
||||||
|
items: map[string]Graph{},
|
||||||
}
|
}
|
||||||
if err := s.load(); err != nil {
|
if err := s.load(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -99,13 +102,32 @@ func (s *Store) Save(g Graph) error {
|
|||||||
return s.saveLocked()
|
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 {
|
func (s *Store) Delete(id string) error {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
if _, ok := s.items[id]; !ok {
|
g, ok := s.items[id]
|
||||||
|
if !ok {
|
||||||
return ErrNotFound
|
return ErrNotFound
|
||||||
}
|
}
|
||||||
|
if err := s.trash(g); err != nil {
|
||||||
|
return fmt.Errorf("move control logic to trash: %w", err)
|
||||||
|
}
|
||||||
delete(s.items, id)
|
delete(s.items, id)
|
||||||
return s.saveLocked()
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
|
|
||||||
"github.com/uopi/uopi/internal/access"
|
"github.com/uopi/uopi/internal/access"
|
||||||
"github.com/uopi/uopi/internal/api"
|
"github.com/uopi/uopi/internal/api"
|
||||||
|
"github.com/uopi/uopi/internal/audit"
|
||||||
"github.com/uopi/uopi/internal/broker"
|
"github.com/uopi/uopi/internal/broker"
|
||||||
"github.com/uopi/uopi/internal/controllogic"
|
"github.com/uopi/uopi/internal/controllogic"
|
||||||
"github.com/uopi/uopi/internal/datasource/synthetic"
|
"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.
|
// 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.
|
// 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, rec audit.Recorder, channelFinderURL, archiverURL, trustedUserHeader string, log *slog.Logger) *Server {
|
||||||
|
if rec == nil {
|
||||||
|
rec = audit.Nop()
|
||||||
|
}
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
// Health check
|
// Health check
|
||||||
@@ -37,7 +41,7 @@ func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Syntheti
|
|||||||
})
|
})
|
||||||
|
|
||||||
// WebSocket endpoint
|
// 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})
|
||||||
|
|
||||||
// Prometheus-format metrics
|
// Prometheus-format metrics
|
||||||
mux.HandleFunc("/metrics", metrics.Handler(brk.ActiveSubscriptions))
|
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
|
// REST API — registered on a dedicated mux so it can be wrapped with the
|
||||||
// access-control middleware (identity resolution + global level enforcement).
|
// access-control middleware (identity resolution + global level enforcement).
|
||||||
apiMux := http.NewServeMux()
|
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))
|
mux.Handle(apiPrefix+"/", accessMiddleware(policy, trustedUserHeader, apiMux))
|
||||||
|
|
||||||
// Embedded frontend — must be last (catch-all)
|
// Embedded frontend — must be last (catch-all)
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -13,6 +15,7 @@ import (
|
|||||||
"github.com/coder/websocket"
|
"github.com/coder/websocket"
|
||||||
|
|
||||||
"github.com/uopi/uopi/internal/access"
|
"github.com/uopi/uopi/internal/access"
|
||||||
|
"github.com/uopi/uopi/internal/audit"
|
||||||
"github.com/uopi/uopi/internal/broker"
|
"github.com/uopi/uopi/internal/broker"
|
||||||
"github.com/uopi/uopi/internal/datasource"
|
"github.com/uopi/uopi/internal/datasource"
|
||||||
"github.com/uopi/uopi/internal/metrics"
|
"github.com/uopi/uopi/internal/metrics"
|
||||||
@@ -96,6 +99,8 @@ type wsHandler struct {
|
|||||||
userHeader string
|
userHeader string
|
||||||
// policy enforces the global access level (blacklist) on signal writes.
|
// policy enforces the global access level (blacklist) on signal writes.
|
||||||
policy *access.Policy
|
policy *access.Policy
|
||||||
|
// audit records signal writes (never nil; audit.Nop when disabled).
|
||||||
|
audit audit.Recorder
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *wsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
func (h *wsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -105,6 +110,12 @@ func (h *wsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
if h.userHeader != "" {
|
if h.userHeader != "" {
|
||||||
user = strings.TrimSpace(r.Header.Get(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{
|
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
|
||||||
// Permissive origin check for LAN / SSH-tunnel use; tighten when auth lands.
|
// Permissive origin check for LAN / SSH-tunnel use; tighten when auth lands.
|
||||||
@@ -120,11 +131,18 @@ func (h *wsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
ctx, cancel := context.WithCancel(r.Context())
|
ctx, cancel := context.WithCancel(r.Context())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
rec := h.audit
|
||||||
|
if rec == nil {
|
||||||
|
rec = audit.Nop()
|
||||||
|
}
|
||||||
|
|
||||||
c := &wsClient{
|
c := &wsClient{
|
||||||
conn: conn,
|
conn: conn,
|
||||||
broker: h.broker,
|
broker: h.broker,
|
||||||
user: user,
|
user: user,
|
||||||
|
ip: clientIP,
|
||||||
policy: h.policy,
|
policy: h.policy,
|
||||||
|
audit: rec,
|
||||||
outCh: make(chan []byte, 512),
|
outCh: make(chan []byte, 512),
|
||||||
updateCh: make(chan broker.Update, 1024),
|
updateCh: make(chan broker.Update, 1024),
|
||||||
subs: make(map[broker.SignalRef]func()),
|
subs: make(map[broker.SignalRef]func()),
|
||||||
@@ -155,7 +173,9 @@ type wsClient struct {
|
|||||||
broker *broker.Broker
|
broker *broker.Broker
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
user string // end-user identity from the trusted proxy header ("" if none)
|
user string // end-user identity from the trusted proxy header ("" if none)
|
||||||
|
ip string // client address, for audit attribution
|
||||||
policy *access.Policy // global access-level enforcement
|
policy *access.Policy // global access-level enforcement
|
||||||
|
audit audit.Recorder // signal-write audit recorder (never nil)
|
||||||
|
|
||||||
outCh chan []byte // serialised outgoing messages
|
outCh chan []byte // serialised outgoing messages
|
||||||
updateCh chan broker.Update // raw updates from the broker
|
updateCh chan broker.Update // raw updates from the broker
|
||||||
@@ -307,6 +327,14 @@ func (c *wsClient) handleWrite(ctx context.Context, msg inMsg) {
|
|||||||
return
|
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)
|
ds, ok := c.broker.Source(msg.DS)
|
||||||
if !ok {
|
if !ok {
|
||||||
c.log.Warn("write: unknown data source", "ds", msg.DS, "name", msg.Name)
|
c.log.Warn("write: unknown data source", "ds", msg.DS, "name", msg.Name)
|
||||||
@@ -340,10 +368,25 @@ func (c *wsClient) handleWrite(ctx context.Context, msg inMsg) {
|
|||||||
// Attribute the write to the connecting end-user so data sources (EPICS) can
|
// Attribute the write to the connecting end-user so data sources (EPICS) can
|
||||||
// act under that identity rather than the server's.
|
// act under that identity rather than the server's.
|
||||||
ctx = datasource.WithUser(ctx, c.user)
|
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 {
|
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)
|
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())
|
c.sendError(ctx, "WRITE_ERROR", err.Error())
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
c.audit.Record(ev)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *wsClient) handleHistory(ctx context.Context, msg inMsg) {
|
func (c *wsClient) handleHistory(ctx context.Context, msg inMsg) {
|
||||||
@@ -420,6 +463,44 @@ func (c *wsClient) sendError(ctx context.Context, code, message string) {
|
|||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
// ── 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 {
|
func metadataToPayload(m datasource.Metadata) *metaPayload {
|
||||||
return &metaPayload{
|
return &metaPayload{
|
||||||
Type: dataTypeName(m.Type),
|
Type: dataTypeName(m.Type),
|
||||||
|
|||||||
@@ -474,16 +474,43 @@ func setXMLAttribute(data []byte, attr, value string) ([]byte, error) {
|
|||||||
return []byte(s[:valStart] + value + s[valStart+valEnd:]), nil
|
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 {
|
func (s *Store) Delete(id string) error {
|
||||||
if err := validateID(id); err != nil {
|
if err := validateID(id); err != nil {
|
||||||
return ErrNotFound
|
return ErrNotFound
|
||||||
}
|
}
|
||||||
err := os.Remove(s.filePath(id))
|
if _, err := os.Stat(s.filePath(id)); err != nil {
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
return ErrNotFound
|
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.
|
// Clone duplicates an interface, assigning a new unique ID.
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -139,6 +139,16 @@ export default function ControlLogicEditor({ onClose }: Props) {
|
|||||||
const [dirty, setDirty] = useState(false);
|
const [dirty, setDirty] = useState(false);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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() {
|
async function reload() {
|
||||||
try {
|
try {
|
||||||
@@ -180,8 +190,8 @@ export default function ControlLogicEditor({ onClose }: Props) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveGraph() {
|
async function saveGraph(): Promise<boolean> {
|
||||||
if (!graph) return;
|
if (!graph) return false;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
@@ -191,8 +201,10 @@ export default function ControlLogicEditor({ onClose }: Props) {
|
|||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
setDirty(false);
|
setDirty(false);
|
||||||
await reload();
|
await reload();
|
||||||
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(`Save failed: ${err instanceof Error ? err.message : err}`);
|
setError(`Save failed: ${err instanceof Error ? err.message : err}`);
|
||||||
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -240,7 +252,7 @@ export default function ControlLogicEditor({ onClose }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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">
|
<div class="cl-modal">
|
||||||
<header class="cl-header">
|
<header class="cl-header">
|
||||||
<span class="cl-title">Control logic</span>
|
<span class="cl-title">Control logic</span>
|
||||||
@@ -248,7 +260,7 @@ export default function ControlLogicEditor({ onClose }: Props) {
|
|||||||
<div class="cl-header-actions">
|
<div class="cl-header-actions">
|
||||||
{graph && dirty && <span class="cl-dirty">unsaved</span>}
|
{graph && dirty && <span class="cl-dirty">unsaved</span>}
|
||||||
{graph && <button class="panel-btn" disabled={busy || !dirty} onClick={saveGraph}>Save</button>}
|
{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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -299,6 +311,21 @@ export default function ControlLogicEditor({ onClose }: Props) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -341,12 +368,15 @@ function FlowEditor({ graph, onChange }: {
|
|||||||
} catch {}
|
} 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(
|
const localNames = Array.from(new Set(
|
||||||
nodes.filter(n => n.kind === 'action.write')
|
nodes.filter(n => n.kind === 'action.write')
|
||||||
.map(n => n.params.target ?? '')
|
.map(n => n.params.target ?? '')
|
||||||
|
.map(t => t.startsWith('local:') ? t.slice('local:'.length) : t)
|
||||||
.filter(t => t && !t.includes(':'))
|
.filter(t => t && !t.includes(':'))
|
||||||
));
|
));
|
||||||
|
// 'srv' is a registered data source, so it already appears in dataSources.
|
||||||
const dsOptions = ['local', 'sys', ...dataSources];
|
const dsOptions = ['local', 'sys', ...dataSources];
|
||||||
function signalOptions(ds: string): string[] {
|
function signalOptions(ds: string): string[] {
|
||||||
if (ds === 'local') return localNames;
|
if (ds === 'local') return localNames;
|
||||||
@@ -702,16 +732,30 @@ function FlowEditor({ graph, onChange }: {
|
|||||||
onSelect={(newDs) => { loadSignals(newDs); patchParams(selected.id, { target: `${newDs}:` }); }} />
|
onSelect={(newDs) => { loadSignals(newDs); patchParams(selected.id, { target: `${newDs}:` }); }} />
|
||||||
</div>
|
</div>
|
||||||
<div class="wizard-field">
|
<div class="wizard-field">
|
||||||
<label>Target signal</label>
|
<label>Target {ds === 'local' ? 'variable' : ds === 'srv' ? 'server variable' : 'signal'}</label>
|
||||||
<SearchableSelect value={name} options={signalOptions(ds)}
|
{(ds === 'local' || ds === 'srv') ? (
|
||||||
onSelect={(sig) => patchParams(selected.id, { target: `${ds}:${sig}` })}
|
<Fragment>
|
||||||
placeholder={ds ? 'Search signals…' : 'Select data source first'} />
|
<input class="prop-input" list={`clvars-${selected.id}`} value={name}
|
||||||
|
placeholder={ds === 'srv' ? 'server variable name (read by panels)' : 'local variable name'}
|
||||||
|
onInput={(e) => patchParams(selected.id, { target: `${ds}:${(e.target as HTMLInputElement).value}` })} />
|
||||||
|
<datalist id={`clvars-${selected.id}`}>
|
||||||
|
{signalOptions(ds).map(o => <option key={o} value={o} />)}
|
||||||
|
</datalist>
|
||||||
|
<p class="hint">{ds === 'srv'
|
||||||
|
? 'Server variables are visible to interface panels (read-only unless the user has control-logic access).'
|
||||||
|
: 'Local variables live only inside this graph and reset on reload.'}</p>
|
||||||
|
</Fragment>
|
||||||
|
) : (
|
||||||
|
<SearchableSelect value={name} options={signalOptions(ds)}
|
||||||
|
onSelect={(sig) => patchParams(selected.id, { target: `${ds}:${sig}` })}
|
||||||
|
placeholder={ds ? 'Search signals…' : 'Select data source first'} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<ExprField label="Value (expression)" value={selected.params.expr ?? ''}
|
<ExprField label="Value (expression)" value={selected.params.expr ?? ''}
|
||||||
onChange={(v) => patchParams(selected.id, { expr: v })}
|
onChange={(v) => patchParams(selected.id, { expr: v })}
|
||||||
onInsert={(ds2, sig) => insertRef(selected.id, 'expr', ds2, sig)}
|
onInsert={(ds2, sig) => insertRef(selected.id, 'expr', ds2, sig)}
|
||||||
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals}
|
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}." />
|
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>
|
</Fragment>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import EditCanvas, { genWidgetId, DEFAULT_SIZES } from './EditCanvas';
|
|||||||
import PlotPanelCanvas from './PlotPanelCanvas';
|
import PlotPanelCanvas from './PlotPanelCanvas';
|
||||||
import LogicEditor from './LogicEditor';
|
import LogicEditor from './LogicEditor';
|
||||||
import PropertiesPane from './PropertiesPane';
|
import PropertiesPane from './PropertiesPane';
|
||||||
import ContextualHelp from './ContextualHelp';
|
|
||||||
import HelpModal from './HelpModal';
|
import HelpModal from './HelpModal';
|
||||||
import ZoomControl from './ZoomControl';
|
import ZoomControl from './ZoomControl';
|
||||||
import { useAuth, canWrite } from './lib/auth';
|
import { useAuth, canWrite } from './lib/auth';
|
||||||
@@ -626,7 +625,6 @@ export default function EditMode({ initial, onDone }: Props) {
|
|||||||
|
|
||||||
<div class="toolbar-right">
|
<div class="toolbar-right">
|
||||||
<ZoomControl />
|
<ZoomControl />
|
||||||
<ContextualHelp mode="edit" onOpenManual={openHelp} />
|
|
||||||
<button class="icon-btn help-manual-btn" onClick={() => openHelp('edit')} title="Open user manual">📖</button>
|
<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={handleImport}>Import</button>
|
||||||
<button class="toolbar-btn" onClick={handleExport}>Export</button>
|
<button class="toolbar-btn" onClick={handleExport}>Export</button>
|
||||||
|
|||||||
+107
-5
@@ -403,6 +403,7 @@ function DiagramSignalTree() {
|
|||||||
|
|
||||||
const SECTIONS = [
|
const SECTIONS = [
|
||||||
{ id: 'start', label: 'Getting Started' },
|
{ id: 'start', label: 'Getting Started' },
|
||||||
|
{ id: 'tips', label: 'Quick Tips' },
|
||||||
{ id: 'view', label: 'View Mode' },
|
{ id: 'view', label: 'View Mode' },
|
||||||
{ id: 'edit', label: 'Edit Mode' },
|
{ id: 'edit', label: 'Edit Mode' },
|
||||||
{ id: 'widgets', label: 'Widgets' },
|
{ id: 'widgets', label: 'Widgets' },
|
||||||
@@ -411,10 +412,68 @@ const SECTIONS = [
|
|||||||
{ id: 'logic', label: 'Panel Logic' },
|
{ id: 'logic', label: 'Panel Logic' },
|
||||||
{ id: 'control', label: 'Control Logic' },
|
{ id: 'control', label: 'Control Logic' },
|
||||||
{ id: 'sharing', label: 'Sharing & Access' },
|
{ id: 'sharing', label: 'Sharing & Access' },
|
||||||
|
{ id: 'audit', label: 'Audit Log' },
|
||||||
{ id: 'history', label: 'Historical Data' },
|
{ id: 'history', label: 'Historical Data' },
|
||||||
{ id: 'shortcuts', label: 'Keyboard Shortcuts' },
|
{ 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() {
|
function SectionStart() {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -481,8 +540,10 @@ function SectionView() {
|
|||||||
|
|
||||||
<h3 class="help-h3">Connection status</h3>
|
<h3 class="help-h3">Connection status</h3>
|
||||||
<p>
|
<p>
|
||||||
The coloured chip in the top-right shows the WebSocket connection state.
|
The coloured chip in the top-right shows the WebSocket connection state. When connected it
|
||||||
A red <em>"disconnected"</em> banner appears at the top while reconnecting — live values will resume automatically.
|
reads <em>"connected as <your username>"</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>
|
</p>
|
||||||
</div>
|
</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() {
|
function SectionWidgets() {
|
||||||
return (
|
return (
|
||||||
<div>
|
<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,
|
start: SectionStart,
|
||||||
|
tips: SectionTips,
|
||||||
view: SectionView,
|
view: SectionView,
|
||||||
edit: SectionEdit,
|
edit: SectionEdit,
|
||||||
widgets: SectionWidgets,
|
widgets: SectionWidgets,
|
||||||
@@ -868,6 +968,7 @@ const SECTION_COMPONENTS: Record<string, () => JSX.Element> = {
|
|||||||
logic: SectionLogic,
|
logic: SectionLogic,
|
||||||
control: SectionControl,
|
control: SectionControl,
|
||||||
sharing: SectionSharing,
|
sharing: SectionSharing,
|
||||||
|
audit: SectionAudit,
|
||||||
history: SectionHistory,
|
history: SectionHistory,
|
||||||
shortcuts: SectionShortcuts,
|
shortcuts: SectionShortcuts,
|
||||||
};
|
};
|
||||||
@@ -877,7 +978,8 @@ const SECTION_COMPONENTS: Record<string, () => JSX.Element> = {
|
|||||||
export default function HelpModal({ initialSection = 'start', onClose }: Props) {
|
export default function HelpModal({ initialSection = 'start', onClose }: Props) {
|
||||||
const [active, setActive] = useState(initialSection);
|
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
|
// Close on backdrop click
|
||||||
function handleBackdrop(e: MouseEvent) {
|
function handleBackdrop(e: MouseEvent) {
|
||||||
@@ -915,7 +1017,7 @@ export default function HelpModal({ initialSection = 'start', onClose }: Props)
|
|||||||
{/* Content area */}
|
{/* Content area */}
|
||||||
<div class="help-content">
|
<div class="help-content">
|
||||||
<h2 class="help-h2">{SECTIONS.find(s => s.id === active)?.label}</h2>
|
<h2 class="help-h2">{SECTIONS.find(s => s.id === active)?.label}</h2>
|
||||||
<Content />
|
<Content onNavigate={setActive} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { h, Fragment } from 'preact';
|
import { h, Fragment } from 'preact';
|
||||||
import { useState, useEffect, useRef, useMemo } from 'preact/hooks';
|
import { useState, useEffect, useRef, useMemo } from 'preact/hooks';
|
||||||
import type { SignalRef, StateVar } from './lib/types';
|
import type { SignalRef, StateVar } from './lib/types';
|
||||||
import SyntheticWizard from './SyntheticWizard';
|
|
||||||
import SyntheticGraphEditor from './SyntheticGraphEditor';
|
import SyntheticGraphEditor from './SyntheticGraphEditor';
|
||||||
import GroupsTree from './GroupsTree';
|
import GroupsTree from './GroupsTree';
|
||||||
import ChannelFinderModal from './ChannelFinderModal';
|
import ChannelFinderModal from './ChannelFinderModal';
|
||||||
@@ -491,10 +490,11 @@ export default function SignalTree({ onDragStart, width, panelId, statevars, onS
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{showWizard && (
|
{showWizard && (
|
||||||
<SyntheticWizard
|
<SyntheticGraphEditor
|
||||||
currentIfaceId={panelId}
|
create
|
||||||
|
panelId={panelId}
|
||||||
onClose={() => setShowWizard(false)}
|
onClose={() => setShowWizard(false)}
|
||||||
onCreated={loadAllSignals}
|
onSaved={loadAllSignals}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,12 @@ interface DataSource { name: string; }
|
|||||||
interface SignalInfo { name: string; }
|
interface SignalInfo { name: string; }
|
||||||
|
|
||||||
interface Props {
|
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;
|
onClose: () => void;
|
||||||
onSaved: () => void;
|
onSaved: () => void;
|
||||||
}
|
}
|
||||||
@@ -170,7 +175,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 [def, setDef] = useState<SignalDef | null>(null);
|
||||||
const [graph, setGraph] = useState<Graph>({ nodes: [], wires: [] });
|
const [graph, setGraph] = useState<Graph>({ nodes: [], wires: [] });
|
||||||
const [selected, setSelected] = useState<string | null>(null);
|
const [selected, setSelected] = useState<string | null>(null);
|
||||||
@@ -179,6 +184,15 @@ export default function SyntheticGraphEditor({ name, onClose, onSaved }: Props)
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState('');
|
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 [dataSources, setDataSources] = useState<string[]>([]);
|
||||||
const [dsSignals, setDsSignals] = useState<Record<string, string[]>>({});
|
const [dsSignals, setDsSignals] = useState<Record<string, string[]>>({});
|
||||||
|
|
||||||
@@ -215,17 +229,28 @@ export default function SyntheticGraphEditor({ name, onClose, onSaved }: Props)
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
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(r => r.ok ? r.json() : Promise.reject(r.statusText))
|
||||||
.then((d: SignalDef) => {
|
.then((d: SignalDef) => {
|
||||||
setDef(d);
|
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);
|
const g = buildInitial(d);
|
||||||
setGraph(g);
|
setGraph(g);
|
||||||
g.nodes.forEach(n => { if (n.kind === 'source' && n.ds) loadSignals(n.ds); });
|
g.nodes.forEach(n => { if (n.kind === 'source' && n.ds) loadSignals(n.ds); });
|
||||||
})
|
})
|
||||||
.catch(e => setError(String(e)))
|
.catch(e => setError(String(e)))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [name]);
|
}, [name, create]);
|
||||||
|
|
||||||
// ── History ────────────────────────────────────────────────────────────────
|
// ── History ────────────────────────────────────────────────────────────────
|
||||||
function pushUndo() {
|
function pushUndo() {
|
||||||
@@ -282,7 +307,8 @@ export default function SyntheticGraphEditor({ name, onClose, onSaved }: Props)
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
function moveNode(id: string, x: number, y: number, record: boolean) {
|
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) {
|
function deleteNode(id: string) {
|
||||||
const n = graph.nodes.find(x => x.id === id);
|
const n = graph.nodes.find(x => x.id === id);
|
||||||
@@ -314,48 +340,52 @@ export default function SyntheticGraphEditor({ name, onClose, onSaved }: Props)
|
|||||||
const p = toCanvas(e);
|
const p = toCanvas(e);
|
||||||
dragNode.current = { id: node.id, dx: p.x - node.x, dy: p.y - node.y, pushed: false };
|
dragNode.current = { id: node.id, dx: p.x - node.x, dy: p.y - node.y, pushed: false };
|
||||||
setSelected(node.id); setSelectedWire(null);
|
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) {
|
function startWire(e: MouseEvent, node: GNode) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const p = toCanvas(e);
|
const p = toCanvas(e);
|
||||||
setPendingWire({ from: node.id, x: p.x, y: p.y });
|
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() {
|
function endWire() {
|
||||||
pendingRef.current = null;
|
pendingRef.current = null;
|
||||||
window.removeEventListener('mousemove', onWireMove);
|
|
||||||
window.removeEventListener('mouseup', onWireUp);
|
|
||||||
setPendingWire(null);
|
setPendingWire(null);
|
||||||
}
|
}
|
||||||
function onWireUp() { endWire(); }
|
|
||||||
function finishWire(target: GNode) {
|
function finishWire(target: GNode) {
|
||||||
const cur = pendingRef.current;
|
const cur = pendingRef.current;
|
||||||
if (cur && hasInput(target.kind)) addWire(cur.from, target.id);
|
if (cur && hasInput(target.kind)) addWire(cur.from, target.id);
|
||||||
endWire();
|
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 ──────────────────────────────────────────────────
|
// ── Palette drag-and-drop ──────────────────────────────────────────────────
|
||||||
const DRAG_MIME = 'application/x-uopi-synth-op';
|
const DRAG_MIME = 'application/x-uopi-synth-op';
|
||||||
function onCanvasDragOver(e: DragEvent) {
|
function onCanvasDragOver(e: DragEvent) {
|
||||||
@@ -396,16 +426,39 @@ export default function SyntheticGraphEditor({ name, onClose, onSaved }: Props)
|
|||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
if (!def) return;
|
if (!def) return;
|
||||||
|
if (create && !sigName) { setError('Enter a name for the new signal.'); return; }
|
||||||
if (compiled.error) { setError(compiled.error); return; }
|
if (compiled.error) { setError(compiled.error); return; }
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
const body = { ...def, inputs: compiled.inputs, pipeline: compiled.pipeline, ds: undefined, signal: undefined };
|
const meta = {
|
||||||
const res = await fetch(`/api/v1/synthetic/${encodeURIComponent(name)}`, {
|
...def.meta,
|
||||||
method: 'PUT',
|
unit: unit || undefined,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
description: desc || undefined,
|
||||||
body: JSON.stringify(body),
|
displayLow: parseFloat(dispLow),
|
||||||
});
|
displayHigh: parseFloat(dispHigh),
|
||||||
|
};
|
||||||
|
const body: any = {
|
||||||
|
...def,
|
||||||
|
name: sigName,
|
||||||
|
inputs: compiled.inputs,
|
||||||
|
pipeline: compiled.pipeline,
|
||||||
|
meta,
|
||||||
|
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) {
|
if (!res.ok) {
|
||||||
const j = await res.json().catch(() => ({ error: res.statusText }));
|
const j = await res.json().catch(() => ({ error: res.statusText }));
|
||||||
throw new Error(j.error ?? res.statusText);
|
throw new Error(j.error ?? res.statusText);
|
||||||
@@ -428,7 +481,7 @@ export default function SyntheticGraphEditor({ name, onClose, onSaved }: Props)
|
|||||||
<div class="wizard-backdrop" onClick={onClose}>
|
<div class="wizard-backdrop" onClick={onClose}>
|
||||||
<div class="synth-graph" onClick={(e) => e.stopPropagation()}>
|
<div class="synth-graph" onClick={(e) => e.stopPropagation()}>
|
||||||
<div class="wizard-header">
|
<div class="wizard-header">
|
||||||
<span>Synthetic Signal — {name}</span>
|
<span>Synthetic Signal{create ? ' — new' : ` — ${name}`}</span>
|
||||||
<button class="icon-btn" onClick={onClose}>✕</button>
|
<button class="icon-btn" onClick={onClose}>✕</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -515,7 +568,53 @@ export default function SyntheticGraphEditor({ name, onClose, onSaved }: Props)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flow-inspector">
|
<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' && (
|
{sel?.kind === 'source' && (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
|
|||||||
@@ -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 (1–8)', 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
@@ -7,10 +7,10 @@ import { parseInterface } from './lib/xml';
|
|||||||
import { newPlotPanel } from './lib/templates';
|
import { newPlotPanel } from './lib/templates';
|
||||||
import { useAuth, canWrite } from './lib/auth';
|
import { useAuth, canWrite } from './lib/auth';
|
||||||
import type { Interface } from './lib/types';
|
import type { Interface } from './lib/types';
|
||||||
import ContextualHelp from './ContextualHelp';
|
|
||||||
import HelpModal from './HelpModal';
|
import HelpModal from './HelpModal';
|
||||||
import ZoomControl from './ZoomControl';
|
import ZoomControl from './ZoomControl';
|
||||||
import ControlLogicEditor from './ControlLogicEditor';
|
import ControlLogicEditor from './ControlLogicEditor';
|
||||||
|
import AuditViewer from './AuditViewer';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onEdit?: (iface?: Interface) => void;
|
onEdit?: (iface?: Interface) => void;
|
||||||
@@ -33,6 +33,7 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) {
|
|||||||
const [helpSection, setHelpSection] = useState('start');
|
const [helpSection, setHelpSection] = useState('start');
|
||||||
const [showTimeNav, setShowTimeNav] = useState(false);
|
const [showTimeNav, setShowTimeNav] = useState(false);
|
||||||
const [showControlLogic, setShowControlLogic] = useState(false);
|
const [showControlLogic, setShowControlLogic] = useState(false);
|
||||||
|
const [showAudit, setShowAudit] = useState(false);
|
||||||
const [leftW, setLeftW] = useState(220);
|
const [leftW, setLeftW] = useState(220);
|
||||||
const [listCollapsed, setListCollapsed] = useState(false);
|
const [listCollapsed, setListCollapsed] = useState(false);
|
||||||
const me = useAuth();
|
const me = useAuth();
|
||||||
@@ -137,9 +138,13 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div class="toolbar-right">
|
<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>
|
<span class="status-dot"></span>
|
||||||
{wsStatus === 'connected' && me.user ? `connected as ${me.user}` : wsStatus}
|
{wsStatus === 'connected' && me.user ? `connected as ${me.user}` : wsStatus}
|
||||||
|
{me.user && !writable && ' (read-only)'}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
class={`toolbar-btn${showTimeNav ? ' toolbar-btn-active' : ''}`}
|
class={`toolbar-btn${showTimeNav ? ' toolbar-btn-active' : ''}`}
|
||||||
@@ -148,7 +153,6 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) {
|
|||||||
>
|
>
|
||||||
⏱ History
|
⏱ History
|
||||||
</button>
|
</button>
|
||||||
<ContextualHelp mode="view" onOpenManual={openHelp} />
|
|
||||||
<button
|
<button
|
||||||
class="icon-btn help-manual-btn"
|
class="icon-btn help-manual-btn"
|
||||||
onClick={() => openHelp('start')}
|
onClick={() => openHelp('start')}
|
||||||
@@ -166,6 +170,15 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) {
|
|||||||
⚙ Control logic
|
⚙ Control logic
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{me.canViewAudit && (
|
||||||
|
<button
|
||||||
|
class="toolbar-btn"
|
||||||
|
onClick={() => setShowAudit(true)}
|
||||||
|
title="View the audit log"
|
||||||
|
>
|
||||||
|
🛡 Audit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{writable && (
|
{writable && (
|
||||||
<button
|
<button
|
||||||
class="btn-edit"
|
class="btn-edit"
|
||||||
@@ -175,11 +188,6 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) {
|
|||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{me.user && (
|
|
||||||
<span class="user-chip" title={`Signed in as ${me.user}${writable ? '' : ' (read-only)'}`}>
|
|
||||||
{me.user}{!writable && ' (read-only)'}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -257,6 +265,10 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) {
|
|||||||
{showControlLogic && (
|
{showControlLogic && (
|
||||||
<ControlLogicEditor onClose={() => setShowControlLogic(false)} />
|
<ControlLogicEditor onClose={() => setShowControlLogic(false)} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showAudit && (
|
||||||
|
<AuditViewer onClose={() => setShowAudit(false)} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -4,7 +4,7 @@ import type { Me, AccessLevel } from './types';
|
|||||||
|
|
||||||
// Default identity used before /api/v1/me resolves (or if it fails): assume a
|
// Default identity used before /api/v1/me resolves (or if it fails): assume a
|
||||||
// trusted LAN user with full access, matching the backend default.
|
// 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
|
// AuthContext carries the resolved identity + global access level for the
|
||||||
// current user throughout the app.
|
// current user throughout the app.
|
||||||
@@ -37,6 +37,7 @@ export async function fetchMe(): Promise<Me> {
|
|||||||
level: (data.level as AccessLevel) ?? 'write',
|
level: (data.level as AccessLevel) ?? 'write',
|
||||||
groups: Array.isArray(data.groups) ? data.groups : [],
|
groups: Array.isArray(data.groups) ? data.groups : [],
|
||||||
canEditLogic: data.canEditLogic !== false,
|
canEditLogic: data.canEditLogic !== false,
|
||||||
|
canViewAudit: data.canViewAudit !== false,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return DEFAULT_ME;
|
return DEFAULT_ME;
|
||||||
|
|||||||
@@ -212,6 +212,9 @@ export interface Me {
|
|||||||
// Whether the user may add/edit panel logic and server-side control logic.
|
// 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.
|
// False only when a logic-editors allowlist is configured and excludes them.
|
||||||
canEditLogic: boolean;
|
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.
|
// Effective permission a user holds on a single panel or folder.
|
||||||
|
|||||||
+183
-81
@@ -123,6 +123,8 @@ body {
|
|||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
border: 1px solid #334155;
|
border: 1px solid #334155;
|
||||||
text-transform: capitalize;
|
text-transform: capitalize;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-chip.connected {
|
.status-chip.connected {
|
||||||
@@ -165,18 +167,6 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── User identity chip ──────────────────────────────────────────────────── */
|
/* ── 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 screen ────────────────────────────────────────────────── */
|
||||||
.access-denied {
|
.access-denied {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -2746,90 +2736,50 @@ kbd {
|
|||||||
color: #e2e8f0;
|
color: #e2e8f0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Contextual Help ─────────────────────────────────────────────────────── */
|
/* ── Manual: Quick Tips list ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
.ctx-help {
|
.help-tips-list {
|
||||||
position: relative;
|
list-style: none;
|
||||||
display: flex;
|
margin: 0 0 1rem;
|
||||||
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;
|
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
}
|
}
|
||||||
.ctx-help-btn:hover { border-color: #4a9eff; color: #4a9eff; }
|
|
||||||
|
|
||||||
.ctx-help-popover {
|
.help-tip-item {
|
||||||
position: absolute;
|
display: flex;
|
||||||
top: calc(100% + 8px);
|
align-items: center;
|
||||||
right: 0;
|
gap: 0.6rem;
|
||||||
z-index: 400;
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
background: #1a1f2e;
|
background: #1a1f2e;
|
||||||
border: 1px solid #2d3748;
|
border: 1px solid #2d3748;
|
||||||
border-radius: 8px;
|
border-radius: 6px;
|
||||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
|
padding: 0.55rem 0.7rem;
|
||||||
width: 280px;
|
|
||||||
padding: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ctx-help-tip {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
font-size: 0.82rem;
|
|
||||||
color: #cbd5e1;
|
color: #cbd5e1;
|
||||||
line-height: 1.5;
|
font-size: 0.85rem;
|
||||||
margin-bottom: 0.6rem;
|
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 {
|
.help-tip-icon {
|
||||||
font-size: 1rem;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
margin-top: 0.05rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ctx-help-footer {
|
.help-tip-text { flex: 1; }
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
border-top: 1px solid #2d3748;
|
|
||||||
padding-top: 0.5rem;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ctx-help-nav {
|
.help-tip-go {
|
||||||
font-size: 0.75rem;
|
flex-shrink: 0;
|
||||||
color: #64748b;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
.ctx-help-nav:hover { color: #94a3b8; }
|
|
||||||
|
|
||||||
.ctx-help-link {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: #4a9eff;
|
color: #4a9eff;
|
||||||
background: none;
|
font-weight: 700;
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
}
|
}
|
||||||
.ctx-help-link:hover { text-decoration: underline; }
|
|
||||||
|
|
||||||
|
|
||||||
/* ── Historical time-nav bar (below main toolbar, hidden by default) ──────── */
|
/* ── Historical time-nav bar (below main toolbar, hidden by default) ──────── */
|
||||||
@@ -3446,6 +3396,37 @@ kbd {
|
|||||||
overflow: hidden;
|
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 {
|
.cl-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -3455,6 +3436,127 @@ kbd {
|
|||||||
flex-shrink: 0;
|
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 {
|
.cl-title {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,9 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "cl-0f99598ca4d0ea15",
|
||||||
|
"name": "New control logic",
|
||||||
|
"enabled": false,
|
||||||
|
"nodes": [],
|
||||||
|
"wires": []
|
||||||
|
}
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user