diff --git a/cmd/uopi/main.go b/cmd/uopi/main.go index 6968dda..c802da0 100644 --- a/cmd/uopi/main.go +++ b/cmd/uopi/main.go @@ -9,14 +9,18 @@ import ( "log/slog" "os" "os/signal" + "os/user" + "path/filepath" "syscall" "github.com/uopi/uopi/internal/access" + "github.com/uopi/uopi/internal/audit" "github.com/uopi/uopi/internal/broker" "github.com/uopi/uopi/internal/config" "github.com/uopi/uopi/internal/controllogic" "github.com/uopi/uopi/internal/datasource/epics" "github.com/uopi/uopi/internal/datasource/pva" + "github.com/uopi/uopi/internal/datasource/servervar" "github.com/uopi/uopi/internal/datasource/stub" "github.com/uopi/uopi/internal/datasource/synthetic" "github.com/uopi/uopi/internal/panelacl" @@ -41,6 +45,16 @@ func main() { os.Exit(1) } + // In an unproxied/local deployment (no trusted_user_header) without an explicit + // default_user, attribute actions to the OS user running the process so the UI + // and audit log show a real identity instead of anonymous. + if cfg.Server.TrustedUserHeader == "" && cfg.Server.DefaultUser == "" { + if u, err := user.Current(); err == nil && u.Username != "" { + cfg.Server.DefaultUser = u.Username + log.Info("no default_user configured; defaulting to OS user", "user", u.Username) + } + } + if err := os.MkdirAll(cfg.Server.StorageDir, 0o755); err != nil { log.Error("failed to create storage dir", "dir", cfg.Server.StorageDir, "err", err) os.Exit(1) @@ -122,6 +136,15 @@ func main() { } } + // Server variables: a small persistent key/value source ("srv") that the + // control-logic engine writes (e.g. sequence state) and panels can read. + srvVars, err := servervar.New(cfg.Server.StorageDir) + if err != nil { + log.Error("server variables init", "err", err) + os.Exit(1) + } + brk.Register(srvVars) + // Build the global access policy from config: every user is trusted with // full write access unless downgraded by the blacklist; groups are named // sets of users referenced by per-panel sharing. @@ -133,7 +156,26 @@ func main() { for _, g := range cfg.Groups { groups[g.Name] = g.Members } - policy := access.New(cfg.Server.DefaultUser, blacklist, groups, cfg.Server.LogicEditors) + policy := access.New(cfg.Server.DefaultUser, blacklist, groups, cfg.Server.LogicEditors, cfg.Audit.Readers) + + // Audit log: when enabled, every system-affecting action (user/automated + // signal writes, interface and control-logic mutations) is recorded to SQLite. + // When disabled a no-op recorder is used so call sites need no guards. + recorder := audit.Nop() + if cfg.Audit.Enabled { + dbPath := cfg.Audit.DBPath + if dbPath == "" { + dbPath = filepath.Join(cfg.Server.StorageDir, "audit.db") + } + r, err := audit.NewSQLite(dbPath, log) + if err != nil { + log.Error("failed to open audit log", "path", dbPath, "err", err) + os.Exit(1) + } + defer r.Close() + recorder = r + log.Info("audit log enabled", "path", dbPath) + } // Server-side control logic: flow graphs that run continuously under the root // context, independent of any panel. The store is loaded from disk and the @@ -143,10 +185,10 @@ func main() { log.Error("failed to open control logic store", "err", err) os.Exit(1) } - ctrlEngine := controllogic.NewEngine(ctx, brk, ctrlStore, log) + ctrlEngine := controllogic.NewEngine(ctx, brk, ctrlStore, recorder, log) 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 { fmt.Fprintf(os.Stderr, "server error: %v\n", err) diff --git a/go.mod b/go.mod index 3d74a13..189ed4a 100644 --- a/go.mod +++ b/go.mod @@ -9,4 +9,16 @@ require ( github.com/yuin/gopher-lua v1.1.2 ) -require golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect + golang.org/x/sys v0.36.0 // indirect + modernc.org/libc v1.66.10 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.42.2 // indirect +) diff --git a/go.sum b/go.sum index d154f28..60c19a5 100644 --- a/go.sum +++ b/go.sum @@ -2,9 +2,32 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/evanw/esbuild v0.28.0 h1:V96ghtc5p5JnNUQIUsc5H3kr+AcFcMqOJll2ZmJW6Lo= github.com/evanw/esbuild v0.28.0/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA= github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A= +modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.42.2 h1:7hkZUNJvJFN2PgfUdjni9Kbvd4ef4mNLOu0B9FGxM74= +modernc.org/sqlite v1.42.2/go.mod h1:+VkC6v3pLOAE0A0uVucQEcbVW0I5nHCeDaBf+DpsQT8= diff --git a/internal/access/access_test.go b/internal/access/access_test.go index 0b73283..48deb67 100644 --- a/internal/access/access_test.go +++ b/internal/access/access_test.go @@ -6,7 +6,7 @@ func TestCanEditLogic(t *testing.T) { groups := map[string][]string{"ops": {"carol"}} // No allowlist configured: everyone with write access may edit logic. - open := New("", nil, groups, nil) + open := New("", nil, groups, nil, nil) for _, u := range []string{"", "alice", "carol"} { if !open.CanEditLogic(u) { t.Errorf("unrestricted: CanEditLogic(%q) = false, want true", u) @@ -17,7 +17,7 @@ func TestCanEditLogic(t *testing.T) { } // Allowlist by username and by group name. - p := New("", nil, groups, []string{"alice", "ops"}) + p := New("", nil, groups, []string{"alice", "ops"}, nil) if !p.LogicRestricted() { t.Error("LogicRestricted() = false with allowlist set") } diff --git a/internal/api/api.go b/internal/api/api.go index e308b03..c0f1491 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -8,12 +8,15 @@ import ( "errors" "io" "log/slog" + "net" "net/http" "net/url" "strconv" "strings" + "time" "github.com/uopi/uopi/internal/access" + "github.com/uopi/uopi/internal/audit" "github.com/uopi/uopi/internal/broker" "github.com/uopi/uopi/internal/controllogic" "github.com/uopi/uopi/internal/datasource" @@ -31,13 +34,18 @@ type Handler struct { acl *panelacl.Store ctrlLogic *controllogic.Store ctrlEngine *controllogic.Engine - channelFinderURL string // empty if not configured - archiverURL string // empty if not configured + audit audit.Recorder // never nil; audit.Nop when disabled + channelFinderURL string // empty if not configured + archiverURL string // empty if not configured log *slog.Logger } // New creates an API Handler. synth may be nil if the synthetic DS is disabled. -func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, policy *access.Policy, acl *panelacl.Store, ctrlLogic *controllogic.Store, ctrlEngine *controllogic.Engine, channelFinderURL, archiverURL string, log *slog.Logger) *Handler { +// rec records system-affecting mutations; pass audit.Nop() to disable auditing. +func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, policy *access.Policy, acl *panelacl.Store, ctrlLogic *controllogic.Store, ctrlEngine *controllogic.Engine, rec audit.Recorder, channelFinderURL, archiverURL string, log *slog.Logger) *Handler { + if rec == nil { + rec = audit.Nop() + } return &Handler{ broker: b, synthetic: synth, @@ -46,6 +54,7 @@ func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, pol acl: acl, ctrlLogic: ctrlLogic, ctrlEngine: ctrlEngine, + audit: rec, channelFinderURL: channelFinderURL, archiverURL: archiverURL, log: log, @@ -56,6 +65,7 @@ func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, pol // Requires Go 1.22+ for method+path routing. func (h *Handler) Register(mux *http.ServeMux, prefix string) { mux.HandleFunc("GET "+prefix+"/me", h.getMe) + mux.HandleFunc("GET "+prefix+"/audit", h.getAudit) mux.HandleFunc("GET "+prefix+"/datasources", h.listDataSources) mux.HandleFunc("GET "+prefix+"/signals", h.listSignals) mux.HandleFunc("GET "+prefix+"/signals/search", h.searchSignals) @@ -118,15 +128,93 @@ func (h *Handler) getMe(w http.ResponseWriter, r *http.Request) { "level": h.policy.Level(user).String(), "groups": groups, "canEditLogic": h.policy.CanEditLogic(user), + "canViewAudit": h.policy.CanViewAudit(user), }) } +// ── /audit ──────────────────────────────────────────────────────────────────── + +// getAudit returns audit-log entries matching the query filters. Access is +// restricted to users permitted by CanViewAudit (the audit-readers allowlist). +// Filters: start, end (RFC3339), user, action, ds, signal, limit. +func (h *Handler) getAudit(w http.ResponseWriter, r *http.Request) { + if !h.policy.CanViewAudit(caller(r)) { + jsonError(w, http.StatusForbidden, "you are not permitted to view the audit log") + return + } + q := r.URL.Query() + f := audit.Filter{ + Actor: q.Get("user"), + Action: q.Get("action"), + DS: q.Get("ds"), + Signal: q.Get("signal"), + } + if v := q.Get("start"); v != "" { + t, err := time.Parse(time.RFC3339, v) + if err != nil { + jsonError(w, http.StatusBadRequest, "invalid start time (want RFC3339): "+v) + return + } + f.Start = t + } + if v := q.Get("end"); v != "" { + t, err := time.Parse(time.RFC3339, v) + if err != nil { + jsonError(w, http.StatusBadRequest, "invalid end time (want RFC3339): "+v) + return + } + f.End = t + } + if v := q.Get("limit"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + f.Limit = n + } + } + events, err := h.audit.Query(f) + if err != nil { + h.log.Error("audit query", "err", err) + jsonError(w, http.StatusInternalServerError, err.Error()) + return + } + jsonOK(w, events) +} + // ── access helpers ──────────────────────────────────────────────────────────── // caller returns the resolved end-user identity for the request (set by the // access middleware). func caller(r *http.Request) string { return access.UserFrom(r.Context()) } +// clientIP returns the best-effort client address for audit attribution, +// preferring the proxy-set X-Forwarded-For / X-Real-IP headers. +func clientIP(r *http.Request) string { + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + if i := strings.IndexByte(xff, ','); i >= 0 { + return strings.TrimSpace(xff[:i]) + } + return strings.TrimSpace(xff) + } + if xr := r.Header.Get("X-Real-IP"); xr != "" { + return strings.TrimSpace(xr) + } + if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { + return host + } + return r.RemoteAddr +} + +// recordMutation appends a successful configuration change to the audit log. +func (h *Handler) recordMutation(r *http.Request, action, detail string) { + h.audit.Record(audit.Event{ + Actor: caller(r), + ActorType: audit.ActorUser, + Action: action, + Detail: detail, + IP: clientIP(r), + Outcome: audit.OutcomeOK, + }) +} + // capByGlobal lowers a per-panel/folder permission to what the user's global // access level allows: read-only users can never exceed read, no-access users // get nothing. @@ -490,6 +578,7 @@ func (h *Handler) createInterface(w http.ResponseWriter, r *http.Request) { h.log.Error("record panel ownership", "id", id, "err", err) } } + h.recordMutation(r, "interface.create", id) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) _ = json.NewEncoder(w).Encode(map[string]string{"id": id}) @@ -580,6 +669,7 @@ func (h *Handler) updateInterface(w http.ResponseWriter, r *http.Request) { } return } + h.recordMutation(r, "interface.update", id) w.WriteHeader(http.StatusNoContent) } @@ -721,6 +811,7 @@ func (h *Handler) deleteInterface(w http.ResponseWriter, r *http.Request) { if err := h.acl.DeletePanel(id); err != nil { h.log.Error("delete panel ACL", "id", id, "err", err) } + h.recordMutation(r, "interface.delete", id) w.WriteHeader(http.StatusNoContent) } @@ -1123,6 +1214,7 @@ func (h *Handler) createControlLogic(w http.ResponseWriter, r *http.Request) { return } h.ctrlEngine.Reload() + h.recordMutation(r, "controllogic.create", g.ID+" "+g.Name) w.WriteHeader(http.StatusCreated) jsonOK(w, g) } @@ -1152,6 +1244,7 @@ func (h *Handler) updateControlLogic(w http.ResponseWriter, r *http.Request) { return } h.ctrlEngine.Reload() + h.recordMutation(r, "controllogic.update", g.ID+" "+g.Name) jsonOK(w, g) } @@ -1164,11 +1257,13 @@ func (h *Handler) deleteControlLogic(w http.ResponseWriter, r *http.Request) { jsonError(w, http.StatusForbidden, "you are not permitted to edit logic") return } - if err := h.ctrlLogic.Delete(r.PathValue("id")); err != nil { - jsonError(w, http.StatusNotFound, "control logic graph not found: "+r.PathValue("id")) + id := r.PathValue("id") + if err := h.ctrlLogic.Delete(id); err != nil { + jsonError(w, http.StatusNotFound, "control logic graph not found: "+id) return } h.ctrlEngine.Reload() + h.recordMutation(r, "controllogic.delete", id) w.WriteHeader(http.StatusNoContent) } diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 7fb85a9..437e4f9 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -14,6 +14,7 @@ import ( "github.com/uopi/uopi/internal/access" "github.com/uopi/uopi/internal/api" + "github.com/uopi/uopi/internal/audit" "github.com/uopi/uopi/internal/broker" "github.com/uopi/uopi/internal/controllogic" "github.com/uopi/uopi/internal/datasource/stub" @@ -50,10 +51,10 @@ func setup(t *testing.T) (*httptest.Server, func()) { if err != nil { t.Fatal("controllogic.NewStore:", err) } - clEngine := controllogic.NewEngine(ctx, brk, clStore, log) + clEngine := controllogic.NewEngine(ctx, brk, clStore, audit.Nop(), log) mux := http.NewServeMux() - api.New(brk, nil, store, access.New("", nil, nil, nil), acl, clStore, clEngine, "", "", log).Register(mux, "/api/v1") + api.New(brk, nil, store, access.New("", nil, nil, nil, nil), acl, clStore, clEngine, audit.Nop(), "", "", log).Register(mux, "/api/v1") srv := httptest.NewServer(mux) return srv, func() { @@ -214,7 +215,9 @@ func TestSearchSignals(t *testing.T) { resp := get(t, srv, "/api/v1/signals/search?q=sine") assertStatus(t, resp, http.StatusOK) - var signals []struct{ Name string `json:"name"` } + var signals []struct { + Name string `json:"name"` + } readJSON(t, resp, &signals) for _, s := range signals { @@ -259,7 +262,9 @@ func TestInterfaceCRUD(t *testing.T) { // Create resp = postRaw(t, srv, "/api/v1/interfaces", "application/xml", []byte(sampleXML)) assertStatus(t, resp, http.StatusCreated) - var created struct{ ID string `json:"id"` } + var created struct { + ID string `json:"id"` + } readJSON(t, resp, &created) if created.ID == "" { t.Fatal("expected non-empty ID from create") @@ -303,7 +308,9 @@ func TestInterfaceCRUD(t *testing.T) { t.Fatal("clone:", err) } assertStatus(t, resp, http.StatusCreated) - var cloned struct{ ID string `json:"id"` } + var cloned struct { + ID string `json:"id"` + } readJSON(t, resp, &cloned) if cloned.ID == created.ID { t.Error("clone produced same ID as original") diff --git a/internal/audit/audit.go b/internal/audit/audit.go new file mode 100644 index 0000000..e3a6b0a --- /dev/null +++ b/internal/audit/audit.go @@ -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{} } diff --git a/internal/audit/sqlite.go b/internal/audit/sqlite.go new file mode 100644 index 0000000..41dd939 --- /dev/null +++ b/internal/audit/sqlite.go @@ -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() +} diff --git a/internal/audit/sqlite_test.go b/internal/audit/sqlite_test.go new file mode 100644 index 0000000..7955d73 --- /dev/null +++ b/internal/audit/sqlite_test.go @@ -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) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 09d67c7..19b9bab 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -84,10 +84,10 @@ type StubConfig struct { } type EPICSConfig struct { - Enabled bool `toml:"enabled"` - CAAddrList string `toml:"ca_addr_list"` - ArchiveURL string `toml:"archive_url"` - ChannelFinderURL string `toml:"channel_finder_url"` + Enabled bool `toml:"enabled"` + CAAddrList string `toml:"ca_addr_list"` + ArchiveURL string `toml:"archive_url"` + ChannelFinderURL string `toml:"channel_finder_url"` AutoSyncFilter string `toml:"auto_sync_filter"` AutoSyncFromArchiver bool `toml:"auto_sync_from_archiver"` PVNames []string `toml:"pv_names"` diff --git a/internal/controllogic/engine.go b/internal/controllogic/engine.go index 865e9fe..f307146 100644 --- a/internal/controllogic/engine.go +++ b/internal/controllogic/engine.go @@ -10,6 +10,7 @@ import ( "sync" "time" + "github.com/uopi/uopi/internal/audit" "github.com/uopi/uopi/internal/broker" ) @@ -25,23 +26,29 @@ const ( type Engine struct { broker *broker.Broker store *Store + audit audit.Recorder log *slog.Logger root context.Context - mu sync.Mutex - cancel context.CancelFunc // cancels the current generation - wg *sync.WaitGroup // tracks the current generation's goroutines + mu sync.Mutex + cancel context.CancelFunc // cancels the current generation + wg *sync.WaitGroup // tracks the current generation's goroutines // Shared live signal cache for the current generation (key "ds\0name"). liveMu sync.RWMutex live map[string]float64 } -// NewEngine creates an engine bound to root. Call Reload to start it. -func NewEngine(root context.Context, brk *broker.Broker, store *Store, log *slog.Logger) *Engine { +// NewEngine creates an engine bound to root. Call Reload to start it. rec records +// the writes performed by flows; pass audit.Nop() to disable auditing. +func NewEngine(root context.Context, brk *broker.Broker, store *Store, rec audit.Recorder, log *slog.Logger) *Engine { + if rec == nil { + rec = audit.Nop() + } return &Engine{ broker: brk, store: store, + audit: rec, log: log, root: root, live: map[string]float64{}, @@ -227,9 +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) return } + ev := audit.Event{ + Actor: cg.name, + ActorType: audit.ActorSystem, + Action: "signal.write", + DS: ds, + Signal: name, + Value: strconv.FormatFloat(val, 'g', -1, 64), + Detail: "control logic: " + cg.name, + Outcome: audit.OutcomeOK, + } if err := src.Write(e.root, name, val); err != nil { e.log.Warn("control logic: write failed", "ds", ds, "signal", name, "err", err) + ev.Outcome = audit.OutcomeError + ev.Error = err.Error() } + e.audit.Record(ev) } // ── compiled graph ───────────────────────────────────────────────────────────── @@ -245,11 +265,11 @@ type compiledGraph struct { genCtx context.Context wg *sync.WaitGroup - name string - byId map[string]Node - out map[string][]wireOut - inc map[string][]string // incoming source ids per node (for gates) - refs map[string]RefLite // unique signals to subscribe (excl. sys/local) + name string + byId map[string]Node + out map[string][]wireOut + inc map[string][]string // incoming source ids per node (for gates) + refs map[string]RefLite // unique signals to subscribe (excl. sys/local) watchers map[string][]string // signal key → trigger node ids luaNodes map[string]*luaRuntime diff --git a/internal/controllogic/store.go b/internal/controllogic/store.go index b98d2d8..4401a6c 100644 --- a/internal/controllogic/store.go +++ b/internal/controllogic/store.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "sync" + "time" ) const definitionsFile = "controllogic.json" @@ -17,16 +18,18 @@ var ErrNotFound = errors.New("control logic graph not found") // Store persists control-logic graphs as a single JSON file in the storage dir. // Writes are atomic (tmp file + rename); all access is mutex-guarded. type Store struct { - mu sync.RWMutex - path string - items map[string]Graph + mu sync.RWMutex + path string + trashDir string + items map[string]Graph } // NewStore opens (or initialises) the control-logic store under storageDir. func NewStore(storageDir string) (*Store, error) { s := &Store{ - path: filepath.Join(storageDir, definitionsFile), - items: map[string]Graph{}, + path: filepath.Join(storageDir, definitionsFile), + trashDir: filepath.Join(storageDir, "trash", "controllogic"), + items: map[string]Graph{}, } if err := s.load(); err != nil { return nil, err @@ -99,13 +102,32 @@ func (s *Store) Save(g Graph) error { return s.saveLocked() } -// Delete removes a graph by id. +// Delete removes a graph by id, first writing a copy into the trash folder so it +// can be recovered if needed. The trash backup is best-effort: a failure to write +// it does not prevent the delete (but is reported). func (s *Store) Delete(id string) error { s.mu.Lock() defer s.mu.Unlock() - if _, ok := s.items[id]; !ok { + g, ok := s.items[id] + if !ok { return ErrNotFound } + if err := s.trash(g); err != nil { + return fmt.Errorf("move control logic to trash: %w", err) + } delete(s.items, id) return s.saveLocked() } + +// trash writes a single graph as a timestamped JSON file under the trash folder. +func (s *Store) trash(g Graph) error { + if err := os.MkdirAll(s.trashDir, 0o755); err != nil { + return err + } + data, err := json.MarshalIndent(g, "", " ") + if err != nil { + return err + } + dst := filepath.Join(s.trashDir, fmt.Sprintf("%s.%d.json", g.ID, time.Now().UnixMilli())) + return os.WriteFile(dst, data, 0o644) +} diff --git a/internal/datasource/servervar/servervar.go b/internal/datasource/servervar/servervar.go new file mode 100644 index 0000000..a11787e --- /dev/null +++ b/internal/datasource/servervar/servervar.go @@ -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 +} diff --git a/internal/datasource/servervar/servervar_test.go b/internal/datasource/servervar/servervar_test.go new file mode 100644 index 0000000..952e054 --- /dev/null +++ b/internal/datasource/servervar/servervar_test.go @@ -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") + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 88e6a48..6e41fcc 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -10,6 +10,7 @@ import ( "github.com/uopi/uopi/internal/access" "github.com/uopi/uopi/internal/api" + "github.com/uopi/uopi/internal/audit" "github.com/uopi/uopi/internal/broker" "github.com/uopi/uopi/internal/controllogic" "github.com/uopi/uopi/internal/datasource/synthetic" @@ -27,7 +28,10 @@ type Server struct { // New creates the HTTP server, registers all routes, and returns a ready-to-start Server. // synth may be nil if the synthetic data source is not enabled. -func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, policy *access.Policy, acl *panelacl.Store, ctrlLogic *controllogic.Store, ctrlEngine *controllogic.Engine, channelFinderURL, archiverURL, trustedUserHeader string, log *slog.Logger) *Server { +func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, policy *access.Policy, acl *panelacl.Store, ctrlLogic *controllogic.Store, ctrlEngine *controllogic.Engine, rec audit.Recorder, channelFinderURL, archiverURL, trustedUserHeader string, log *slog.Logger) *Server { + if rec == nil { + rec = audit.Nop() + } mux := http.NewServeMux() // Health check @@ -37,7 +41,7 @@ func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Syntheti }) // WebSocket endpoint - mux.Handle("/ws", &wsHandler{broker: brk, log: log, userHeader: trustedUserHeader, policy: policy}) + mux.Handle("/ws", &wsHandler{broker: brk, log: log, userHeader: trustedUserHeader, policy: policy, audit: rec}) // Prometheus-format metrics mux.HandleFunc("/metrics", metrics.Handler(brk.ActiveSubscriptions)) @@ -45,7 +49,7 @@ func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Syntheti // REST API — registered on a dedicated mux so it can be wrapped with the // access-control middleware (identity resolution + global level enforcement). apiMux := http.NewServeMux() - api.New(brk, synth, store, policy, acl, ctrlLogic, ctrlEngine, channelFinderURL, archiverURL, log).Register(apiMux, apiPrefix) + api.New(brk, synth, store, policy, acl, ctrlLogic, ctrlEngine, rec, channelFinderURL, archiverURL, log).Register(apiMux, apiPrefix) mux.Handle(apiPrefix+"/", accessMiddleware(policy, trustedUserHeader, apiMux)) // Embedded frontend — must be last (catch-all) diff --git a/internal/server/ws.go b/internal/server/ws.go index 3c440c3..26e1de5 100644 --- a/internal/server/ws.go +++ b/internal/server/ws.go @@ -5,7 +5,9 @@ import ( "encoding/json" "errors" "log/slog" + "net" "net/http" + "strconv" "strings" "sync" "time" @@ -13,6 +15,7 @@ import ( "github.com/coder/websocket" "github.com/uopi/uopi/internal/access" + "github.com/uopi/uopi/internal/audit" "github.com/uopi/uopi/internal/broker" "github.com/uopi/uopi/internal/datasource" "github.com/uopi/uopi/internal/metrics" @@ -96,6 +99,8 @@ type wsHandler struct { userHeader string // policy enforces the global access level (blacklist) on signal writes. policy *access.Policy + // audit records signal writes (never nil; audit.Nop when disabled). + audit audit.Recorder } 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 != "" { user = strings.TrimSpace(r.Header.Get(h.userHeader)) } + // Resolve to the configured default_user when the header is absent so the + // session carries a real identity (used for audit + EPICS write attribution). + if h.policy != nil { + user = h.policy.ResolveUser(user) + } + clientIP := clientIP(r) conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{ // Permissive origin check for LAN / SSH-tunnel use; tighten when auth lands. @@ -120,11 +131,18 @@ func (h *wsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithCancel(r.Context()) defer cancel() + rec := h.audit + if rec == nil { + rec = audit.Nop() + } + c := &wsClient{ conn: conn, broker: h.broker, user: user, + ip: clientIP, policy: h.policy, + audit: rec, outCh: make(chan []byte, 512), updateCh: make(chan broker.Update, 1024), subs: make(map[broker.SignalRef]func()), @@ -155,7 +173,9 @@ type wsClient struct { broker *broker.Broker log *slog.Logger 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 + audit audit.Recorder // signal-write audit recorder (never nil) outCh chan []byte // serialised outgoing messages updateCh chan broker.Update // raw updates from the broker @@ -307,6 +327,14 @@ func (c *wsClient) handleWrite(ctx context.Context, msg inMsg) { return } + // Server variables are read-any but write only for control-logic editors, so a + // panel cannot mutate sequence state unless its user owns control-logic access. + if msg.DS == "srv" && c.policy != nil && !c.policy.CanEditLogic(c.policy.ResolveUser(c.user)) { + c.log.Warn("write: server variable denied", "name", msg.Name, "user", c.user) + c.sendError(ctx, "ACCESS_DENIED", "writing server variables requires control-logic access") + return + } + ds, ok := c.broker.Source(msg.DS) if !ok { c.log.Warn("write: unknown data source", "ds", msg.DS, "name", msg.Name) @@ -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 // act under that identity rather than the server's. ctx = datasource.WithUser(ctx, c.user) + ev := audit.Event{ + Actor: c.user, + ActorType: audit.ActorUser, + Action: "signal.write", + DS: msg.DS, + Signal: msg.Name, + Value: formatAuditValue(value), + IP: c.ip, + Outcome: audit.OutcomeOK, + } if err := ds.Write(ctx, msg.Name, value); err != nil { c.log.Warn("write: ds.Write failed", "ds", msg.DS, "name", msg.Name, "err", err) + ev.Outcome = audit.OutcomeError + ev.Error = err.Error() + c.audit.Record(ev) c.sendError(ctx, "WRITE_ERROR", err.Error()) + return } + c.audit.Record(ev) } func (c *wsClient) handleHistory(ctx context.Context, msg inMsg) { @@ -420,6 +463,44 @@ func (c *wsClient) sendError(ctx context.Context, code, message string) { // ── Helpers ─────────────────────────────────────────────────────────────────── +// clientIP returns the best-effort client address for audit attribution, +// preferring the X-Forwarded-For / X-Real-IP headers set by a reverse proxy and +// falling back to the TCP peer address. +func clientIP(r *http.Request) string { + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + if i := strings.IndexByte(xff, ','); i >= 0 { + return strings.TrimSpace(xff[:i]) + } + return strings.TrimSpace(xff) + } + if xr := r.Header.Get("X-Real-IP"); xr != "" { + return strings.TrimSpace(xr) + } + if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { + return host + } + return r.RemoteAddr +} + +// formatAuditValue renders a written value as a compact string for the audit log. +func formatAuditValue(v any) string { + switch x := v.(type) { + case string: + return x + case float64: + return strconv.FormatFloat(x, 'g', -1, 64) + case bool: + return strconv.FormatBool(x) + case nil: + return "" + default: + if b, err := json.Marshal(v); err == nil { + return string(b) + } + return "" + } +} + func metadataToPayload(m datasource.Metadata) *metaPayload { return &metaPayload{ Type: dataTypeName(m.Type), diff --git a/internal/storage/store.go b/internal/storage/store.go index 1b67282..b8a26ac 100644 --- a/internal/storage/store.go +++ b/internal/storage/store.go @@ -474,16 +474,43 @@ func setXMLAttribute(data []byte, attr, value string) ([]byte, error) { return []byte(s[:valStart] + value + s[valStart+valEnd:]), nil } -// Delete removes the interface with the given ID. +// Delete moves the interface with the given ID — and all of its version +// backups — into a timestamped trash folder so it can be recovered if needed. +// Nothing is destroyed; recovery is a matter of moving the files back. func (s *Store) Delete(id string) error { if err := validateID(id); err != nil { return ErrNotFound } - err := os.Remove(s.filePath(id)) - if errors.Is(err, os.ErrNotExist) { - return ErrNotFound + if _, err := os.Stat(s.filePath(id)); err != nil { + if errors.Is(err, os.ErrNotExist) { + return ErrNotFound + } + return err } - return err + + trashDir := filepath.Join(s.rootDir, "trash", "interfaces", fmt.Sprintf("%s.%d", id, time.Now().UnixMilli())) + if err := os.MkdirAll(trashDir, 0o755); err != nil { + return fmt.Errorf("create trash dir: %w", err) + } + + // Move the current file plus every versioned backup (id.vN.xml), preserving + // their original names so a restore is a straight move-back. + entries, err := os.ReadDir(s.dir) + if err != nil { + return err + } + for _, e := range entries { + name := e.Name() + if e.IsDir() { + continue + } + if name == id+".xml" || (strings.HasPrefix(name, id+".v") && isVersioned(name)) { + if err := os.Rename(filepath.Join(s.dir, name), filepath.Join(trashDir, name)); err != nil { + return fmt.Errorf("move %s to trash: %w", name, err) + } + } + } + return nil } // Clone duplicates an interface, assigning a new unique ID. diff --git a/web/src/AuditViewer.tsx b/web/src/AuditViewer.tsx new file mode 100644 index 0000000..9f9588b --- /dev/null +++ b/web/src/AuditViewer.tsx @@ -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([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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 ( +
{ if (e.target === e.currentTarget) onClose(); }}> +
+
+ Audit log + Every system-affecting action (signal writes, control-logic and interface changes). +
+ + +
+
+ +
+ setUser((e.target as HTMLInputElement).value)} /> + + setSignal((e.target as HTMLInputElement).value)} /> + + + + +
+ + {error &&
{error}
} + +
+ {!error && events.length === 0 && !loading && ( +
No matching audit entries.
+ )} + {events.length > 0 && ( + + + + + + + + + + + + + + + + {events.map((ev, i) => ( + + + + + + + + + + + + ))} + +
TimeActorActionSourceSignal / targetValueDetailClientResult
{fmtTime(ev.time)} + {ev.actorType} + {' '}{ev.actor || '—'} + {ev.action}{ev.ds || '—'}{ev.signal || '—'}{ev.value ?? '—'}{ev.detail || '—'}{ev.ip || '—'} + {ev.outcome === 'error' + ? error + : ok} +
+ )} +
+
+
+ ); +} diff --git a/web/src/ContextualHelp.tsx b/web/src/ContextualHelp.tsx deleted file mode 100644 index a93ad2c..0000000 --- a/web/src/ContextualHelp.tsx +++ /dev/null @@ -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> = { - 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(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 ( -
- - - {open && ( - - )} -
- ); -} diff --git a/web/src/ControlLogicEditor.tsx b/web/src/ControlLogicEditor.tsx index d2dc294..97ce37b 100644 --- a/web/src/ControlLogicEditor.tsx +++ b/web/src/ControlLogicEditor.tsx @@ -139,6 +139,16 @@ export default function ControlLogicEditor({ onClose }: Props) { const [dirty, setDirty] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); + const [showCloseConfirm, setShowCloseConfirm] = useState(false); + + // Closing with unsaved changes prompts instead of silently discarding them. + function requestClose() { + if (dirty) { setShowCloseConfirm(true); return; } + onClose(); + } + async function saveAndClose() { + if (await saveGraph()) { setShowCloseConfirm(false); onClose(); } + } async function reload() { try { @@ -180,8 +190,8 @@ export default function ControlLogicEditor({ onClose }: Props) { } } - async function saveGraph() { - if (!graph) return; + async function saveGraph(): Promise { + if (!graph) return false; setBusy(true); setError(null); try { @@ -191,8 +201,10 @@ export default function ControlLogicEditor({ onClose }: Props) { if (!res.ok) throw new Error(`HTTP ${res.status}`); setDirty(false); await reload(); + return true; } catch (err) { setError(`Save failed: ${err instanceof Error ? err.message : err}`); + return false; } finally { setBusy(false); } @@ -240,7 +252,7 @@ export default function ControlLogicEditor({ onClose }: Props) { } return ( -
{ if (e.target === e.currentTarget) onClose(); }}> +
{ if (e.target === e.currentTarget) requestClose(); }}>
Control logic @@ -248,7 +260,7 @@ export default function ControlLogicEditor({ onClose }: Props) {
{graph && dirty && unsaved} {graph && } - +
@@ -299,6 +311,21 @@ export default function ControlLogicEditor({ onClose }: Props) { )}
+ + {showCloseConfirm && ( +
{ if (e.target === e.currentTarget) setShowCloseConfirm(false); }}> +
+
Unsaved changes
+

This graph has unsaved changes. What would you like to do?

+
+ + + +
+
+
+ )}
); @@ -341,12 +368,15 @@ function FlowEditor({ graph, onChange }: { } catch {} } - // Bare-name write targets are graph-local variables; offer them under 'local'. + // Graph-local write targets (bare name, or an explicit "local:" prefix) become + // the suggestions offered when the target data source is 'local'. const localNames = Array.from(new Set( nodes.filter(n => n.kind === 'action.write') .map(n => n.params.target ?? '') + .map(t => t.startsWith('local:') ? t.slice('local:'.length) : t) .filter(t => t && !t.includes(':')) )); + // 'srv' is a registered data source, so it already appears in dataSources. const dsOptions = ['local', 'sys', ...dataSources]; function signalOptions(ds: string): string[] { if (ds === 'local') return localNames; @@ -702,16 +732,30 @@ function FlowEditor({ graph, onChange }: { onSelect={(newDs) => { loadSignals(newDs); patchParams(selected.id, { target: `${newDs}:` }); }} />
- - patchParams(selected.id, { target: `${ds}:${sig}` })} - placeholder={ds ? 'Search signals…' : 'Select data source first'} /> + + {(ds === 'local' || ds === 'srv') ? ( + + patchParams(selected.id, { target: `${ds}:${(e.target as HTMLInputElement).value}` })} /> + + {signalOptions(ds).map(o => +

{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.'}

+
+ ) : ( + patchParams(selected.id, { target: `${ds}:${sig}` })} + placeholder={ds ? 'Search signals…' : 'Select data source first'} /> + )}
patchParams(selected.id, { expr: v })} onInsert={(ds2, sig) => insertRef(selected.id, 'expr', ds2, sig)} dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals} - hint="Write to a data source signal, or a bare name to store a graph-local var. e.g. ({stub:a} - {stub:b}) / {sys:dt}." /> + 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}." /> ); })()} diff --git a/web/src/EditMode.tsx b/web/src/EditMode.tsx index 4f9fc82..cbf7a0d 100644 --- a/web/src/EditMode.tsx +++ b/web/src/EditMode.tsx @@ -8,7 +8,6 @@ import EditCanvas, { genWidgetId, DEFAULT_SIZES } from './EditCanvas'; import PlotPanelCanvas from './PlotPanelCanvas'; import LogicEditor from './LogicEditor'; import PropertiesPane from './PropertiesPane'; -import ContextualHelp from './ContextualHelp'; import HelpModal from './HelpModal'; import ZoomControl from './ZoomControl'; import { useAuth, canWrite } from './lib/auth'; @@ -626,7 +625,6 @@ export default function EditMode({ initial, onDone }: Props) {
- diff --git a/web/src/HelpModal.tsx b/web/src/HelpModal.tsx index 9bc31a8..5967085 100644 --- a/web/src/HelpModal.tsx +++ b/web/src/HelpModal.tsx @@ -403,6 +403,7 @@ function DiagramSignalTree() { const SECTIONS = [ { id: 'start', label: 'Getting Started' }, + { id: 'tips', label: 'Quick Tips' }, { id: 'view', label: 'View Mode' }, { id: 'edit', label: 'Edit Mode' }, { id: 'widgets', label: 'Widgets' }, @@ -411,10 +412,68 @@ const SECTIONS = [ { id: 'logic', label: 'Panel Logic' }, { id: 'control', label: 'Control Logic' }, { id: 'sharing', label: 'Sharing & Access' }, + { id: 'audit', label: 'Audit Log' }, { id: 'history', label: 'Historical Data' }, { id: 'shortcuts', label: 'Keyboard Shortcuts' }, ]; +// Quick tips, grouped by the mode they apply to. Each links to the manual +// section that covers it in depth. (Previously surfaced via a floating +// contextual-help popover; now consolidated here in the manual.) +const TIPS: Record<'view' | 'edit', Array<{ text: string; section: string }>> = { + view: [ + { text: 'Click any interface in the left panel to load it on the canvas.', section: 'view' }, + { text: 'Right-click a widget to view signal info or export data as CSV.', section: 'view' }, + { text: 'The ● Live button shows live streaming is active. Use the date pickers to load historical data.', section: 'history' }, + { text: 'Click Edit (top-right) to open the drag-and-drop panel builder.', section: 'edit' }, + { text: 'Use the Share button on a panel to grant access to users or groups.', section: 'sharing' }, + { text: 'The ⚙ Control logic button opens server-side automation that runs even with no panel open.', section: 'control' }, + { text: 'The 🛡 Audit button (for permitted users) opens the log of system-affecting actions.', section: 'audit' }, + { text: 'The status chip turns green and shows your username when the WebSocket is connected.', section: 'view' }, + ], + edit: [ + { text: 'Drag a signal from the left tree onto the canvas to create a widget.', section: 'edit' }, + { text: 'Click a widget to select it, then adjust its properties on the right.', section: 'edit' }, + { text: 'Ctrl+click to select multiple widgets; then use the align toolbar.', section: 'edit' }, + { text: 'Ctrl+Z to undo, Ctrl+Y to redo. Up to 50 steps are remembered.', section: 'shortcuts' }, + { text: 'Use "+ Synthetic" in the signal tree to create computed/filtered signals.', section: 'signals' }, + { text: 'Switch to the Logic tab to add interactive behaviour with a node-graph flow editor.', section: 'logic' }, + { text: 'Add Local variables for set-points and counters that live inside the panel.', section: 'edit' }, + { text: 'Arrow keys nudge the selected widget by 1 px (Shift for grid size).', section: 'shortcuts' }, + ], +}; + +function SectionTips({ onNavigate }: { onNavigate: (section: string) => void }) { + const groups: Array<['view' | 'edit', string]> = [ + ['view', 'In View mode'], + ['edit', 'In Edit mode'], + ]; + return ( +
+

+ 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. +

+ {groups.map(([mode, title]) => ( +
+

{title}

+
    + {TIPS[mode].map(tip => ( +
  • + +
  • + ))} +
+
+ ))} +
+ ); +} + function SectionStart() { return (
@@ -481,8 +540,10 @@ function SectionView() {

Connection status

- The coloured chip in the top-right shows the WebSocket connection state. - A red "disconnected" banner appears at the top while reconnecting — live values will resume automatically. + The coloured chip in the top-right shows the WebSocket connection state. When connected it + reads "connected as <your username>", so you can confirm the identity your + actions are attributed to in the audit log. A red "disconnected" banner appears at + the top while reconnecting — live values will resume automatically.

); @@ -677,6 +738,44 @@ function SectionSharing() { ); } +function SectionAudit() { + return ( +
+

+ When the server is configured with [audit] enabled = true, every + system-affecting action is appended to an append-only log so it can be + reviewed later. Permitted users open it with the 🛡 Audit button in the + view-mode toolbar. +

+ +

What gets recorded

+
    +
  • Signal writes — both user-initiated (set-value widgets, buttons, panel + logic) and automated writes from the control-logic engine.
  • +
  • Interface changes — panels created, updated, or deleted.
  • +
  • Control-logic changes — graphs created, updated, or deleted.
  • +
+

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.

+ +

Viewing the log

+
    +
  • The viewer lists the most recent events first.
  • +
  • Filter by user, action, signal, or a start/end time range.
  • +
  • System (automated) actions are tagged distinctly from user actions.
  • +
+ +

+ Access to the audit log is gated by an allowlist (audit.readers 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. +

+
+ ); +} + function SectionWidgets() { return (
@@ -858,8 +957,9 @@ function SectionShortcuts() { ); } -const SECTION_COMPONENTS: Record JSX.Element> = { +const SECTION_COMPONENTS: Record void }) => JSX.Element> = { start: SectionStart, + tips: SectionTips, view: SectionView, edit: SectionEdit, widgets: SectionWidgets, @@ -868,6 +968,7 @@ const SECTION_COMPONENTS: Record JSX.Element> = { logic: SectionLogic, control: SectionControl, sharing: SectionSharing, + audit: SectionAudit, history: SectionHistory, shortcuts: SectionShortcuts, }; @@ -877,7 +978,8 @@ const SECTION_COMPONENTS: Record JSX.Element> = { export default function HelpModal({ initialSection = 'start', onClose }: Props) { const [active, setActive] = useState(initialSection); - const Content = SECTION_COMPONENTS[active] ?? SectionStart; + const Content: (props: { onNavigate: (section: string) => void }) => JSX.Element = + SECTION_COMPONENTS[active] ?? SectionStart; // Close on backdrop click function handleBackdrop(e: MouseEvent) { @@ -915,7 +1017,7 @@ export default function HelpModal({ initialSection = 'start', onClose }: Props) {/* Content area */}

{SECTIONS.find(s => s.id === active)?.label}

- +
diff --git a/web/src/SignalTree.tsx b/web/src/SignalTree.tsx index 1c87b9d..6b7ff7e 100644 --- a/web/src/SignalTree.tsx +++ b/web/src/SignalTree.tsx @@ -1,7 +1,6 @@ import { h, Fragment } from 'preact'; import { useState, useEffect, useRef, useMemo } from 'preact/hooks'; import type { SignalRef, StateVar } from './lib/types'; -import SyntheticWizard from './SyntheticWizard'; import SyntheticGraphEditor from './SyntheticGraphEditor'; import GroupsTree from './GroupsTree'; import ChannelFinderModal from './ChannelFinderModal'; @@ -491,10 +490,11 @@ export default function SignalTree({ onDragStart, width, panelId, statevars, onS )} {showWizard && ( - setShowWizard(false)} - onCreated={loadAllSignals} + onSaved={loadAllSignals} /> )} diff --git a/web/src/SyntheticGraphEditor.tsx b/web/src/SyntheticGraphEditor.tsx index 211436b..15dc7a5 100644 --- a/web/src/SyntheticGraphEditor.tsx +++ b/web/src/SyntheticGraphEditor.tsx @@ -8,7 +8,12 @@ interface DataSource { name: string; } interface SignalInfo { name: string; } interface Props { - name: string; + // Existing signal name (edit mode). Omitted/empty in create mode. + name?: string; + // When true the editor starts blank and POSTs a new signal on save. + create?: boolean; + // Interface id used to bind panel-scoped signals when creating. + panelId?: string; onClose: () => void; onSaved: () => void; } @@ -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(null); const [graph, setGraph] = useState({ nodes: [], wires: [] }); const [selected, setSelected] = useState(null); @@ -179,6 +184,15 @@ export default function SyntheticGraphEditor({ name, onClose, onSaved }: Props) const [saving, setSaving] = useState(false); const [error, setError] = useState(''); + // Identity fields — editable only when creating a new signal. + const [newName, setNewName] = useState(''); + const [visibility, setVisibility] = useState<'panel' | 'user' | 'global'>(panelId ? 'panel' : 'user'); + const [unit, setUnit] = useState(''); + const [desc, setDesc] = useState(''); + const [dispLow, setDispLow] = useState('0'); + const [dispHigh, setDispHigh] = useState('100'); + const sigName = create ? newName.trim() : (name ?? ''); + const [dataSources, setDataSources] = useState([]); const [dsSignals, setDsSignals] = useState>({}); @@ -215,17 +229,28 @@ export default function SyntheticGraphEditor({ name, onClose, onSaved }: Props) } useEffect(() => { - fetch(`/api/v1/synthetic/${encodeURIComponent(name)}`) + if (create) { + const blank: SignalDef = { name: '', inputs: [], pipeline: [], meta: {} }; + setDef(blank); + setGraph(buildInitial(blank)); + setLoading(false); + return; + } + fetch(`/api/v1/synthetic/${encodeURIComponent(name ?? '')}`) .then(r => r.ok ? r.json() : Promise.reject(r.statusText)) .then((d: SignalDef) => { setDef(d); + setUnit(d.meta?.unit ?? ''); + setDesc(d.meta?.description ?? ''); + setDispLow(String(d.meta?.displayLow ?? '0')); + setDispHigh(String(d.meta?.displayHigh ?? '100')); const g = buildInitial(d); setGraph(g); g.nodes.forEach(n => { if (n.kind === 'source' && n.ds) loadSignals(n.ds); }); }) .catch(e => setError(String(e))) .finally(() => setLoading(false)); - }, [name]); + }, [name, create]); // ── History ──────────────────────────────────────────────────────────────── function pushUndo() { @@ -282,7 +307,8 @@ export default function SyntheticGraphEditor({ name, onClose, onSaved }: Props) }); } function moveNode(id: string, x: number, y: number, record: boolean) { - commit({ nodes: graph.nodes.map(n => (n.id === id ? { ...n, x, y } : n)), wires: graph.wires }, record); + const g = graphRef.current; + commit({ nodes: g.nodes.map(n => (n.id === id ? { ...n, x, y } : n)), wires: g.wires }, record); } function deleteNode(id: string) { const n = graph.nodes.find(x => x.id === id); @@ -314,48 +340,52 @@ export default function SyntheticGraphEditor({ name, onClose, onSaved }: Props) const p = toCanvas(e); dragNode.current = { id: node.id, dx: p.x - node.x, dy: p.y - node.y, pushed: false }; setSelected(node.id); setSelectedWire(null); - window.addEventListener('mousemove', onNodeDragMove); - window.addEventListener('mouseup', onNodeDragUp); - } - function onNodeDragMove(e: MouseEvent) { - const d = dragNode.current; - if (!d) return; - const p = toCanvas(e); - const record = !d.pushed; - d.pushed = true; - moveNode(d.id, Math.max(0, p.x - d.dx), Math.max(0, p.y - d.dy), record); - } - function onNodeDragUp() { - dragNode.current = null; - window.removeEventListener('mousemove', onNodeDragMove); - window.removeEventListener('mouseup', onNodeDragUp); } function startWire(e: MouseEvent, node: GNode) { e.stopPropagation(); const p = toCanvas(e); setPendingWire({ from: node.id, x: p.x, y: p.y }); - window.addEventListener('mousemove', onWireMove); - window.addEventListener('mouseup', onWireUp); - } - function onWireMove(e: MouseEvent) { - const cur = pendingRef.current; - if (!cur) return; - const p = toCanvas(e); - setPendingWire({ ...cur, x: p.x, y: p.y }); } function endWire() { pendingRef.current = null; - window.removeEventListener('mousemove', onWireMove); - window.removeEventListener('mouseup', onWireUp); setPendingWire(null); } - function onWireUp() { endWire(); } function finishWire(target: GNode) { const cur = pendingRef.current; if (cur && hasInput(target.kind)) addWire(cur.from, target.id); endWire(); } + // Single mount-time pointer handler. Reads live drag/wire state from refs so it + // never goes stale across re-renders — fixes the "can't drop a dragged node" bug. + useEffect(() => { + function onMove(e: MouseEvent) { + const d = dragNode.current; + if (d) { + const p = toCanvas(e); + const record = !d.pushed; + d.pushed = true; + moveNode(d.id, Math.max(0, p.x - d.dx), Math.max(0, p.y - d.dy), record); + return; + } + const cur = pendingRef.current; + if (cur) { + const p = toCanvas(e); + setPendingWire({ ...cur, x: p.x, y: p.y }); + } + } + function onUp() { + dragNode.current = null; + if (pendingRef.current) endWire(); + } + window.addEventListener('mousemove', onMove); + window.addEventListener('mouseup', onUp); + return () => { + window.removeEventListener('mousemove', onMove); + window.removeEventListener('mouseup', onUp); + }; + }, []); + // ── Palette drag-and-drop ────────────────────────────────────────────────── const DRAG_MIME = 'application/x-uopi-synth-op'; function onCanvasDragOver(e: DragEvent) { @@ -396,16 +426,39 @@ export default function SyntheticGraphEditor({ name, onClose, onSaved }: Props) async function handleSave() { if (!def) return; + if (create && !sigName) { setError('Enter a name for the new signal.'); return; } if (compiled.error) { setError(compiled.error); return; } setSaving(true); setError(''); try { - const body = { ...def, inputs: compiled.inputs, pipeline: compiled.pipeline, ds: undefined, signal: undefined }; - const res = await fetch(`/api/v1/synthetic/${encodeURIComponent(name)}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); + const meta = { + ...def.meta, + unit: unit || undefined, + description: desc || undefined, + displayLow: parseFloat(dispLow), + displayHigh: parseFloat(dispHigh), + }; + const body: any = { + ...def, + name: sigName, + 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) { const j = await res.json().catch(() => ({ error: res.statusText })); throw new Error(j.error ?? res.statusText); @@ -428,7 +481,7 @@ export default function SyntheticGraphEditor({ name, onClose, onSaved }: Props)
e.stopPropagation()}>
- Synthetic Signal — {name} + Synthetic Signal{create ? ' — new' : ` — ${name}`}
@@ -515,7 +568,53 @@ export default function SyntheticGraphEditor({ name, onClose, onSaved }: Props)
- {!sel &&
Select a node to edit it, or add one from the palette.
} + {!sel && ( + +
Signal
+ {create ? ( + +
+ + setNewName((e.target as HTMLInputElement).value)} /> +
+
+ + +
+
+ ) : ( +

Editing {name}.

+ )} +
+ + setUnit((e.target as HTMLInputElement).value)} /> +
+
+ + setDesc((e.target as HTMLInputElement).value)} /> +
+
+ + setDispLow((e.target as HTMLInputElement).value)} /> +
+
+ + setDispHigh((e.target as HTMLInputElement).value)} /> +
+
Select a node to edit it, or add one from the palette.
+
+ )} {sel?.kind === 'source' && ( diff --git a/web/src/SyntheticWizard.tsx b/web/src/SyntheticWizard.tsx deleted file mode 100644 index 9ab2064..0000000 --- a/web/src/SyntheticWizard.tsx +++ /dev/null @@ -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([{ ds: 'stub', signal: 'sine_1hz' }]); - const [nodeType, setNodeType] = useState('gain'); - const [params, setParams] = useState>({}); - // 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([]); - const [dsSignals, setDsSignals] = useState>({}); - - 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) { - 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 = {}; - 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 ( -
-
e.stopPropagation()}> -
- New Synthetic Signal - -
- -
- {error &&

{error}

} - -
Signal identity
-
- - setName((e.target as HTMLInputElement).value)} /> -
- -
- - -
- -
Input signals
- {inputs.map((inp, idx) => ( -
-
- - updateInput(idx, { ds })} - /> -
-
- - updateInput(idx, { signal })} - placeholder={inp.ds ? 'Search signals…' : 'Select data source first'} - /> -
- -
- ))} - - -
Processing
-
- - -
- {nodeDef.params.map(p => ( -
- - {p.type === 'lua' ? ( - setParam(p.key, v)} - /> - ) : ( - setParam(p.key, (e.target as HTMLInputElement).value)} /> - )} -
- ))} - -
Metadata (optional)
-
-
- - setUnit((e.target as HTMLInputElement).value)} /> -
-
- - setDispLow((e.target as HTMLInputElement).value)} /> -
-
- - setDispHigh((e.target as HTMLInputElement).value)} /> -
-
-
- - setDesc((e.target as HTMLInputElement).value)} /> -
-
- - -
-
- ); -} diff --git a/web/src/ViewMode.tsx b/web/src/ViewMode.tsx index f06dcc3..f5f2f5d 100644 --- a/web/src/ViewMode.tsx +++ b/web/src/ViewMode.tsx @@ -7,10 +7,10 @@ import { parseInterface } from './lib/xml'; import { newPlotPanel } from './lib/templates'; import { useAuth, canWrite } from './lib/auth'; import type { Interface } from './lib/types'; -import ContextualHelp from './ContextualHelp'; import HelpModal from './HelpModal'; import ZoomControl from './ZoomControl'; import ControlLogicEditor from './ControlLogicEditor'; +import AuditViewer from './AuditViewer'; interface Props { onEdit?: (iface?: Interface) => void; @@ -33,6 +33,7 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) { const [helpSection, setHelpSection] = useState('start'); const [showTimeNav, setShowTimeNav] = useState(false); const [showControlLogic, setShowControlLogic] = useState(false); + const [showAudit, setShowAudit] = useState(false); const [leftW, setLeftW] = useState(220); const [listCollapsed, setListCollapsed] = useState(false); const me = useAuth(); @@ -137,9 +138,13 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) { )}
-
+
{wsStatus === 'connected' && me.user ? `connected as ${me.user}` : wsStatus} + {me.user && !writable && ' (read-only)'}
- )} + {me.canViewAudit && ( + + )} {writable && (
@@ -257,6 +265,10 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) { {showControlLogic && ( setShowControlLogic(false)} /> )} + + {showAudit && ( + setShowAudit(false)} /> + )}
); } diff --git a/web/src/lib/auth.ts b/web/src/lib/auth.ts index 565165c..2026ce7 100644 --- a/web/src/lib/auth.ts +++ b/web/src/lib/auth.ts @@ -4,7 +4,7 @@ import type { Me, AccessLevel } from './types'; // Default identity used before /api/v1/me resolves (or if it fails): assume a // trusted LAN user with full access, matching the backend default. -export const DEFAULT_ME: Me = { user: '', level: 'write', groups: [], canEditLogic: true }; +export const DEFAULT_ME: Me = { user: '', level: 'write', groups: [], canEditLogic: true, canViewAudit: true }; // AuthContext carries the resolved identity + global access level for the // current user throughout the app. @@ -37,6 +37,7 @@ export async function fetchMe(): Promise { level: (data.level as AccessLevel) ?? 'write', groups: Array.isArray(data.groups) ? data.groups : [], canEditLogic: data.canEditLogic !== false, + canViewAudit: data.canViewAudit !== false, }; } catch { return DEFAULT_ME; diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 50285f2..f919e7b 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -212,6 +212,9 @@ export interface Me { // Whether the user may add/edit panel logic and server-side control logic. // False only when a logic-editors allowlist is configured and excludes them. canEditLogic: boolean; + // Whether the user may view the audit log. False only when an audit-readers + // allowlist is configured and excludes them, or auditing is disabled. + canViewAudit: boolean; } // Effective permission a user holds on a single panel or folder. diff --git a/web/src/styles.css b/web/src/styles.css index 4b59653..30ddfac 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -123,6 +123,8 @@ body { color: #94a3b8; border: 1px solid #334155; text-transform: capitalize; + white-space: nowrap; + flex-shrink: 0; } .status-chip.connected { @@ -165,18 +167,6 @@ body { } /* ── User identity chip ──────────────────────────────────────────────────── */ -.user-chip { - display: flex; - align-items: center; - font-size: 0.75rem; - padding: 0.2rem 0.6rem; - border-radius: 9999px; - background: #1e293b; - color: #cbd5e1; - border: 1px solid #334155; - white-space: nowrap; -} - /* ── Access denied screen ────────────────────────────────────────────────── */ .access-denied { display: flex; @@ -2746,90 +2736,50 @@ kbd { color: #e2e8f0; } -/* ── Contextual Help ─────────────────────────────────────────────────────── */ +/* ── Manual: Quick Tips list ─────────────────────────────────────────────── */ -.ctx-help { - position: relative; - display: flex; - align-items: center; -} - -.ctx-help-btn { - width: 22px; - height: 22px; - border-radius: 50%; - border: 1.5px solid #4a5568; - background: #1a2236; - color: #94a3b8; - font-size: 0.78rem; - font-weight: 700; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - transition: border-color 0.15s, color 0.15s; - flex-shrink: 0; - line-height: 1; +.help-tips-list { + list-style: none; + margin: 0 0 1rem; padding: 0; + display: flex; + flex-direction: column; + gap: 0.4rem; } -.ctx-help-btn:hover { border-color: #4a9eff; color: #4a9eff; } -.ctx-help-popover { - position: absolute; - top: calc(100% + 8px); - right: 0; - z-index: 400; +.help-tip-item { + display: flex; + align-items: center; + gap: 0.6rem; + width: 100%; + text-align: left; background: #1a1f2e; border: 1px solid #2d3748; - border-radius: 8px; - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); - width: 280px; - padding: 0.75rem; -} - -.ctx-help-tip { - display: flex; - gap: 0.5rem; - font-size: 0.82rem; + border-radius: 6px; + padding: 0.55rem 0.7rem; color: #cbd5e1; - line-height: 1.5; - margin-bottom: 0.6rem; + font-size: 0.85rem; + line-height: 1.45; + cursor: pointer; + transition: border-color 0.15s, background 0.15s; +} +.help-tip-item:hover { + border-color: #4a9eff; + background: #1e2540; } -.ctx-help-icon { - font-size: 1rem; +.help-tip-icon { flex-shrink: 0; - margin-top: 0.05rem; + font-size: 1rem; } -.ctx-help-footer { - display: flex; - justify-content: space-between; - align-items: center; - border-top: 1px solid #2d3748; - padding-top: 0.5rem; - gap: 0.5rem; -} +.help-tip-text { flex: 1; } -.ctx-help-nav { - font-size: 0.75rem; - color: #64748b; - background: none; - border: none; - cursor: pointer; - padding: 0; -} -.ctx-help-nav:hover { color: #94a3b8; } - -.ctx-help-link { - font-size: 0.75rem; +.help-tip-go { + flex-shrink: 0; color: #4a9eff; - background: none; - border: none; - cursor: pointer; - padding: 0; + font-weight: 700; } -.ctx-help-link:hover { text-decoration: underline; } /* ── Historical time-nav bar (below main toolbar, hidden by default) ──────── */ @@ -3446,6 +3396,37 @@ kbd { overflow: hidden; } +.cl-confirm-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.55); + display: flex; + align-items: center; + justify-content: center; + z-index: 650; +} + +.cl-confirm { + background: #1a1f2e; + border: 1px solid #2d3748; + border-radius: 10px; + padding: 1.25rem 1.5rem; + width: min(440px, 92vw); + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.6); +} + +.cl-confirm-title { + font-weight: 600; + margin-bottom: 0.5rem; +} + +.cl-confirm-actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + margin-top: 1.25rem; +} + .cl-header { display: flex; align-items: center; @@ -3455,6 +3436,127 @@ kbd { flex-shrink: 0; } +/* ── Audit log viewer ──────────────────────────────────────────────────── */ +.audit-modal { + width: min(1300px, 98vw); +} + +.audit-filters { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.5rem; + padding: 0.6rem 1.25rem; + border-bottom: 1px solid #2d3748; + flex-shrink: 0; +} + +.audit-filter { + background: #0f1420; + border: 1px solid #2d3748; + border-radius: 5px; + color: #e2e8f0; + padding: 0.3rem 0.5rem; + font-size: 0.8rem; +} + +.audit-filter-label { + display: flex; + align-items: center; + gap: 0.3rem; + font-size: 0.75rem; + color: #94a3b8; +} + +.audit-body { + flex: 1; + overflow: auto; + padding: 0 0 0.5rem; +} + +.audit-empty { + padding: 1.5rem 1.25rem; +} + +.audit-table { + width: 100%; + border-collapse: collapse; + font-size: 0.8rem; +} + +.audit-table th, +.audit-table td { + text-align: left; + padding: 0.35rem 0.6rem; + border-bottom: 1px solid #232b3a; + white-space: nowrap; +} + +.audit-table th { + position: sticky; + top: 0; + background: #161c2a; + color: #94a3b8; + font-weight: 600; + z-index: 1; +} + +.audit-table tbody tr:hover { + background: #1d2433; +} + +.audit-row-error td { + background: rgba(220, 38, 38, 0.08); +} + +.audit-time { + color: #94a3b8; + font-variant-numeric: tabular-nums; +} + +.audit-signal, +.audit-detail { + max-width: 18rem; + overflow: hidden; + text-overflow: ellipsis; +} + +.audit-value { + max-width: 10rem; + overflow: hidden; + text-overflow: ellipsis; + font-family: ui-monospace, monospace; +} + +.audit-actor-tag { + display: inline-block; + font-size: 0.65rem; + text-transform: uppercase; + letter-spacing: 0.03em; + padding: 0.05rem 0.35rem; + border-radius: 3px; + vertical-align: middle; +} + +.audit-actor-user { + background: #1e3a5f; + color: #93c5fd; +} + +.audit-actor-system { + background: #4a2d6b; + color: #d8b4fe; +} + +.audit-result-ok { + color: #4ade80; +} + +.audit-result-err { + color: #f87171; + font-weight: 600; +} + .cl-title { font-size: 1rem; font-weight: 700; diff --git a/workspace/data/audit.db b/workspace/data/audit.db new file mode 100644 index 0000000..350bcb8 Binary files /dev/null and b/workspace/data/audit.db differ diff --git a/workspace/data/controllogic.json b/workspace/data/controllogic.json new file mode 100644 index 0000000..25d614b --- /dev/null +++ b/workspace/data/controllogic.json @@ -0,0 +1,9 @@ +[ + { + "id": "cl-0f99598ca4d0ea15", + "name": "New control logic", + "enabled": false, + "nodes": [], + "wires": [] + } +] \ No newline at end of file