From aba394b84d17cd3e7423b41d3a6bfc356cf8f2f9 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Thu, 18 Jun 2026 17:37:04 +0200 Subject: [PATCH] Major changes: logic add to panel, local variables, panel histor, users management... --- cmd/uopi/main.go | 23 +- debug_api.go | 32 - internal/access/access.go | 150 ++++ internal/api/api.go | 572 +++++++++++- internal/api/api_test.go | 12 +- internal/config/config.go | 44 +- internal/datasource/epics/epics.go | 5 + internal/datasource/epics/noop.go | 87 +- internal/datasource/synthetic/definition.go | 10 + internal/datasource/synthetic/dsp_bridge.go | 3 + internal/datasource/synthetic/synthetic.go | 16 + internal/datasource/user.go | 27 + internal/dsp/nodes.go | 28 + internal/panelacl/panelacl.go | 417 +++++++++ internal/panelacl/panelacl_test.go | 109 +++ internal/server/server.go | 52 +- internal/server/ws.go | 31 +- internal/storage/store.go | 245 +++++- internal/storage/store_test.go | 22 +- uopi.example.toml | 21 + web/src/App.tsx | 51 +- web/src/Canvas.tsx | 59 +- web/src/ContextMenu.tsx | 9 +- web/src/EditCanvas.tsx | 5 +- web/src/EditMode.tsx | 623 ++++++++++--- web/src/InterfaceList.tsx | 176 +++- web/src/LogicEditor.tsx | 807 +++++++++++++++++ web/src/PlotPanel.tsx | 424 --------- web/src/PlotPanelCanvas.tsx | 107 +++ web/src/PropertiesPane.tsx | 79 +- web/src/SearchableSelect.tsx | 58 ++ web/src/ShareDialog.tsx | 181 ++++ web/src/SignalTree.tsx | 139 ++- web/src/SplitLayout.tsx | 64 ++ web/src/SyntheticEditor.tsx | 1 + web/src/SyntheticWizard.tsx | 81 +- web/src/ViewMode.tsx | 97 +- web/src/lib/auth.ts | 43 + web/src/lib/expr.ts | 268 ++++++ web/src/lib/fft.ts | 89 ++ web/src/lib/localstate.ts | 82 ++ web/src/lib/logic.ts | 375 ++++++++ web/src/lib/plotLayout.ts | 72 ++ web/src/lib/stores.ts | 6 + web/src/lib/templates.ts | 31 + web/src/lib/types.ts | 174 ++++ web/src/lib/ws.ts | 6 + web/src/lib/xml.ts | 147 +++- web/src/styles.css | 926 ++++++++++++++------ web/src/widgets/Button.tsx | 49 +- web/src/widgets/PlotWidget.tsx | 101 ++- web/src/widgets/SetValue.tsx | 22 +- workspace/data/acl.json | 10 + workspace/run.sh | 2 +- 54 files changed, 6104 insertions(+), 1166 deletions(-) delete mode 100644 debug_api.go create mode 100644 internal/access/access.go create mode 100644 internal/datasource/user.go create mode 100644 internal/panelacl/panelacl.go create mode 100644 internal/panelacl/panelacl_test.go create mode 100644 web/src/LogicEditor.tsx delete mode 100644 web/src/PlotPanel.tsx create mode 100644 web/src/PlotPanelCanvas.tsx create mode 100644 web/src/SearchableSelect.tsx create mode 100644 web/src/ShareDialog.tsx create mode 100644 web/src/SplitLayout.tsx create mode 100644 web/src/lib/auth.ts create mode 100644 web/src/lib/expr.ts create mode 100644 web/src/lib/fft.ts create mode 100644 web/src/lib/localstate.ts create mode 100644 web/src/lib/logic.ts create mode 100644 web/src/lib/plotLayout.ts create mode 100644 web/src/lib/templates.ts create mode 100644 workspace/data/acl.json diff --git a/cmd/uopi/main.go b/cmd/uopi/main.go index 78469b5..b498a93 100644 --- a/cmd/uopi/main.go +++ b/cmd/uopi/main.go @@ -11,12 +11,14 @@ import ( "os/signal" "syscall" + "github.com/uopi/uopi/internal/access" "github.com/uopi/uopi/internal/broker" "github.com/uopi/uopi/internal/config" "github.com/uopi/uopi/internal/datasource/epics" "github.com/uopi/uopi/internal/datasource/pva" "github.com/uopi/uopi/internal/datasource/stub" "github.com/uopi/uopi/internal/datasource/synthetic" + "github.com/uopi/uopi/internal/panelacl" "github.com/uopi/uopi/internal/server" "github.com/uopi/uopi/internal/storage" "github.com/uopi/uopi/web" @@ -49,6 +51,12 @@ func main() { os.Exit(1) } + aclStore, err := panelacl.New(cfg.Server.StorageDir) + if err != nil { + log.Error("failed to open panel ACL store", "err", err) + os.Exit(1) + } + webFS, err := fs.Sub(web.FS, "dist") if err != nil { log.Error("failed to sub web dist", "err", err) @@ -113,7 +121,20 @@ func main() { } } - srv := server.New(cfg.Server.Listen, webFS, brk, synthDS, store, cfg.Datasource.EPICS.ChannelFinderURL, cfg.Datasource.EPICS.ArchiveURL, log) + // 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. + blacklist := make(map[string]string, len(cfg.Server.Blacklist)) + for _, e := range cfg.Server.Blacklist { + blacklist[e.User] = e.Level + } + groups := make(map[string][]string, len(cfg.Groups)) + for _, g := range cfg.Groups { + groups[g.Name] = g.Members + } + policy := access.New(cfg.Server.DefaultUser, blacklist, groups) + + srv := server.New(cfg.Server.Listen, webFS, brk, synthDS, store, policy, aclStore, 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/debug_api.go b/debug_api.go deleted file mode 100644 index 3d50ac1..0000000 --- a/debug_api.go +++ /dev/null @@ -1,32 +0,0 @@ -package main - -import ( - "context" - "fmt" - "log/slog" - "net/http" - "net/http/httptest" - "os" - - "github.com/uopi/uopi/internal/api" - "github.com/uopi/uopi/internal/broker" - "github.com/uopi/uopi/internal/storage" -) - -func main() { - log := slog.New(slog.NewTextHandler(os.Stderr, nil)) - store, _ := storage.New("./interfaces") - // broker.New(ctx, log, maxRate) - brk := broker.New(context.Background(), log, 0) - - h := api.New(brk, nil, store, "", "", log) - mux := http.NewServeMux() - h.Register(mux, "/api/v1") - - req := httptest.NewRequest("GET", "/api/v1/interfaces", nil) - rr := httptest.NewRecorder() - mux.ServeHTTP(rr, req) - - fmt.Printf("Status: %d\n", rr.Code) - fmt.Printf("Body: %s\n", rr.Body.String()) -} diff --git a/internal/access/access.go b/internal/access/access.go new file mode 100644 index 0000000..17ce3c1 --- /dev/null +++ b/internal/access/access.go @@ -0,0 +1,150 @@ +// Package access implements uopi's global user-access policy: every user is +// trusted (full write) by default, while a configured blacklist can downgrade +// specific users to read-only or no access. It also resolves the per-request +// user identity and the user→group memberships defined in config. +// +// This is the foundation layer (Phase 1). Per-panel ownership/ACL evaluation is +// layered on top of this in a later phase. +package access + +import ( + "context" + "sort" + "strings" +) + +// Level is a global access level. Higher levels include lower ones. +type Level int + +const ( + // LevelNone denies all access. + LevelNone Level = iota + // LevelRead permits reads only (no create/update/delete/share, no signal writes). + LevelRead + // LevelWrite permits full access. This is the default for any user not blacklisted. + LevelWrite +) + +// String renders the level using the same tokens accepted by ParseLevel and +// surfaced to the frontend via /api/v1/me. +func (l Level) String() string { + switch l { + case LevelNone: + return "none" + case LevelRead: + return "readonly" + default: + return "write" + } +} + +// ParseLevel maps a config string to a Level. Unknown values restrict to +// read-only, since the only reason to list a user is to limit them. +func ParseLevel(s string) Level { + switch strings.ToLower(strings.TrimSpace(s)) { + case "noaccess", "none", "no": + return LevelNone + case "readonly", "read", "ro": + return LevelRead + case "write", "readwrite", "rw", "full": + return LevelWrite + default: + return LevelRead + } +} + +// Policy holds the resolved global access configuration. It is immutable after +// construction and safe for concurrent use. +type Policy struct { + defaultUser string + blacklist map[string]Level // user → downgraded level + userGroups map[string][]string // user → groups they belong to + groupNames []string // all configured group names (sorted) +} + +// New builds a Policy. blacklist maps a username to a config level string; +// groups maps a group name to its member usernames. +func New(defaultUser string, blacklist map[string]string, groups map[string][]string) *Policy { + p := &Policy{ + defaultUser: strings.TrimSpace(defaultUser), + blacklist: make(map[string]Level), + userGroups: make(map[string][]string), + } + for user, lvl := range blacklist { + u := strings.TrimSpace(user) + if u == "" { + continue + } + p.blacklist[u] = ParseLevel(lvl) + } + for g, members := range groups { + g = strings.TrimSpace(g) + if g == "" { + continue + } + p.groupNames = append(p.groupNames, g) + for _, m := range members { + m = strings.TrimSpace(m) + if m == "" { + continue + } + p.userGroups[m] = append(p.userGroups[m], g) + } + } + sort.Strings(p.groupNames) + return p +} + +// GroupNames returns a copy of every configured user-group name, sorted. +func (p *Policy) GroupNames() []string { + out := make([]string, len(p.groupNames)) + copy(out, p.groupNames) + return out +} + +// ResolveUser trims the proxy-provided header value and falls back to the +// configured default_user when it is empty (e.g. unproxied/dev deployments). +func (p *Policy) ResolveUser(headerValue string) string { + u := strings.TrimSpace(headerValue) + if u == "" { + return p.defaultUser + } + return u +} + +// Level returns the global access level for a user. Users that are neither +// blacklisted nor anonymous get full write access. +func (p *Policy) Level(user string) Level { + user = strings.TrimSpace(user) + if user == "" { + // No identity at all (no proxy header, no default_user): trusted LAN. + return LevelWrite + } + if lvl, ok := p.blacklist[user]; ok { + return lvl + } + return LevelWrite +} + +// GroupsOf returns a copy of the groups a user belongs to. +func (p *Policy) GroupsOf(user string) []string { + src := p.userGroups[strings.TrimSpace(user)] + out := make([]string, len(src)) + copy(out, src) + return out +} + +// ── request-scoped user identity ──────────────────────────────────────────── + +type ctxKey struct{} + +// WithUser returns a copy of ctx carrying the resolved end-user identity. +func WithUser(ctx context.Context, user string) context.Context { + return context.WithValue(ctx, ctxKey{}, user) +} + +// UserFrom returns the identity stored by WithUser, or "" if none. +func UserFrom(ctx context.Context) string { + u, _ := ctx.Value(ctxKey{}).(string) + return u +} diff --git a/internal/api/api.go b/internal/api/api.go index cfd517f..e8a4dae 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -2,17 +2,22 @@ package api import ( + "crypto/rand" + "encoding/hex" "encoding/json" "errors" "io" "log/slog" "net/http" "net/url" + "strconv" "strings" + "github.com/uopi/uopi/internal/access" "github.com/uopi/uopi/internal/broker" "github.com/uopi/uopi/internal/datasource" "github.com/uopi/uopi/internal/datasource/synthetic" + "github.com/uopi/uopi/internal/panelacl" "github.com/uopi/uopi/internal/storage" ) @@ -21,17 +26,21 @@ type Handler struct { broker *broker.Broker synthetic *synthetic.Synthetic // nil if not enabled store *storage.Store + policy *access.Policy + acl *panelacl.Store 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, channelFinderURL, archiverURL string, log *slog.Logger) *Handler { +func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, policy *access.Policy, acl *panelacl.Store, channelFinderURL, archiverURL string, log *slog.Logger) *Handler { return &Handler{ broker: b, synthetic: synth, store: store, + policy: policy, + acl: acl, channelFinderURL: channelFinderURL, archiverURL: archiverURL, log: log, @@ -41,6 +50,7 @@ func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, cha // Register mounts all API routes onto mux under the given prefix. // 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+"/datasources", h.listDataSources) mux.HandleFunc("GET "+prefix+"/signals", h.listSignals) mux.HandleFunc("GET "+prefix+"/signals/search", h.searchSignals) @@ -48,10 +58,26 @@ func (h *Handler) Register(mux *http.ServeMux, prefix string) { mux.HandleFunc("GET "+prefix+"/archiver/search", h.archiverSearch) mux.HandleFunc("GET "+prefix+"/interfaces", h.listInterfaces) mux.HandleFunc("POST "+prefix+"/interfaces", h.createInterface) + mux.HandleFunc("POST "+prefix+"/interfaces/reorder", h.reorderInterfaces) mux.HandleFunc("GET "+prefix+"/interfaces/{id}", h.getInterface) mux.HandleFunc("PUT "+prefix+"/interfaces/{id}", h.updateInterface) mux.HandleFunc("DELETE "+prefix+"/interfaces/{id}", h.deleteInterface) mux.HandleFunc("POST "+prefix+"/interfaces/{id}/clone", h.cloneInterface) + mux.HandleFunc("GET "+prefix+"/interfaces/{id}/versions", h.listVersions) + mux.HandleFunc("GET "+prefix+"/interfaces/{id}/versions/{version}", h.getVersion) + mux.HandleFunc("PUT "+prefix+"/interfaces/{id}/versions/{version}/tag", h.setVersionTag) + mux.HandleFunc("POST "+prefix+"/interfaces/{id}/versions/{version}/promote", h.promoteVersion) + mux.HandleFunc("POST "+prefix+"/interfaces/{id}/versions/{version}/fork", h.forkVersion) + // Per-panel ownership / sharing (Phase 2) + mux.HandleFunc("GET "+prefix+"/interfaces/{id}/acl", h.getPanelACL) + mux.HandleFunc("PUT "+prefix+"/interfaces/{id}/acl", h.putPanelACL) + // Panel folders / hierarchy (Phase 3) + mux.HandleFunc("GET "+prefix+"/folders", h.listFolders) + mux.HandleFunc("POST "+prefix+"/folders", h.createFolder) + mux.HandleFunc("PUT "+prefix+"/folders/{id}", h.updateFolder) + mux.HandleFunc("DELETE "+prefix+"/folders/{id}", h.deleteFolder) + // User groups defined in config (read-only; for the sharing UI) + mux.HandleFunc("GET "+prefix+"/usergroups", h.listUserGroups) // Signal group tree mux.HandleFunc("GET "+prefix+"/groups", h.getGroups) mux.HandleFunc("PUT "+prefix+"/groups", h.putGroups) @@ -63,6 +89,67 @@ func (h *Handler) Register(mux *http.ServeMux, prefix string) { mux.HandleFunc("DELETE "+prefix+"/synthetic/{name}", h.deleteSynthetic) } +// ── /me ───────────────────────────────────────────────────────────────────── + +// getMe reports the caller's resolved identity, global access level, and group +// memberships. The frontend uses this to tailor the UI (hide edit affordances +// for read-only users, block no-access users). Always reachable, even for +// no-access users, so the UI can render an appropriate blocked state. +func (h *Handler) getMe(w http.ResponseWriter, r *http.Request) { + user := access.UserFrom(r.Context()) + groups := h.policy.GroupsOf(user) + if groups == nil { + groups = []string{} + } + jsonOK(w, map[string]any{ + "user": user, + "level": h.policy.Level(user).String(), + "groups": groups, + }) +} + +// ── 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()) } + +// 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. +func (h *Handler) capByGlobal(user string, p panelacl.Perm) panelacl.Perm { + switch h.policy.Level(user) { + case access.LevelNone: + return panelacl.PermNone + case access.LevelRead: + if p > panelacl.PermRead { + return panelacl.PermRead + } + } + return p +} + +// panelPerm returns the caller's effective permission on a panel. A request +// with no resolved identity (no proxy header and no default_user) is a trusted +// LAN deployment with no per-user enforcement, so it gets full write. +func (h *Handler) panelPerm(r *http.Request, id string) panelacl.Perm { + user := caller(r) + if user == "" { + return panelacl.PermWrite + } + return h.capByGlobal(user, h.acl.PanelPerm(id, user, h.policy.GroupsOf(user))) +} + +// folderPerm returns the caller's effective permission on a folder. As with +// panelPerm, an unidentified caller is trusted with full write. +func (h *Handler) folderPerm(r *http.Request, id string) panelacl.Perm { + user := caller(r) + if user == "" { + return panelacl.PermWrite + } + return h.capByGlobal(user, h.acl.FolderPerm(id, user, h.policy.GroupsOf(user))) +} + // ── /datasources ────────────────────────────────────────────────────────────── type dsInfo struct { @@ -101,6 +188,22 @@ func (h *Handler) listSignals(w http.ResponseWriter, r *http.Request) { return } + // Synthetic signals carry per-caller visibility rules, so build their list + // from the definitions (filtered) rather than the generic metadata path. + if dsName == "synthetic" && h.synthetic != nil { + user := caller(r) + panel := r.URL.Query().Get("panel") + metas := h.synthetic.FilteredMetadata(func(d synthetic.SignalDef) bool { + return synVisible(d, user, panel) + }) + out := make([]signalInfo, len(metas)) + for i, m := range metas { + out[i] = metaToSignalInfo(m) + } + jsonOK(w, out) + return + } + ds, ok := h.broker.Source(dsName) if !ok { jsonError(w, http.StatusNotFound, "unknown data source: "+dsName) @@ -121,6 +224,20 @@ func (h *Handler) listSignals(w http.ResponseWriter, r *http.Request) { jsonOK(w, out) } +// synVisible reports whether a synthetic signal should be listed for the given +// caller while editing the given panel. An empty Visibility is treated as +// "global" so legacy definitions remain visible everywhere. +func synVisible(d synthetic.SignalDef, user, panel string) bool { + switch d.Visibility { + case "user": + return user != "" && d.Owner == user + case "panel": + return panel != "" && d.Panel == panel + default: // "global" or legacy empty + return true + } +} + // ── /signals/search ─────────────────────────────────────────────────────────── func (h *Handler) searchSignals(w http.ResponseWriter, r *http.Request) { @@ -139,11 +256,22 @@ func (h *Handler) searchSignals(w http.ResponseWriter, r *http.Request) { sources = h.broker.DataSources() } + user := caller(r) + panel := r.URL.Query().Get("panel") + var out []signalInfo for _, ds := range sources { - metas, err := ds.ListSignals(r.Context()) - if err != nil { - continue + var metas []datasource.Metadata + if ds.Name() == "synthetic" && h.synthetic != nil { + metas = h.synthetic.FilteredMetadata(func(d synthetic.SignalDef) bool { + return synVisible(d, user, panel) + }) + } else { + var err error + metas, err = ds.ListSignals(r.Context()) + if err != nil { + continue + } } for _, m := range metas { if q == "" || strings.Contains(strings.ToLower(m.Name), q) || strings.Contains(strings.ToLower(m.Description), q) { @@ -163,21 +291,27 @@ func (h *Handler) searchSignals(w http.ResponseWriter, r *http.Request) { // channelFinder proxies a query to the EPICS Channel Finder service when one // is configured, returning a flat list of PV names. Returns 501 otherwise. func (h *Handler) channelFinder(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query().Get("q") + + // Empty-query availability probe (used by the signal tree to decide whether + // to surface Channel Finder UI). Always respond 200 so an unconfigured CFS + // is not logged as a failed request in the browser console. + if q == "" && (r.URL.RawQuery == "" || r.URL.RawQuery == "q=") { + jsonOK(w, map[string]bool{"available": h.channelFinderURL != ""}) + return + } + if h.channelFinderURL == "" { jsonError(w, http.StatusNotImplemented, "Channel Finder not configured (set channel_finder_url in config)") return } - q := r.URL.Query().Get("q") cfURL := strings.TrimRight(h.channelFinderURL, "/") + "/resources/channels" if q != "" { cfURL += "?~name=*" + q + "*" - } else if r.URL.RawQuery != "" && r.URL.RawQuery != "q=" { - cfURL += "?" + r.URL.RawQuery } else { - // Empty query check: return empty results to signal CFS is configured. - jsonOK(w, []struct{}{}) - return + // Structured query (e.g. ~name=, ~tag=, property filters) passed through. + cfURL += "?" + r.URL.RawQuery } resp, err := http.Get(cfURL) //nolint:noctx @@ -281,6 +415,16 @@ func (h *Handler) putGroups(w http.ResponseWriter, r *http.Request) { // ── /interfaces ─────────────────────────────────────────────────────────────── +// interfaceListItem enriches the stored metadata with the panel's owner, +// containing folder, and the caller's effective permission, so the frontend can +// render sharing affordances and filter the visible set. +type interfaceListItem struct { + storage.InterfaceMeta + Owner string `json:"owner,omitempty"` + Folder string `json:"folder,omitempty"` + Perm string `json:"perm"` +} + func (h *Handler) listInterfaces(w http.ResponseWriter, r *http.Request) { list, err := h.store.List() if err != nil { @@ -288,7 +432,21 @@ func (h *Handler) listInterfaces(w http.ResponseWriter, r *http.Request) { jsonError(w, http.StatusInternalServerError, err.Error()) return } - jsonOK(w, list) + out := make([]interfaceListItem, 0, len(list)) + for _, m := range list { + perm := h.panelPerm(r, m.ID) + if perm < panelacl.PermRead { + continue // hide panels the caller cannot see + } + item := interfaceListItem{InterfaceMeta: m, Perm: perm.String()} + if acl := h.acl.GetPanel(m.ID); acl != nil { + item.Owner = acl.Owner + item.Folder = acl.Folder + } + out = append(out, item) + } + h.log.Info("list interfaces", "count", len(out)) + jsonOK(w, out) } func (h *Handler) createInterface(w http.ResponseWriter, r *http.Request) { @@ -297,12 +455,20 @@ func (h *Handler) createInterface(w http.ResponseWriter, r *http.Request) { jsonError(w, http.StatusBadRequest, "read body: "+err.Error()) return } - id, err := h.store.Create(body) + id, err := h.store.Create(body, r.URL.Query().Get("tag")) if err != nil { h.log.Error("create interface", "err", err) jsonError(w, http.StatusBadRequest, err.Error()) return } + // Record ownership so the new panel defaults to private (owner-only). Skip + // for unidentified callers (trusted LAN): an owner-less record would make the + // panel invisible to everyone. + if user := caller(r); user != "" { + if err := h.acl.CreatePanel(id, user); err != nil { + h.log.Error("record panel ownership", "id", id, "err", err) + } + } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) _ = json.NewEncoder(w).Encode(map[string]string{"id": id}) @@ -310,6 +476,10 @@ func (h *Handler) createInterface(w http.ResponseWriter, r *http.Request) { func (h *Handler) getInterface(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") + if h.panelPerm(r, id) < panelacl.PermRead { + jsonError(w, http.StatusNotFound, "interface not found: "+id) + return + } data, err := h.store.Get(id) if err != nil { if errors.Is(err, storage.ErrNotFound) { @@ -325,12 +495,16 @@ func (h *Handler) getInterface(w http.ResponseWriter, r *http.Request) { func (h *Handler) updateInterface(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") + if h.panelPerm(r, id) < panelacl.PermWrite { + jsonError(w, http.StatusForbidden, "you do not have write access to this panel") + return + } body, err := io.ReadAll(io.LimitReader(r.Body, 4<<20)) if err != nil { jsonError(w, http.StatusBadRequest, "read body: "+err.Error()) return } - if err := h.store.Update(id, body); err != nil { + if err := h.store.Update(id, body, r.URL.Query().Get("tag")); err != nil { if errors.Is(err, storage.ErrNotFound) { jsonError(w, http.StatusNotFound, "interface not found: "+id) } else { @@ -342,8 +516,133 @@ func (h *Handler) updateInterface(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } +func (h *Handler) listVersions(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if h.panelPerm(r, id) < panelacl.PermRead { + jsonError(w, http.StatusNotFound, "interface not found: "+id) + return + } + versions, err := h.store.Versions(id) + if err != nil { + if errors.Is(err, storage.ErrNotFound) { + jsonError(w, http.StatusNotFound, "interface not found: "+id) + } else { + h.log.Error("list versions", "id", id, "err", err) + jsonError(w, http.StatusInternalServerError, err.Error()) + } + return + } + jsonOK(w, versions) +} + +func (h *Handler) getVersion(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if h.panelPerm(r, id) < panelacl.PermRead { + jsonError(w, http.StatusNotFound, "interface not found: "+id) + return + } + version, err := strconv.Atoi(r.PathValue("version")) + if err != nil { + jsonError(w, http.StatusBadRequest, "invalid version: "+r.PathValue("version")) + return + } + data, err := h.store.GetVersion(id, version) + if err != nil { + if errors.Is(err, storage.ErrNotFound) { + jsonError(w, http.StatusNotFound, "version not found") + } else { + jsonError(w, http.StatusInternalServerError, err.Error()) + } + return + } + w.Header().Set("Content-Type", "application/xml") + _, _ = w.Write(data) +} + +func (h *Handler) setVersionTag(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if h.panelPerm(r, id) < panelacl.PermWrite { + jsonError(w, http.StatusForbidden, "you do not have write access to this panel") + return + } + version, err := strconv.Atoi(r.PathValue("version")) + if err != nil { + jsonError(w, http.StatusBadRequest, "invalid version: "+r.PathValue("version")) + return + } + if err := h.store.SetVersionTag(id, version, r.URL.Query().Get("tag")); err != nil { + if errors.Is(err, storage.ErrNotFound) { + jsonError(w, http.StatusNotFound, "version not found") + } else { + h.log.Error("set version tag", "id", id, "version", version, "err", err) + jsonError(w, http.StatusInternalServerError, err.Error()) + } + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (h *Handler) promoteVersion(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if h.panelPerm(r, id) < panelacl.PermWrite { + jsonError(w, http.StatusForbidden, "you do not have write access to this panel") + return + } + version, err := strconv.Atoi(r.PathValue("version")) + if err != nil { + jsonError(w, http.StatusBadRequest, "invalid version: "+r.PathValue("version")) + return + } + if err := h.store.Promote(id, version); err != nil { + if errors.Is(err, storage.ErrNotFound) { + jsonError(w, http.StatusNotFound, "version not found") + } else { + h.log.Error("promote version", "id", id, "version", version, "err", err) + jsonError(w, http.StatusInternalServerError, err.Error()) + } + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (h *Handler) forkVersion(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if h.panelPerm(r, id) < panelacl.PermRead { + jsonError(w, http.StatusNotFound, "interface not found: "+id) + return + } + version, err := strconv.Atoi(r.PathValue("version")) + if err != nil { + jsonError(w, http.StatusBadRequest, "invalid version: "+r.PathValue("version")) + return + } + newID, err := h.store.Fork(id, version) + if err != nil { + if errors.Is(err, storage.ErrNotFound) { + jsonError(w, http.StatusNotFound, "version not found") + } else { + h.log.Error("fork version", "id", id, "version", version, "err", err) + jsonError(w, http.StatusInternalServerError, err.Error()) + } + return + } + // The fork is a brand-new panel owned by the caller. + if user := caller(r); user != "" { + if err := h.acl.CreatePanel(newID, user); err != nil { + h.log.Error("record fork ownership", "id", newID, "err", err) + } + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(map[string]string{"id": newID}) +} + func (h *Handler) deleteInterface(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") + if h.panelPerm(r, id) < panelacl.PermWrite { + jsonError(w, http.StatusForbidden, "you do not have write access to this panel") + return + } if err := h.store.Delete(id); err != nil { if errors.Is(err, storage.ErrNotFound) { jsonError(w, http.StatusNotFound, "interface not found: "+id) @@ -352,11 +651,18 @@ func (h *Handler) deleteInterface(w http.ResponseWriter, r *http.Request) { } return } + if err := h.acl.DeletePanel(id); err != nil { + h.log.Error("delete panel ACL", "id", id, "err", err) + } w.WriteHeader(http.StatusNoContent) } func (h *Handler) cloneInterface(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") + if h.panelPerm(r, id) < panelacl.PermRead { + jsonError(w, http.StatusNotFound, "interface not found: "+id) + return + } newID, err := h.store.Clone(id) if err != nil { if errors.Is(err, storage.ErrNotFound) { @@ -367,11 +673,242 @@ func (h *Handler) cloneInterface(w http.ResponseWriter, r *http.Request) { } return } + // The clone is a brand-new panel owned by the caller. + if user := caller(r); user != "" { + if err := h.acl.CreatePanel(newID, user); err != nil { + h.log.Error("record clone ownership", "id", newID, "err", err) + } + } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) _ = json.NewEncoder(w).Encode(map[string]string{"id": newID}) } +// ── /interfaces/{id}/acl (Phase 2) ────────────────────────────────────────── + +// panelACLRequest is the body accepted by putPanelACL: the panel's folder, +// public level, and explicit user/group grants. Owner is immutable here. +type panelACLRequest struct { + Folder string `json:"folder"` + Public string `json:"public"` + Grants []panelacl.Grant `json:"grants"` +} + +// panelACLResponse is the sharing record returned to the frontend, enriched +// with the caller's own effective permission. +type panelACLResponse struct { + Owner string `json:"owner"` + Folder string `json:"folder"` + Public string `json:"public"` + Grants []panelacl.Grant `json:"grants"` + Perm string `json:"perm"` +} + +// getPanelACL returns the sharing settings for a panel. Any user who can see +// the panel (read) may inspect them. +func (h *Handler) getPanelACL(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + perm := h.panelPerm(r, id) + if perm < panelacl.PermRead { + jsonError(w, http.StatusNotFound, "interface not found: "+id) + return + } + resp := panelACLResponse{Grants: []panelacl.Grant{}, Perm: perm.String()} + if acl := h.acl.GetPanel(id); acl != nil { + resp.Owner = acl.Owner + resp.Folder = acl.Folder + resp.Public = acl.Public + if acl.Grants != nil { + resp.Grants = acl.Grants + } + } + jsonOK(w, resp) +} + +// putPanelACL replaces a panel's sharing settings. Only the panel owner may do +// this (an unmanaged legacy panel is adopted by the first writer to set an ACL). +func (h *Handler) putPanelACL(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + user := caller(r) + // The panel must exist in storage. + if _, err := h.store.Get(id); err != nil { + if errors.Is(err, storage.ErrNotFound) { + jsonError(w, http.StatusNotFound, "interface not found: "+id) + } else { + jsonError(w, http.StatusInternalServerError, err.Error()) + } + return + } + // Read-only / no-access users can never change sharing. + if h.policy.Level(user) < access.LevelWrite { + jsonError(w, http.StatusForbidden, "you do not have write access") + return + } + owner := user + if acl := h.acl.GetPanel(id); acl != nil && acl.Owner != "" { + if acl.Owner != user { + jsonError(w, http.StatusForbidden, "only the owner can change sharing") + return + } + owner = acl.Owner + } + var req panelACLRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return + } + // A referenced folder must exist. + if req.Folder != "" { + if _, err := h.acl.GetFolder(req.Folder); err != nil { + jsonError(w, http.StatusBadRequest, "unknown folder: "+req.Folder) + return + } + } + if err := h.acl.SetPanel(id, owner, req.Folder, req.Public, req.Grants); err != nil { + h.log.Error("set panel ACL", "id", id, "err", err) + jsonError(w, http.StatusInternalServerError, err.Error()) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// ── /folders (Phase 3) ────────────────────────────────────────────────────── + +// folderItem is a folder enriched with the caller's effective permission. +type folderItem struct { + panelacl.Folder + Perm string `json:"perm"` +} + +// folderRequest is the body accepted by create/updateFolder. +type folderRequest struct { + Name string `json:"name"` + Parent string `json:"parent"` + Public string `json:"public"` + Grants []panelacl.Grant `json:"grants"` +} + +// listFolders returns every folder the caller can at least read. +func (h *Handler) listFolders(w http.ResponseWriter, r *http.Request) { + folders := h.acl.Folders() + out := make([]folderItem, 0, len(folders)) + for id, f := range folders { + perm := h.folderPerm(r, id) + if perm < panelacl.PermRead { + continue + } + if f.Grants == nil { + f.Grants = []panelacl.Grant{} + } + out = append(out, folderItem{Folder: f, Perm: perm.String()}) + } + jsonOK(w, out) +} + +// createFolder creates a new folder owned by the caller. Creating a subfolder +// requires write access on the parent. +func (h *Handler) createFolder(w http.ResponseWriter, r *http.Request) { + user := caller(r) + if h.policy.Level(user) < access.LevelWrite { + jsonError(w, http.StatusForbidden, "you do not have write access") + return + } + var req folderRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return + } + if strings.TrimSpace(req.Name) == "" { + jsonError(w, http.StatusBadRequest, "folder name is required") + return + } + if req.Parent != "" && h.folderPerm(r, req.Parent) < panelacl.PermWrite { + jsonError(w, http.StatusForbidden, "you do not have write access to the parent folder") + return + } + f, err := h.acl.CreateFolder(genID("fld"), req.Name, req.Parent, user) + if err != nil { + if errors.Is(err, panelacl.ErrNotFound) { + jsonError(w, http.StatusBadRequest, "unknown parent folder: "+req.Parent) + } else { + h.log.Error("create folder", "err", err) + jsonError(w, http.StatusInternalServerError, err.Error()) + } + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(f) +} + +// updateFolder replaces a folder's mutable fields. Requires write access on the +// folder (owners always have it). +func (h *Handler) updateFolder(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if h.folderPerm(r, id) < panelacl.PermWrite { + jsonError(w, http.StatusForbidden, "you do not have write access to this folder") + return + } + var req folderRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return + } + if strings.TrimSpace(req.Name) == "" { + jsonError(w, http.StatusBadRequest, "folder name is required") + return + } + if err := h.acl.UpdateFolder(id, req.Name, req.Parent, req.Public, req.Grants); err != nil { + switch { + case errors.Is(err, panelacl.ErrNotFound): + jsonError(w, http.StatusNotFound, "folder not found: "+id) + default: + jsonError(w, http.StatusBadRequest, err.Error()) + } + return + } + w.WriteHeader(http.StatusNoContent) +} + +// deleteFolder removes a folder, reparenting its contents. Requires write +// access on the folder. +func (h *Handler) deleteFolder(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if h.folderPerm(r, id) < panelacl.PermWrite { + jsonError(w, http.StatusForbidden, "you do not have write access to this folder") + return + } + if err := h.acl.DeleteFolder(id); err != nil { + if errors.Is(err, panelacl.ErrNotFound) { + jsonError(w, http.StatusNotFound, "folder not found: "+id) + } else { + h.log.Error("delete folder", "id", id, "err", err) + jsonError(w, http.StatusInternalServerError, err.Error()) + } + return + } + w.WriteHeader(http.StatusNoContent) +} + +// ── /usergroups ───────────────────────────────────────────────────────────── + +// listUserGroups returns the names of the user-groups defined in config, for +// populating the sharing UI's group picker. +func (h *Handler) listUserGroups(w http.ResponseWriter, _ *http.Request) { + names := h.policy.GroupNames() + if names == nil { + names = []string{} + } + jsonOK(w, names) +} + +// genID returns a short unique id with the given prefix (e.g. "fld-1a2b3c4d"). +func genID(prefix string) string { + var b [8]byte + _, _ = rand.Read(b[:]) + return prefix + "-" + hex.EncodeToString(b[:]) +} + // ── /synthetic ──────────────────────────────────────────────────────────────── func (h *Handler) listSynthetic(w http.ResponseWriter, _ *http.Request) { @@ -392,6 +929,11 @@ func (h *Handler) createSynthetic(w http.ResponseWriter, r *http.Request) { jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) return } + // Stamp ownership from the trusted identity; clients cannot spoof it. + def.Owner = caller(r) + if def.Visibility == "" { + def.Visibility = "panel" + } if err := h.synthetic.AddSignal(def); err != nil { if errors.Is(err, datasource.ErrNotFound) { jsonError(w, http.StatusConflict, err.Error()) @@ -435,6 +977,10 @@ func (h *Handler) updateSynthetic(w http.ResponseWriter, r *http.Request) { } // Ensure the name in the URL matches the body (or fill it in). def.Name = name + // Preserve the original owner across edits; never trust a client-supplied one. + if existing, err := h.synthetic.GetSignal(name); err == nil { + def.Owner = existing.Owner + } if err := h.synthetic.UpdateSignal(def); err != nil { if errors.Is(err, datasource.ErrNotFound) { jsonError(w, http.StatusNotFound, "synthetic signal not found: "+name) diff --git a/internal/api/api_test.go b/internal/api/api_test.go index b88d5c1..41094b3 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -12,9 +12,11 @@ import ( "strings" "testing" + "github.com/uopi/uopi/internal/access" "github.com/uopi/uopi/internal/api" "github.com/uopi/uopi/internal/broker" "github.com/uopi/uopi/internal/datasource/stub" + "github.com/uopi/uopi/internal/panelacl" "github.com/uopi/uopi/internal/storage" ) @@ -38,9 +40,13 @@ func setup(t *testing.T) (*httptest.Server, func()) { if err != nil { t.Fatal("storage.New:", err) } + acl, err := panelacl.New(dir) + if err != nil { + t.Fatal("panelacl.New:", err) + } mux := http.NewServeMux() - api.New(brk, nil, store, "", "", log).Register(mux, "/api/v1") + api.New(brk, nil, store, access.New("", nil, nil), acl, "", "", log).Register(mux, "/api/v1") srv := httptest.NewServer(mux) return srv, func() { @@ -396,7 +402,7 @@ func TestStorageValidateID(t *testing.T) { } // Create a real interface first to get a valid ID. - id, err := store.Create([]byte(sampleXML)) + id, err := store.Create([]byte(sampleXML), "") if err != nil { t.Fatal("create:", err) } @@ -416,7 +422,7 @@ func TestStorageValidateID(t *testing.T) { if _, err := store.Get(bad); err == nil { t.Errorf("Get(%q) should have failed, got nil error", bad) } - if err := store.Update(bad, []byte(sampleXML)); err == nil { + if err := store.Update(bad, []byte(sampleXML), ""); err == nil { t.Errorf("Update(%q) should have failed", bad) } if err := store.Delete(bad); err == nil { diff --git a/internal/config/config.go b/internal/config/config.go index 7b18489..13a8edc 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -12,12 +12,44 @@ import ( type Config struct { Server ServerConfig `toml:"server"` Datasource DatasourceConfig `toml:"datasource"` + // Groups are named sets of users, referenced by panel sharing rules. + Groups []GroupDef `toml:"groups"` +} + +// GroupDef is a named set of users defined as [[groups]] in the config file. +type GroupDef struct { + Name string `toml:"name"` + Members []string `toml:"members"` +} + +// BlacklistEntry downgrades a specific user's global access level. Levels: +// "readonly" (no writes) or "noaccess" (denied). Users not listed are trusted +// with full access. +type BlacklistEntry struct { + User string `toml:"user"` + Level string `toml:"level"` } type ServerConfig struct { - Listen string `toml:"listen"` - StorageDir string `toml:"storage_dir"` - MaxUpdateRateHz float64 `toml:"max_update_rate_hz"` // 0 = unlimited + Listen string `toml:"listen"` + StorageDir string `toml:"storage_dir"` + MaxUpdateRateHz float64 `toml:"max_update_rate_hz"` // 0 = unlimited + + // TrustedUserHeader is the HTTP header from which the end-user identity is + // read on each WebSocket connection (set by a trusted reverse proxy doing + // authentication). The identity is used for EPICS writes so each session + // can act as a different user. Empty disables it (writes use the server + // identity). MUST only be enabled when a proxy strips any client-supplied + // value, otherwise the header can be spoofed. + TrustedUserHeader string `toml:"trusted_user_header"` + + // DefaultUser is the identity used when the trusted user header is absent or + // empty (e.g. unproxied/dev/LAN deployments). Empty leaves the user anonymous. + DefaultUser string `toml:"default_user"` + + // Blacklist downgrades specific users' global access level. Everyone not + // listed is trusted with full write access. + Blacklist []BlacklistEntry `toml:"blacklist"` } type DatasourceConfig struct { @@ -93,6 +125,12 @@ func applyEnv(cfg *Config) { cfg.Server.MaxUpdateRateHz = hz } } + if v := env("UOPI_SERVER_TRUSTED_USER_HEADER"); v != "" { + cfg.Server.TrustedUserHeader = v + } + if v := env("UOPI_SERVER_DEFAULT_USER"); v != "" { + cfg.Server.DefaultUser = v + } if v := env("UOPI_EPICS_CA_ADDR_LIST"); v != "" { cfg.Datasource.EPICS.CAAddrList = v } diff --git a/internal/datasource/epics/epics.go b/internal/datasource/epics/epics.go index e8df9a3..393b9ae 100644 --- a/internal/datasource/epics/epics.go +++ b/internal/datasource/epics/epics.go @@ -508,6 +508,11 @@ func (e *EPICS) ListSignals(_ context.Context) ([]datasource.Metadata, error) { // Write puts a new value onto a CA channel. // If the signal is not currently subscribed (e.g. a button in oneshot mode), // a temporary CA channel is created, used for the put, then torn down. +// +// NOTE: per-session end-user identity (datasource.WithUser) is NOT honoured in +// the CGo/libca build: libca uses a single process-wide CA context whose client +// name is fixed at startup. Writes therefore use the server identity here. Use +// the default pure-Go build for per-user write attribution. func (e *EPICS) Write(ctx context.Context, signal string, value any) error { e.attachCAContext() diff --git a/internal/datasource/epics/noop.go b/internal/datasource/epics/noop.go index f1b6a2d..69ebe54 100644 --- a/internal/datasource/epics/noop.go +++ b/internal/datasource/epics/noop.go @@ -39,8 +39,29 @@ type EPICS struct { mu sync.RWMutex metadata map[string]datasource.Metadata + + // Per-user CA clients for writes. EPICS Channel Access carries the client + // username at the circuit (TCP) level, so attributing a write to a specific + // end-user requires a dedicated client whose ClientName is that user. These + // are created lazily on first write by each user and evicted when idle. + ctx context.Context // datasource lifetime; governs per-user clients + baseCfg ca.Config // template config for per-user clients + selfName string // the server's own CA client name + userMu sync.Mutex + userClients map[string]*userClient } +// userClient is a per-end-user CA client and its last-use time for idle eviction. +type userClient struct { + cli *ca.Client + lastUsed time.Time +} + +// userClientIdleTTL is how long an idle per-user CA client is kept before being +// closed. Writes are bursty, so a few minutes avoids reconnecting on every put +// without holding circuits open indefinitely. +const userClientIdleTTL = 10 * time.Minute + // New creates a new EPICS Channel Access data source. // // caAddrList is the EPICS_CA_ADDR_LIST value (space-separated addresses). @@ -79,7 +100,12 @@ func (e *EPICS) Connect(ctx context.Context) error { return fmt.Errorf("epics: %w", err) } e.client = cli - slog.Info("epics: CA client started", "addrs", cfg.AddrList) + e.ctx = ctx + e.baseCfg = cfg + e.selfName = cfg.ClientName + e.userClients = make(map[string]*userClient) + go e.evictIdleUserClients(ctx) + slog.Info("epics: CA client started", "addrs", cfg.AddrList, "client_name", cfg.ClientName) // Perform background sync from Channel Finder if configured. if e.cfURL != "" && e.autoSyncFilter != "" { @@ -344,13 +370,70 @@ func (e *EPICS) ListSignals(_ context.Context) ([]datasource.Metadata, error) { // Write puts a new value onto the named CA channel. // value must be one of: float64, float32, int64, int32, int, int16, string, bool. +// +// When the request context carries an end-user identity (see datasource.WithUser) +// that differs from the server's own CA client name, the put is performed on a +// dedicated CA client whose ClientName is that user, so the IOC's access-security +// rules see the actual end-user rather than the server process. func (e *EPICS) Write(ctx context.Context, signal string, value any) error { - if err := e.client.Put(ctx, signal, value); err != nil { + cli := e.client + if user, ok := datasource.UserFrom(ctx); ok && user != e.selfName { + uc, err := e.clientForUser(user) + if err != nil { + return fmt.Errorf("epics: write %q as %q: %w", signal, user, err) + } + cli = uc + } + if err := cli.Put(ctx, signal, value); err != nil { return fmt.Errorf("epics: write %q: %w", signal, err) } return nil } +// clientForUser returns a CA client whose ClientName is user, creating and +// caching one on first use. The client is bound to the datasource lifetime ctx, +// not the per-request ctx, so it survives across writes until evicted when idle. +func (e *EPICS) clientForUser(user string) (*ca.Client, error) { + e.userMu.Lock() + defer e.userMu.Unlock() + if uc, ok := e.userClients[user]; ok { + uc.lastUsed = time.Now() + return uc.cli, nil + } + cfg := e.baseCfg + cfg.ClientName = user + cli, err := ca.NewClient(e.ctx, cfg) + if err != nil { + return nil, err + } + e.userClients[user] = &userClient{cli: cli, lastUsed: time.Now()} + slog.Info("epics: created per-user CA client", "user", user) + return cli, nil +} + +// evictIdleUserClients closes per-user CA clients that have not been used within +// userClientIdleTTL. It runs until ctx is cancelled. +func (e *EPICS) evictIdleUserClients(ctx context.Context) { + t := time.NewTicker(time.Minute) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case now := <-t.C: + e.userMu.Lock() + for user, uc := range e.userClients { + if now.Sub(uc.lastUsed) > userClientIdleTTL { + uc.cli.Close() + delete(e.userClients, user) + slog.Info("epics: evicted idle per-user CA client", "user", user) + } + } + e.userMu.Unlock() + } + } +} + // -------------------------------------------------------------------------- // // History // // -------------------------------------------------------------------------- // diff --git a/internal/datasource/synthetic/definition.go b/internal/datasource/synthetic/definition.go index 45894f4..a5cf39f 100644 --- a/internal/datasource/synthetic/definition.go +++ b/internal/datasource/synthetic/definition.go @@ -10,6 +10,16 @@ type SignalDef struct { Inputs []InputRef `json:"inputs"` // alternative multi-input format Pipeline []NodeDef `json:"pipeline"` // ordered list of DSP nodes Meta MetaOverride `json:"meta"` // optional metadata overrides + + // Visibility controls who sees this signal in the signal tree: + // "global" — listed in every panel's edit mode + // "user" — listed in every panel owned by Owner + // "panel" — listed only when editing the bound Panel + // An empty value is treated as "global" for backward compatibility with + // definitions created before this field existed. + Visibility string `json:"visibility,omitempty"` + Owner string `json:"owner,omitempty"` // creator identity (stamped server-side) + Panel string `json:"panel,omitempty"` // bound interface id for "panel" visibility } // InputRef names one upstream signal used as input to the pipeline. diff --git a/internal/datasource/synthetic/dsp_bridge.go b/internal/datasource/synthetic/dsp_bridge.go index e01ef8b..9156168 100644 --- a/internal/datasource/synthetic/dsp_bridge.go +++ b/internal/datasource/synthetic/dsp_bridge.go @@ -83,6 +83,9 @@ func buildNode(d NodeDef) (dsp.Node, error) { case "derivative": return &dsp.DerivativeNode{}, nil + case "integrate": + return &dsp.IntegrateNode{}, nil + case "clamp": return &dsp.ClampNode{ Min: floatParam(p, "min"), diff --git a/internal/datasource/synthetic/synthetic.go b/internal/datasource/synthetic/synthetic.go index 0c0f77e..901d0e6 100644 --- a/internal/datasource/synthetic/synthetic.go +++ b/internal/datasource/synthetic/synthetic.go @@ -85,6 +85,22 @@ func (s *Synthetic) ListSignals(_ context.Context) ([]datasource.Metadata, error return out, nil } +// FilteredMetadata returns metadata for every defined synthetic signal for +// which keep returns true. It lets the API layer apply per-caller visibility +// rules (which depend on SignalDef fields not present in datasource.Metadata). +func (s *Synthetic) FilteredMetadata(keep func(SignalDef) bool) []datasource.Metadata { + s.mu.RLock() + defer s.mu.RUnlock() + + out := make([]datasource.Metadata, 0, len(s.signals)) + for _, st := range s.signals { + if keep(st.def) { + out = append(out, defToMetadata(st.def)) + } + } + return out +} + // GetMetadata returns metadata for a single named signal. func (s *Synthetic) GetMetadata(_ context.Context, signal string) (datasource.Metadata, error) { s.mu.RLock() diff --git a/internal/datasource/user.go b/internal/datasource/user.go new file mode 100644 index 0000000..72159c4 --- /dev/null +++ b/internal/datasource/user.go @@ -0,0 +1,27 @@ +package datasource + +import "context" + +// userKey is the unexported context key under which the end-user identity is +// stored. Using a private type prevents collisions with other packages. +type userKey struct{} + +// WithUser returns a copy of ctx carrying the end-user identity associated with +// the request (e.g. the authenticated web client). Data sources may use this to +// attribute operations to the actual user rather than the server process — for +// example EPICS Channel Access access-security rules match on the client +// username. An empty user is treated as "no client identity". +func WithUser(ctx context.Context, user string) context.Context { + if user == "" { + return ctx + } + return context.WithValue(ctx, userKey{}, user) +} + +// UserFrom returns the end-user identity stored in ctx by WithUser. The boolean +// is false when no (non-empty) identity is present, in which case callers +// should fall back to the server's own identity. +func UserFrom(ctx context.Context) (string, bool) { + u, ok := ctx.Value(userKey{}).(string) + return u, ok && u != "" +} diff --git a/internal/dsp/nodes.go b/internal/dsp/nodes.go index 7181f86..4c22789 100644 --- a/internal/dsp/nodes.go +++ b/internal/dsp/nodes.go @@ -204,6 +204,34 @@ func (n *DerivativeNode) Process(inputs []float64, state map[string]any) (float6 return 0, nil } +// ── IntegrateNode ───────────────────────────────────────────────────────────── + +// IntegrateNode accumulates the time-integral of input[0] using the trapezoidal +// rule, where dt is in seconds. On the first call it returns 0 (no interval yet). +type IntegrateNode struct{} + +func (n *IntegrateNode) Type() string { return "integrate" } +func (n *IntegrateNode) Process(inputs []float64, state map[string]any) (float64, error) { + if len(inputs) == 0 { + return 0, errors.New("integrate: no inputs") + } + now := time.Now() + acc, _ := state["acc"].(float64) + if prevVal, ok := state["prev_val"].(float64); ok { + if prevTime, ok := state["prev_time"].(time.Time); ok { + dt := now.Sub(prevTime).Seconds() + if dt < 0 { + dt = 0 + } + acc += (inputs[0] + prevVal) * 0.5 * dt + } + } + state["acc"] = acc + state["prev_val"] = inputs[0] + state["prev_time"] = now + return acc, nil +} + // ── ClampNode ───────────────────────────────────────────────────────────────── // ClampNode clamps the output to [Min, Max]. diff --git a/internal/panelacl/panelacl.go b/internal/panelacl/panelacl.go new file mode 100644 index 0000000..bb58ef4 --- /dev/null +++ b/internal/panelacl/panelacl.go @@ -0,0 +1,417 @@ +// Package panelacl implements per-panel ownership and sharing (Phase 2) plus a +// nested folder hierarchy for organising panels (Phase 3). It persists a single +// sidecar index, {storageDir}/acl.json, alongside the XML interface files. +// +// The effective permission a user has on a panel is the maximum of: ownership +// (always write), the panel's public level, any direct user grant, any grant to +// a user-group the user belongs to, and the permissions inherited from the +// folder chain the panel lives in. Callers are expected to further cap this by +// the user's global access level (see internal/access). +// +// Panels that have no record in the index are treated as fully open +// (PermWrite). This preserves access to interfaces created before ACLs existed; +// panels created through the ACL-aware API are recorded as private to their +// owner. +package panelacl + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "sync" +) + +// ErrNotFound is returned when a folder does not exist. +var ErrNotFound = errors.New("not found") + +// Perm is an effective access level on a panel or folder. +type Perm int + +const ( + // PermNone denies access. + PermNone Perm = iota + // PermRead permits viewing only. + PermRead + // PermWrite permits viewing and modification. + PermWrite +) + +// String renders a Perm as the token used in the JSON index and HTTP API. +func (p Perm) String() string { + switch p { + case PermRead: + return "read" + case PermWrite: + return "write" + default: + return "none" + } +} + +// parsePerm maps a config/API token to a Perm. Unknown/empty → PermNone. +func parsePerm(s string) Perm { + switch s { + case "read": + return PermRead + case "write", "readwrite", "rw": + return PermWrite + default: + return PermNone + } +} + +// Grant shares a panel or folder with a single user or a named user-group. +type Grant struct { + Kind string `json:"kind"` // "user" | "group" + Name string `json:"name"` // username or user-group name + Perm string `json:"perm"` // "read" | "write" +} + +// PanelACL is the ownership + sharing record for one panel. +type PanelACL struct { + Owner string `json:"owner"` + Folder string `json:"folder,omitempty"` // folder id the panel belongs to ("" = root) + Public string `json:"public,omitempty"` // "" | "read" | "write" + Grants []Grant `json:"grants,omitempty"` + Order float64 `json:"order,omitempty"` // sort position within its folder +} + +// Folder is a node in the panel-organisation hierarchy. Permissions set on a +// folder are inherited by every panel and subfolder beneath it. +type Folder struct { + ID string `json:"id"` + Name string `json:"name"` + Parent string `json:"parent,omitempty"` // parent folder id ("" = root) + Owner string `json:"owner"` + Public string `json:"public,omitempty"` + Grants []Grant `json:"grants,omitempty"` +} + +type index struct { + Panels map[string]*PanelACL `json:"panels"` + Folders map[string]*Folder `json:"folders"` +} + +// Store persists and resolves the panel ACL index. It is safe for concurrent +// use. +type Store struct { + path string + mu sync.RWMutex + idx index +} + +// New opens (loading if present) the acl.json index in storageDir. +func New(storageDir string) (*Store, error) { + s := &Store{ + path: filepath.Join(storageDir, "acl.json"), + idx: index{Panels: map[string]*PanelACL{}, Folders: map[string]*Folder{}}, + } + data, err := os.ReadFile(s.path) + if errors.Is(err, os.ErrNotExist) { + return s, nil + } + if err != nil { + return nil, err + } + if err := json.Unmarshal(data, &s.idx); err != nil { + return nil, err + } + if s.idx.Panels == nil { + s.idx.Panels = map[string]*PanelACL{} + } + if s.idx.Folders == nil { + s.idx.Folders = map[string]*Folder{} + } + return s, nil +} + +// saveLocked atomically persists the index. The caller must hold s.mu. +func (s *Store) saveLocked() error { + data, err := json.MarshalIndent(s.idx, "", " ") + if err != nil { + return err + } + tmp := s.path + ".tmp" + if err := os.WriteFile(tmp, data, 0o644); err != nil { + return err + } + return os.Rename(tmp, s.path) +} + +// ── Panel records ──────────────────────────────────────────────────────────── + +// GetPanel returns a copy of a panel's ACL record, or nil if it is unmanaged. +func (s *Store) GetPanel(id string) *PanelACL { + s.mu.RLock() + defer s.mu.RUnlock() + acl, ok := s.idx.Panels[id] + if !ok { + return nil + } + return clonePanel(acl) +} + +// CreatePanel records ownership for a newly created panel, defaulting to +// private (owner-only) access. Existing records are left untouched. +func (s *Store) CreatePanel(id, owner string) error { + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.idx.Panels[id]; ok { + return nil + } + s.idx.Panels[id] = &PanelACL{Owner: owner} + return s.saveLocked() +} + +// SetPanel replaces a panel's sharing settings (folder, public level, grants) +// while preserving its existing owner (or adopting the supplied owner when the +// panel was previously unmanaged). +func (s *Store) SetPanel(id, owner, folder, public string, grants []Grant) error { + s.mu.Lock() + defer s.mu.Unlock() + acl := s.idx.Panels[id] + if acl == nil { + acl = &PanelACL{Owner: owner} + s.idx.Panels[id] = acl + } + acl.Folder = folder + acl.Public = public + acl.Grants = grants + return s.saveLocked() +} + +// PlacePanel sets only a panel's organizational fields — its folder and sort +// order — preserving ownership and sharing. A previously-unmanaged panel gets a +// record with no owner, which PanelPerm still treats as fully open (so dragging +// a legacy panel into a folder does not lock it). +func (s *Store) PlacePanel(id, folder string, order float64) error { + s.mu.Lock() + defer s.mu.Unlock() + acl := s.idx.Panels[id] + if acl == nil { + acl = &PanelACL{} + s.idx.Panels[id] = acl + } + acl.Folder = folder + acl.Order = order + return s.saveLocked() +} + +// DeletePanel removes a panel's ACL record (called when the panel is deleted). +func (s *Store) DeletePanel(id string) error { + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.idx.Panels[id]; !ok { + return nil + } + delete(s.idx.Panels, id) + return s.saveLocked() +} + +// ── Folders ────────────────────────────────────────────────────────────────── + +// Folders returns a copy of every folder, keyed by id. +func (s *Store) Folders() map[string]Folder { + s.mu.RLock() + defer s.mu.RUnlock() + out := make(map[string]Folder, len(s.idx.Folders)) + for id, f := range s.idx.Folders { + out[id] = *cloneFolder(f) + } + return out +} + +// GetFolder returns a copy of a folder, or ErrNotFound. +func (s *Store) GetFolder(id string) (Folder, error) { + s.mu.RLock() + defer s.mu.RUnlock() + f, ok := s.idx.Folders[id] + if !ok { + return Folder{}, ErrNotFound + } + return *cloneFolder(f), nil +} + +// CreateFolder adds a new folder owned by owner and returns it. +func (s *Store) CreateFolder(id, name, parent, owner string) (Folder, error) { + s.mu.Lock() + defer s.mu.Unlock() + if parent != "" { + if _, ok := s.idx.Folders[parent]; !ok { + return Folder{}, ErrNotFound + } + } + f := &Folder{ID: id, Name: name, Parent: parent, Owner: owner} + s.idx.Folders[id] = f + if err := s.saveLocked(); err != nil { + return Folder{}, err + } + return *cloneFolder(f), nil +} + +// UpdateFolder replaces a folder's mutable fields (name, parent, public, +// grants). It rejects parent changes that would create a cycle. +func (s *Store) UpdateFolder(id, name, parent, public string, grants []Grant) error { + s.mu.Lock() + defer s.mu.Unlock() + f, ok := s.idx.Folders[id] + if !ok { + return ErrNotFound + } + if parent != "" { + if _, ok := s.idx.Folders[parent]; !ok { + return ErrNotFound + } + if s.createsCycleLocked(id, parent) { + return errors.New("folder parent change would create a cycle") + } + } + f.Name = name + f.Parent = parent + f.Public = public + f.Grants = grants + return s.saveLocked() +} + +// DeleteFolder removes a folder, reparenting its child folders and panels to the +// deleted folder's parent (so nothing is orphaned). +func (s *Store) DeleteFolder(id string) error { + s.mu.Lock() + defer s.mu.Unlock() + f, ok := s.idx.Folders[id] + if !ok { + return ErrNotFound + } + newParent := f.Parent + for _, child := range s.idx.Folders { + if child.Parent == id { + child.Parent = newParent + } + } + for _, acl := range s.idx.Panels { + if acl.Folder == id { + acl.Folder = newParent + } + } + delete(s.idx.Folders, id) + return s.saveLocked() +} + +// createsCycleLocked reports whether setting folder id's parent to newParent +// would introduce a cycle. The caller must hold s.mu. +func (s *Store) createsCycleLocked(id, newParent string) bool { + for cur := newParent; cur != ""; { + if cur == id { + return true + } + f, ok := s.idx.Folders[cur] + if !ok { + return false + } + cur = f.Parent + } + return false +} + +// ── Permission resolution ──────────────────────────────────────────────────── + +// PanelPerm returns the effective permission user has on panel id, given the +// user-groups the user belongs to. Unmanaged panels are fully open. +func (s *Store) PanelPerm(id, user string, groups []string) Perm { + s.mu.RLock() + defer s.mu.RUnlock() + acl, ok := s.idx.Panels[id] + if !ok { + return PermWrite // legacy/unmanaged panel: fully open + } + // A record carrying only organizational metadata (folder/order) with no + // owner, public level, or grants is still open — keeps an unmanaged panel + // accessible after it has merely been moved into a folder or reordered. + if acl.Owner == "" && acl.Public == "" && len(acl.Grants) == 0 { + return PermWrite + } + best := PermNone + if user != "" && acl.Owner == user { + return PermWrite + } + best = maxPerm(best, parsePerm(acl.Public)) + best = maxPerm(best, grantsPerm(acl.Grants, user, groups)) + if acl.Folder != "" { + best = maxPerm(best, s.folderPermLocked(acl.Folder, user, groups, map[string]bool{})) + } + return best +} + +// FolderPerm returns the effective permission user has on folder id. +func (s *Store) FolderPerm(id, user string, groups []string) Perm { + s.mu.RLock() + defer s.mu.RUnlock() + return s.folderPermLocked(id, user, groups, map[string]bool{}) +} + +func (s *Store) folderPermLocked(id, user string, groups []string, seen map[string]bool) Perm { + if id == "" || seen[id] { + return PermNone + } + seen[id] = true + f, ok := s.idx.Folders[id] + if !ok { + return PermNone + } + if user != "" && f.Owner == user { + return PermWrite + } + best := PermNone + best = maxPerm(best, parsePerm(f.Public)) + best = maxPerm(best, grantsPerm(f.Grants, user, groups)) + best = maxPerm(best, s.folderPermLocked(f.Parent, user, groups, seen)) + return best +} + +func grantsPerm(grants []Grant, user string, groups []string) Perm { + best := PermNone + for _, g := range grants { + switch g.Kind { + case "user": + if user != "" && g.Name == user { + best = maxPerm(best, parsePerm(g.Perm)) + } + case "group": + if contains(groups, g.Name) { + best = maxPerm(best, parsePerm(g.Perm)) + } + } + } + return best +} + +// ── helpers ────────────────────────────────────────────────────────────────── + +func maxPerm(a, b Perm) Perm { + if a > b { + return a + } + return b +} + +func contains(xs []string, v string) bool { + for _, x := range xs { + if x == v { + return true + } + } + return false +} + +func clonePanel(a *PanelACL) *PanelACL { + c := *a + c.Grants = append([]Grant(nil), a.Grants...) + return &c +} + +func cloneFolder(f *Folder) *Folder { + c := *f + c.Grants = append([]Grant(nil), f.Grants...) + return &c +} diff --git a/internal/panelacl/panelacl_test.go b/internal/panelacl/panelacl_test.go new file mode 100644 index 0000000..af5bfff --- /dev/null +++ b/internal/panelacl/panelacl_test.go @@ -0,0 +1,109 @@ +package panelacl + +import "testing" + +func newStore(t *testing.T) *Store { + t.Helper() + s, err := New(t.TempDir()) + if err != nil { + t.Fatal("New:", err) + } + return s +} + +func TestUnmanagedPanelIsOpen(t *testing.T) { + s := newStore(t) + if got := s.PanelPerm("legacy", "alice", nil); got != PermWrite { + t.Fatalf("unmanaged panel perm = %v, want write", got) + } +} + +func TestOwnerAlwaysWrites(t *testing.T) { + s := newStore(t) + if err := s.CreatePanel("p1", "alice"); err != nil { + t.Fatal(err) + } + if got := s.PanelPerm("p1", "alice", nil); got != PermWrite { + t.Fatalf("owner perm = %v, want write", got) + } + // A freshly created panel is private to its owner. + if got := s.PanelPerm("p1", "bob", nil); got != PermNone { + t.Fatalf("non-owner perm on private panel = %v, want none", got) + } +} + +func TestPublicAndGrants(t *testing.T) { + s := newStore(t) + if err := s.CreatePanel("p1", "alice"); err != nil { + t.Fatal(err) + } + grants := []Grant{ + {Kind: "user", Name: "bob", Perm: "write"}, + {Kind: "group", Name: "ops", Perm: "read"}, + } + if err := s.SetPanel("p1", "alice", "", "read", grants); err != nil { + t.Fatal(err) + } + // Public read applies to anyone. + if got := s.PanelPerm("p1", "carol", nil); got != PermRead { + t.Fatalf("public perm = %v, want read", got) + } + // Direct user grant raises bob to write. + if got := s.PanelPerm("p1", "bob", nil); got != PermWrite { + t.Fatalf("granted user perm = %v, want write", got) + } + // Group membership grants read. + if got := s.PanelPerm("p1", "dave", []string{"ops"}); got != PermRead { + t.Fatalf("group perm = %v, want read", got) + } +} + +func TestFolderInheritance(t *testing.T) { + s := newStore(t) + f, err := s.CreateFolder("f1", "Shared", "", "alice") + if err != nil { + t.Fatal(err) + } + if err := s.UpdateFolder(f.ID, f.Name, "", "read", nil); err != nil { + t.Fatal(err) + } + if err := s.CreatePanel("p1", "alice"); err != nil { + t.Fatal(err) + } + if err := s.SetPanel("p1", "alice", f.ID, "", nil); err != nil { + t.Fatal(err) + } + // The panel inherits the folder's public-read level. + if got := s.PanelPerm("p1", "bob", nil); got != PermRead { + t.Fatalf("inherited perm = %v, want read", got) + } +} + +func TestFolderCycleRejected(t *testing.T) { + s := newStore(t) + a, _ := s.CreateFolder("a", "A", "", "alice") + b, _ := s.CreateFolder("b", "B", a.ID, "alice") + // Re-parenting A under its own descendant B must be rejected. + if err := s.UpdateFolder(a.ID, a.Name, b.ID, "", nil); err == nil { + t.Fatal("expected cycle rejection, got nil") + } +} + +func TestDeleteFolderReparents(t *testing.T) { + s := newStore(t) + a, _ := s.CreateFolder("a", "A", "", "alice") + b, _ := s.CreateFolder("b", "B", a.ID, "alice") + if err := s.CreatePanel("p1", "alice"); err != nil { + t.Fatal(err) + } + if err := s.SetPanel("p1", "alice", b.ID, "", nil); err != nil { + t.Fatal(err) + } + if err := s.DeleteFolder(b.ID); err != nil { + t.Fatal(err) + } + // B's panel should reparent to A (B's former parent), not orphan. + if acl := s.GetPanel("p1"); acl == nil || acl.Folder != a.ID { + t.Fatalf("panel folder after delete = %+v, want %s", acl, a.ID) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index a8375ed..e8cfca3 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -5,12 +5,15 @@ import ( "io/fs" "log/slog" "net/http" + "strings" "time" + "github.com/uopi/uopi/internal/access" "github.com/uopi/uopi/internal/api" "github.com/uopi/uopi/internal/broker" "github.com/uopi/uopi/internal/datasource/synthetic" "github.com/uopi/uopi/internal/metrics" + "github.com/uopi/uopi/internal/panelacl" "github.com/uopi/uopi/internal/storage" ) @@ -23,7 +26,7 @@ type Server struct { // New creates the HTTP server, registers all routes, and returns a ready-to-start Server. // synth may be nil if the synthetic data source is not enabled. -func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, channelFinderURL, archiverURL 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, channelFinderURL, archiverURL, trustedUserHeader string, log *slog.Logger) *Server { mux := http.NewServeMux() // Health check @@ -33,13 +36,16 @@ func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Syntheti }) // WebSocket endpoint - mux.Handle("/ws", &wsHandler{broker: brk, log: log}) + mux.Handle("/ws", &wsHandler{broker: brk, log: log, userHeader: trustedUserHeader, policy: policy}) // Prometheus-format metrics mux.HandleFunc("/metrics", metrics.Handler(brk.ActiveSubscriptions)) - // REST API - api.New(brk, synth, store, channelFinderURL, archiverURL, log).Register(mux, apiPrefix) + // 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, channelFinderURL, archiverURL, log).Register(apiMux, apiPrefix) + mux.Handle(apiPrefix+"/", accessMiddleware(policy, trustedUserHeader, apiMux)) // Embedded frontend — must be last (catch-all) mux.Handle("/", http.FileServerFS(webFS)) @@ -56,6 +62,44 @@ func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Syntheti } } +// accessMiddleware resolves the end-user identity from the trusted proxy header +// (falling back to the configured default_user), stores it on the request +// context, and enforces the user's global access level on every API request: +// - LevelNone → 403 for all requests. +// - LevelRead → 403 for any mutating request (non GET/HEAD). +// - LevelWrite → no restriction. +// +// The /me endpoint is always reachable so the frontend can discover the +// caller's identity and level even when otherwise restricted. +func accessMiddleware(policy *access.Policy, userHeader string, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var raw string + if userHeader != "" { + raw = r.Header.Get(userHeader) + } + user := policy.ResolveUser(raw) + r = r.WithContext(access.WithUser(r.Context(), user)) + + // Always allow identity discovery. + if strings.HasSuffix(r.URL.Path, apiPrefix+"/me") { + next.ServeHTTP(w, r) + return + } + + switch policy.Level(user) { + case access.LevelNone: + http.Error(w, "access denied", http.StatusForbidden) + return + case access.LevelRead: + if r.Method != http.MethodGet && r.Method != http.MethodHead { + http.Error(w, "read-only access", http.StatusForbidden) + return + } + } + next.ServeHTTP(w, r) + }) +} + // Start listens and serves until ctx is cancelled. func (s *Server) Start(ctx context.Context) error { s.log.Info("listening", "addr", s.httpServer.Addr) diff --git a/internal/server/ws.go b/internal/server/ws.go index 0717b6e..ff71e9e 100644 --- a/internal/server/ws.go +++ b/internal/server/ws.go @@ -6,11 +6,13 @@ import ( "errors" "log/slog" "net/http" + "strings" "sync" "time" "github.com/coder/websocket" + "github.com/uopi/uopi/internal/access" "github.com/uopi/uopi/internal/broker" "github.com/uopi/uopi/internal/datasource" "github.com/uopi/uopi/internal/metrics" @@ -87,9 +89,21 @@ type histPoint struct { type wsHandler struct { broker *broker.Broker log *slog.Logger + // userHeader is the HTTP header from which the per-session end-user identity + // is read (set by a trusted auth proxy). Empty disables per-user identity. + userHeader string + // policy enforces the global access level (blacklist) on signal writes. + policy *access.Policy } func (h *wsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // Capture the end-user identity from the trusted proxy header (if enabled) + // before the connection is upgraded, while the original headers are present. + var user string + if h.userHeader != "" { + user = strings.TrimSpace(r.Header.Get(h.userHeader)) + } + conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{ // Permissive origin check for LAN / SSH-tunnel use; tighten when auth lands. InsecureSkipVerify: true, @@ -107,6 +121,8 @@ func (h *wsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { c := &wsClient{ conn: conn, broker: h.broker, + user: user, + policy: h.policy, outCh: make(chan []byte, 512), updateCh: make(chan broker.Update, 1024), subs: make(map[broker.SignalRef]func()), @@ -136,6 +152,8 @@ type wsClient struct { conn *websocket.Conn broker *broker.Broker log *slog.Logger + user string // end-user identity from the trusted proxy header ("" if none) + policy *access.Policy // global access-level enforcement outCh chan []byte // serialised outgoing messages updateCh chan broker.Update // raw updates from the broker @@ -277,6 +295,14 @@ func (c *wsClient) handleUnsubscribe(refs []sigRef) { } func (c *wsClient) handleWrite(ctx context.Context, msg inMsg) { + // Enforce the user's global access level: blacklisted read-only / no-access + // users may not write signals, regardless of the channel's own writability. + if c.policy != nil && c.policy.Level(c.policy.ResolveUser(c.user)) < access.LevelWrite { + c.log.Warn("write: access denied", "ds", msg.DS, "name", msg.Name, "user", c.user) + c.sendError(ctx, "ACCESS_DENIED", "you do not have write access") + return + } + ds, ok := c.broker.Source(msg.DS) if !ok { c.log.Warn("write: unknown data source", "ds", msg.DS, "name", msg.Name) @@ -305,8 +331,11 @@ func (c *wsClient) handleWrite(ctx context.Context, msg inMsg) { return } - c.log.Info("write", "ds", msg.DS, "name", msg.Name, "value", value) + c.log.Info("write", "ds", msg.DS, "name", msg.Name, "value", value, "user", c.user) metrics.IncWrites() + // 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) if err := ds.Write(ctx, msg.Name, value); err != nil { c.log.Warn("write: ds.Write failed", "ds", msg.DS, "name", msg.Name, "err", err) c.sendError(ctx, "WRITE_ERROR", err.Error()) diff --git a/internal/storage/store.go b/internal/storage/store.go index c09ca26..1b67282 100644 --- a/internal/storage/store.go +++ b/internal/storage/store.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "path/filepath" + "sort" "strconv" "strings" "time" @@ -23,6 +24,15 @@ type InterfaceMeta struct { Version int `json:"version"` } +// VersionMeta describes a single persisted revision of an interface. +type VersionMeta struct { + Version int `json:"version"` + Name string `json:"name"` + Tag string `json:"tag,omitempty"` + Current bool `json:"current"` + SavedAt time.Time `json:"savedAt"` +} + // Store persists interface definitions as XML files under {storageDir}/interfaces/ // and workspace-level JSON blobs directly in {storageDir}. type Store struct { @@ -123,6 +133,7 @@ type rootAttrs struct { ID string `xml:"id,attr"` Name string `xml:"name,attr"` Version int `xml:"version,attr"` + Tag string `xml:"tag,attr"` } func (s *Store) readMeta(id string) (InterfaceMeta, error) { @@ -151,8 +162,8 @@ func (s *Store) Get(id string) ([]byte, error) { // Create stores new interface XML and returns the generated ID. // The ID is derived from the name attribute; a timestamp suffix is added to -// ensure uniqueness. -func (s *Store) Create(xmlData []byte) (string, error) { +// ensure uniqueness. If tag is non-empty it is stamped as the revision label. +func (s *Store) Create(xmlData []byte, tag string) (string, error) { var root rootAttrs if err := xml.Unmarshal(xmlData, &root); err != nil { return "", fmt.Errorf("invalid XML: %w", err) @@ -167,18 +178,30 @@ func (s *Store) Create(xmlData []byte) (string, error) { id = id + "-" + fmt.Sprintf("%d", time.Now().UnixMilli()) } - // Initialize version to 1. + // Initialize version to 1 and stamp the generated ID so the persisted XML + // is self-consistent (the client sends an empty id for new interfaces). updatedData, err := setXMLAttribute(xmlData, "version", "1") if err != nil { return "", fmt.Errorf("initialize version attribute: %w", err) } + updatedData, err = setXMLAttribute(updatedData, "id", id) + if err != nil { + return "", fmt.Errorf("stamp id attribute: %w", err) + } + if tag != "" { + updatedData, err = setXMLAttribute(updatedData, "tag", tag) + if err != nil { + return "", fmt.Errorf("set tag attribute: %w", err) + } + } return id, os.WriteFile(s.filePath(id), updatedData, 0o644) } // Update replaces the XML for an existing interface, preserving the previous -// version as a separate file. -func (s *Store) Update(id string, xmlData []byte) error { +// version as a separate file. If tag is non-empty it is stamped as the label +// for the new revision. +func (s *Store) Update(id string, xmlData []byte, tag string) error { if err := validateID(id); err != nil { return ErrNotFound } @@ -213,21 +236,223 @@ func (s *Store) Update(id string, xmlData []byte) error { if err != nil { return fmt.Errorf("update version attribute: %w", err) } + if tag != "" { + updatedData, err = setXMLAttribute(updatedData, "tag", tag) + if err != nil { + return fmt.Errorf("set tag attribute: %w", err) + } + } return os.WriteFile(oldPath, updatedData, 0o644) } +// Versions returns metadata for every persisted revision of the interface, +// ordered newest-first. The current file is flagged with Current=true. +func (s *Store) Versions(id string) ([]VersionMeta, error) { + if err := validateID(id); err != nil { + return nil, ErrNotFound + } + + // Current revision. + curInfo, err := os.Stat(s.filePath(id)) + if errors.Is(err, os.ErrNotExist) { + return nil, ErrNotFound + } else if err != nil { + return nil, err + } + curRoot, err := s.readRoot(s.filePath(id)) + if err != nil { + return nil, err + } + + out := []VersionMeta{{ + Version: curRoot.Version, + Name: curRoot.Name, + Tag: curRoot.Tag, + Current: true, + SavedAt: curInfo.ModTime(), + }} + + // Backup revisions: id.vN.xml + entries, err := os.ReadDir(s.dir) + if err != nil { + return nil, err + } + prefix := id + ".v" + for _, e := range entries { + name := e.Name() + if e.IsDir() || !strings.HasPrefix(name, prefix) || !strings.HasSuffix(name, ".xml") { + continue + } + if !isVersioned(name) { + continue + } + p := filepath.Join(s.dir, name) + info, err := e.Info() + if err != nil { + continue + } + root, err := s.readRoot(p) + if err != nil { + continue + } + out = append(out, VersionMeta{ + Version: root.Version, + Name: root.Name, + Tag: root.Tag, + SavedAt: info.ModTime(), + }) + } + + sort.Slice(out, func(i, j int) bool { return out[i].Version > out[j].Version }) + return out, nil +} + +// GetVersion returns the raw XML bytes for a specific revision of an interface. +func (s *Store) GetVersion(id string, version int) ([]byte, error) { + if err := validateID(id); err != nil { + return nil, ErrNotFound + } + + // The current file may hold the requested version. + curRoot, err := s.readRoot(s.filePath(id)) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, ErrNotFound + } + return nil, err + } + if curRoot.Version == version { + return os.ReadFile(s.filePath(id)) + } + + backupPath := filepath.Join(s.dir, fmt.Sprintf("%s.v%d.xml", id, version)) + data, err := os.ReadFile(backupPath) + if errors.Is(err, os.ErrNotExist) { + return nil, ErrNotFound + } + return data, err +} + +// versionPath returns the on-disk path for a specific revision, resolving the +// current file when version matches the live revision. +func (s *Store) versionPath(id string, version int) (string, error) { + cur := s.filePath(id) + curRoot, err := s.readRoot(cur) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return "", ErrNotFound + } + return "", err + } + if curRoot.Version == version { + return cur, nil + } + backup := filepath.Join(s.dir, fmt.Sprintf("%s.v%d.xml", id, version)) + if _, err := os.Stat(backup); err != nil { + if errors.Is(err, os.ErrNotExist) { + return "", ErrNotFound + } + return "", err + } + return backup, nil +} + +// SetVersionTag sets (or clears, when tag is empty) the label of a specific +// revision in place, without creating a new revision. +func (s *Store) SetVersionTag(id string, version int, tag string) error { + if err := validateID(id); err != nil { + return ErrNotFound + } + path, err := s.versionPath(id, version) + if err != nil { + return err + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + updated, err := setXMLAttribute(data, "tag", tag) + if err != nil { + return fmt.Errorf("set tag attribute: %w", err) + } + return os.WriteFile(path, updated, 0o644) +} + +// Promote makes a past revision the current one by re-saving its content as a +// new revision on top of history. The existing current revision is preserved +// as a backup, so promotion is non-destructive. +func (s *Store) Promote(id string, version int) error { + data, err := s.GetVersion(id, version) + if err != nil { + return err + } + return s.Update(id, data, fmt.Sprintf("restored from v%d", version)) +} + +// Fork creates a brand-new interface from the content of a specific revision, +// assigning a fresh unique ID and resetting its version to 1. +func (s *Store) Fork(id string, version int) (string, error) { + data, err := s.GetVersion(id, version) + if err != nil { + return "", err + } + newID := id + "-fork-" + fmt.Sprintf("%d", time.Now().UnixMilli()) + updated, err := setXMLAttribute(data, "version", "1") + if err != nil { + return "", fmt.Errorf("reset version attribute: %w", err) + } + updated, err = setXMLAttribute(updated, "id", newID) + if err != nil { + return "", fmt.Errorf("stamp id attribute: %w", err) + } + updated, err = setXMLAttribute(updated, "tag", "") + if err != nil { + return "", fmt.Errorf("clear tag attribute: %w", err) + } + return newID, os.WriteFile(s.filePath(newID), updated, 0o644) +} + +// readRoot parses the top-level interface attributes from the file at path. +func (s *Store) readRoot(path string) (rootAttrs, error) { + data, err := os.ReadFile(path) + if err != nil { + return rootAttrs{}, err + } + var root rootAttrs + if err := xml.Unmarshal(data, &root); err != nil { + return rootAttrs{}, err + } + return root, nil +} + // setXMLAttribute is a primitive helper to update a top-level attribute in an // XML blob without full unmarshal/marshal (which might mess up formatting). func setXMLAttribute(data []byte, attr, value string) ([]byte, error) { // Very basic implementation: look for the attribute in the first 512 bytes. // A proper implementation would use an XML encoder or a better regex. s := string(data) - tagEnd := strings.Index(s, ">") - if tagEnd == -1 { + + // Locate the root element's opening tag, skipping any XML declaration + // (), comments, or processing instructions. Operating on the + // first '>' in the document would match the '?>' of the XML declaration and + // corrupt its version attribute (producing invalid XML). + tagStart := -1 + for i := 0; i+1 < len(s); i++ { + if s[i] == '<' && s[i+1] != '?' && s[i+1] != '!' { + tagStart = i + break + } + } + if tagStart == -1 { + return nil, errors.New("malformed XML: no root element") + } + rel := strings.Index(s[tagStart:], ">") + if rel == -1 { return nil, errors.New("malformed XML: no root tag end") } - rootTag := s[:tagEnd] + tagEnd := tagStart + rel + rootTag := s[tagStart:tagEnd] attrSearch := " " + attr + "=\"" idx := strings.Index(rootTag, attrSearch) @@ -240,8 +465,8 @@ func setXMLAttribute(data []byte, attr, value string) ([]byte, error) { return []byte(s[:insertIdx] + fmt.Sprintf(" %s=\"%s\"", attr, value) + s[insertIdx:]), nil } - // Attribute found, replace its value. - valStart := idx + len(attrSearch) + // Attribute found, replace its value (idx is relative to rootTag). + valStart := tagStart + idx + len(attrSearch) valEnd := strings.Index(s[valStart:], "\"") if valEnd == -1 { return nil, errors.New("malformed XML: attribute value not closed") diff --git a/internal/storage/store_test.go b/internal/storage/store_test.go index 6f7a594..bf9a08f 100644 --- a/internal/storage/store_test.go +++ b/internal/storage/store_test.go @@ -35,7 +35,7 @@ func TestListEmpty(t *testing.T) { func TestListAfterCreate(t *testing.T) { s := newStore(t) - if _, err := s.Create([]byte(sampleXML)); err != nil { + if _, err := s.Create([]byte(sampleXML), ""); err != nil { t.Fatalf("Create: %v", err) } list, err := s.List() @@ -59,7 +59,7 @@ func TestListAfterCreate(t *testing.T) { func TestCreateAndGet(t *testing.T) { s := newStore(t) - id, err := s.Create([]byte(sampleXML)) + id, err := s.Create([]byte(sampleXML), "") if err != nil { t.Fatalf("Create: %v", err) } @@ -78,7 +78,7 @@ func TestCreateAndGet(t *testing.T) { func TestCreateUsesEmbeddedID(t *testing.T) { s := newStore(t) - id, err := s.Create([]byte(sampleXML)) + id, err := s.Create([]byte(sampleXML), "") if err != nil { t.Fatalf("Create: %v", err) } @@ -90,11 +90,11 @@ func TestCreateUsesEmbeddedID(t *testing.T) { func TestCreateDuplicateGetsUniqueID(t *testing.T) { s := newStore(t) - id1, err := s.Create([]byte(sampleXML)) + id1, err := s.Create([]byte(sampleXML), "") if err != nil { t.Fatalf("first Create: %v", err) } - id2, err := s.Create([]byte(sampleXML)) + id2, err := s.Create([]byte(sampleXML), "") if err != nil { t.Fatalf("second Create: %v", err) } @@ -105,7 +105,7 @@ func TestCreateDuplicateGetsUniqueID(t *testing.T) { func TestCreateInvalidXML(t *testing.T) { s := newStore(t) - _, err := s.Create([]byte("not xml")) + _, err := s.Create([]byte("not xml"), "") if err == nil { t.Error("expected error for invalid XML") } @@ -145,13 +145,13 @@ func TestGetEmptyID(t *testing.T) { func TestUpdate(t *testing.T) { s := newStore(t) - id, err := s.Create([]byte(sampleXML)) + id, err := s.Create([]byte(sampleXML), "") if err != nil { t.Fatalf("Create: %v", err) } updated := `` - if err := s.Update(id, []byte(updated)); err != nil { + if err := s.Update(id, []byte(updated), ""); err != nil { t.Fatalf("Update: %v", err) } @@ -166,7 +166,7 @@ func TestUpdate(t *testing.T) { func TestUpdateNotFound(t *testing.T) { s := newStore(t) - err := s.Update("no-such-id", []byte(sampleXML)) + err := s.Update("no-such-id", []byte(sampleXML), "") if !errors.Is(err, storage.ErrNotFound) { t.Errorf("Update missing: want ErrNotFound, got %v", err) } @@ -178,7 +178,7 @@ func TestUpdateNotFound(t *testing.T) { func TestDelete(t *testing.T) { s := newStore(t) - id, err := s.Create([]byte(sampleXML)) + id, err := s.Create([]byte(sampleXML), "") if err != nil { t.Fatalf("Create: %v", err) } @@ -205,7 +205,7 @@ func TestDeleteNotFound(t *testing.T) { func TestClone(t *testing.T) { s := newStore(t) - id, err := s.Create([]byte(sampleXML)) + id, err := s.Create([]byte(sampleXML), "") if err != nil { t.Fatalf("Create: %v", err) } diff --git a/uopi.example.toml b/uopi.example.toml index 5e03ea8..64a31bb 100644 --- a/uopi.example.toml +++ b/uopi.example.toml @@ -2,6 +2,27 @@ listen = ":8080" storage_dir = "./interfaces" +# ── Identity & access control ─────────────────────────────────────────────── +# The end-user identity is read from a header set by a trusted authenticating +# reverse proxy. Leave trusted_user_header empty for unproxied/dev/LAN use. +# SECURITY: only enable this when the proxy strips any client-supplied value of +# this header, otherwise it can be spoofed. +trusted_user_header = "" # e.g. "X-Forwarded-User" +# Identity used when the trusted header is absent/empty. Empty = anonymous. +default_user = "" + +# Every user is trusted with full write access by default. The blacklist +# downgrades specific users. Levels: "readonly" (view only, no writes) or +# "noaccess" (denied entirely). +# [[server.blacklist]] +# user = "guest" +# level = "readonly" + +# Named sets of users, referenced by per-panel sharing rules (future phases). +# [[groups]] +# name = "operators" +# members = ["alice", "bob"] + [datasource.epics] enabled = true ca_addr_list = "" # overrides EPICS_CA_ADDR_LIST if set diff --git a/web/src/App.tsx b/web/src/App.tsx index 583993b..b76e0fc 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -5,7 +5,8 @@ import ViewMode from './ViewMode'; import EditMode from './EditMode'; import FullscreenMode from './FullscreenMode'; import { applyZoom, getStoredZoom } from './ZoomControl'; -import type { Interface } from './lib/types'; +import { AuthContext, DEFAULT_ME, fetchMe, canRead } from './lib/auth'; +import type { Interface, Me } from './lib/types'; type AppMode = 'view' | 'edit'; @@ -18,6 +19,8 @@ export default function App() { const [editTarget, setEditTarget] = useState(null); // Interface to show when returning to view mode after editing const [viewTarget, setViewTarget] = useState(null); + // Resolved identity + global access level for the current user. + const [me, setMe] = useState(DEFAULT_ME); useEffect(() => { const unsub = wsClient.status.subscribe(setWsStatus); @@ -29,6 +32,7 @@ export default function App() { document.documentElement.style.setProperty('--dpr', String(dpr)); applyZoom(getStoredZoom()); if (!fsParam) wsClient.connect('/ws'); + fetchMe().then(setMe); }, []); if (fsParam) { @@ -40,25 +44,40 @@ export default function App() { setMode('edit'); } - function exitEdit(iface: Interface) { - setViewTarget(iface); + // `iface === null` means the editor discarded an unsaved/new panel; keep + // showing whatever was previously open (held in viewTarget). + function exitEdit(iface: Interface | null) { + if (iface) setViewTarget(iface); setMode('view'); setEditTarget(null); } - return ( -
- {wsStatus === 'disconnected' && ( -
WebSocket disconnected — reconnecting…
- )} - {/* ViewMode is always mounted so PlotPanel ring-buffers are never destroyed. - EditMode is conditionally rendered; it resets from props each time anyway. */} -
- + if (!canRead(me.level)) { + return ( +
+
+

Access denied

+

+ Your account{me.user ? ` (${me.user})` : ''} does not have permission + to access this application. Contact an administrator. +

+
- {mode === 'edit' && ( - - )} -
+ ); + } + + return ( + +
+ {wsStatus === 'disconnected' && ( +
WebSocket disconnected — reconnecting…
+ )} + {mode === 'view' ? ( + + ) : ( + + )} +
+
); } diff --git a/web/src/Canvas.tsx b/web/src/Canvas.tsx index 3baae30..892a561 100644 --- a/web/src/Canvas.tsx +++ b/web/src/Canvas.tsx @@ -1,5 +1,7 @@ import { h } from 'preact'; -import { useState } from 'preact/hooks'; +import { useState, useEffect } from 'preact/hooks'; +import { initLocalState } from './lib/localstate'; +import { logicEngine } from './lib/logic'; import type { Interface, Widget, SignalRef } from './lib/types'; import TextView from './widgets/TextView'; import TextLabel from './widgets/TextLabel'; @@ -15,6 +17,7 @@ import ImageWidget from './widgets/ImageWidget'; import LinkWidget from './widgets/LinkWidget'; import ContextMenu from './ContextMenu'; import InfoPanel from './InfoPanel'; +import SplitLayout from './SplitLayout'; const COMPONENTS: Record = { textview: TextView, @@ -42,15 +45,25 @@ interface Props { iface: Interface | null; onNavigate?: (interfaceId: string) => void; timeRange?: { start: string; end: string } | null; - onPlot?: (signal: SignalRef) => void; } -export default function Canvas({ iface, onNavigate, timeRange, onPlot }: Props) { +export default function Canvas({ iface, onNavigate, timeRange }: Props) { const [ctxMenu, setCtxMenu] = useState({ visible: false, x: 0, y: 0, signal: null, }); const [infoSignal, setInfoSignal] = useState(null); + // Instantiate this panel's local state variables from their initial values. + useEffect(() => { + initLocalState(iface?.statevars); + }, [iface?.id, iface?.statevars]); + + // Activate panel logic for the live view; tear it down on unmount/panel switch. + useEffect(() => { + logicEngine.load(iface?.logic); + return () => logicEngine.clear(); + }, [iface?.id, iface?.logic]); + function onCtxMenu(e: MouseEvent, widget: Widget) { e.preventDefault(); e.stopPropagation(); @@ -65,10 +78,6 @@ export default function Canvas({ iface, onNavigate, timeRange, onPlot }: Props) if (ctxMenu.signal) setInfoSignal(ctxMenu.signal); } - function handlePlot() { - if (ctxMenu.signal && onPlot) onPlot(ctxMenu.signal); - } - if (!iface) { return (
@@ -79,6 +88,41 @@ export default function Canvas({ iface, onNavigate, timeRange, onPlot }: Props) ); } + // Plot panel: plots fill the viewport arranged in a recursive split layout. + if (iface.kind === 'plot' && iface.layout) { + const byId = new Map(iface.widgets.map(w => [w.id, w])); + return ( +
+
+ { + const widget = byId.get(wid); + if (!widget) return
missing plot
; + return ( + onCtxMenu(e, widget)} + timeRange={timeRange} + /> + ); + }} + /> +
+ + + + {infoSignal && ( + setInfoSignal(null)} /> + )} +
+ ); + } + return (
@@ -110,7 +154,6 @@ export default function Canvas({ iface, onNavigate, timeRange, onPlot }: Props) {...ctxMenu} onClose={closeCtxMenu} onInfo={handleInfo} - onPlot={onPlot ? handlePlot : undefined} /> {infoSignal && ( diff --git a/web/src/ContextMenu.tsx b/web/src/ContextMenu.tsx index 5f7a036..dd24837 100644 --- a/web/src/ContextMenu.tsx +++ b/web/src/ContextMenu.tsx @@ -11,10 +11,9 @@ interface Props { signal: SignalRef | null; onClose: () => void; onInfo?: () => void; - onPlot?: () => void; } -export default function ContextMenu({ visible, x, y, signal, onClose, onInfo, onPlot }: Props) { +export default function ContextMenu({ visible, x, y, signal, onClose, onInfo }: Props) { const [meta, setMeta] = useState(null); const menuRef = useRef(null); @@ -47,11 +46,6 @@ export default function ContextMenu({ visible, x, y, signal, onClose, onInfo, on onInfo?.(); } - function openPlot() { - onClose(); - onPlot?.(); - } - function exportCSV() { // Phase 5+: export CSV data onClose(); @@ -73,7 +67,6 @@ export default function ContextMenu({ visible, x, y, signal, onClose, onInfo, on
-
diff --git a/web/src/EditCanvas.tsx b/web/src/EditCanvas.tsx index eedf8d9..533f24d 100644 --- a/web/src/EditCanvas.tsx +++ b/web/src/EditCanvas.tsx @@ -364,7 +364,7 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna >
- {iface.widgets.map(widget => { + {(iface.widgets || []).map(widget => { const Comp = COMPONENTS[widget.type]; return Comp ? @@ -376,8 +376,9 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna ); })} - {iface.widgets.map(widget => { + {(iface.widgets || []).map(widget => { const isSelected = selectedIds.includes(widget.id); + return (
void; + onDone: (iface: Interface | null) => void; +} + +interface VersionMeta { + version: number; + name: string; + tag?: string; + current: boolean; + savedAt: string; } function blankInterface(): Interface { @@ -69,12 +81,14 @@ function distributeWidgets(widgets: Widget[], ids: string[], axis: 'h' | 'v'): W const STATIC_WIDGET_TYPES = [ { type: 'textlabel', label: 'Text Label' }, + { type: 'button', label: 'Action Button' }, { type: 'image', label: 'Image' }, { type: 'link', label: 'Link' }, ]; export default function EditMode({ initial, onDone }: Props) { const [iface, setIface] = useState(initial ?? blankInterface()); + const writable = canWrite(useAuth().level); const [selectedIds, setSelectedIds] = useState([]); const [dirty, setDirty] = useState(false); const [saving, setSaving] = useState(false); @@ -86,8 +100,22 @@ export default function EditMode({ initial, onDone }: Props) { const [helpSection, setHelpSection] = useState('edit'); const [leftW, setLeftW] = useState(220); const [rightW, setRightW] = useState(260); + // Center column tab: the drag-and-drop layout editor, or the panel-logic editor. + const [centerTab, setCenterTab] = useState<'layout' | 'logic'>('layout'); - function startResize(side: 'left' | 'right') { + // History panel + const [showHistory, setShowHistory] = useState(false); + const [versions, setVersions] = useState([]); + const [historyLoading, setHistoryLoading] = useState(false); + const [tag, setTag] = useState(''); + // Bumped whenever the editor content is replaced wholesale (version load / + // promote) to force a full canvas remount, so no stale widget state lingers. + const [canvasNonce, setCanvasNonce] = useState(0); + + // Clipboard + const clipboard = useRef([]); + + const startResize = useCallback((side: 'left' | 'right') => { return (e: MouseEvent) => { e.preventDefault(); const startX = e.clientX; @@ -104,74 +132,189 @@ export default function EditMode({ initial, onDone }: Props) { window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp); }; - } - function openHelp(section = 'edit') { setHelpSection(section); setShowHelp(true); } + }, [leftW, rightW]); - // Undo / redo - const undoStack = useRef([]); - const redoStack = useRef([]); + const openHelp = useCallback((section = 'edit') => { + setHelpSection(section); + setShowHelp(true); + }, []); + + // Undo / redo. Snapshots capture both widgets and (for plot panels) the split + // layout, so split/close/resize operations are undone atomically. + type Snapshot = { widgets: Widget[]; layout?: PlotLayout }; + const undoStack = useRef([]); + const redoStack = useRef([]); const [historyLen, setHistoryLen] = useState(0); // drives canUndo/canRedo display - const widgetsRef = useRef(iface.widgets); - widgetsRef.current = iface.widgets; + const widgetsRef = useRef(iface.widgets || []); + widgetsRef.current = iface.widgets || []; + const layoutRef = useRef(iface.layout); + layoutRef.current = iface.layout; - function pushUndo() { - undoStack.current = [...undoStack.current.slice(-49), [...widgetsRef.current]]; + const snapshot = useCallback((): Snapshot => ({ + widgets: [...widgetsRef.current], + layout: layoutRef.current, + }), []); + + const pushUndo = useCallback(() => { + undoStack.current = [...undoStack.current.slice(-49), snapshot()]; redoStack.current = []; setHistoryLen(undoStack.current.length); - } + }, [snapshot]); - function undo() { + const undo = useCallback(() => { if (undoStack.current.length === 0) return; const prev = undoStack.current[undoStack.current.length - 1]; - redoStack.current = [widgetsRef.current, ...redoStack.current]; + redoStack.current = [snapshot(), ...redoStack.current]; undoStack.current = undoStack.current.slice(0, -1); setHistoryLen(undoStack.current.length); - setIface(f => ({ ...f, widgets: prev })); + setIface(f => ({ ...f, widgets: prev.widgets, layout: prev.layout })); setDirty(true); - } + }, [snapshot]); - function redo() { + const redo = useCallback(() => { if (redoStack.current.length === 0) return; const next = redoStack.current[0]; - undoStack.current = [...undoStack.current, widgetsRef.current]; + undoStack.current = [...undoStack.current, snapshot()]; redoStack.current = redoStack.current.slice(1); setHistoryLen(undoStack.current.length); - setIface(f => ({ ...f, widgets: next })); + setIface(f => ({ ...f, widgets: next.widgets, layout: next.layout })); setDirty(true); - } + }, [snapshot]); const handleWidgetsChange = useCallback((widgets: Widget[]) => { pushUndo(); setIface(f => ({ ...f, widgets })); setDirty(true); - }, []); + }, [pushUndo]); + + // Combined widgets + layout change for plot-panel split/close/resize. + const handlePlotChange = useCallback((widgets: Widget[], layout: PlotLayout) => { + pushUndo(); + setIface(f => ({ ...f, widgets, layout })); + setDirty(true); + }, [pushUndo]); const handleWidgetChange = useCallback((updated: Widget) => { pushUndo(); - setIface(f => ({ ...f, widgets: f.widgets.map(w => w.id === updated.id ? updated : w) })); + setIface(f => ({ ...f, widgets: (f.widgets || []).map(w => w.id === updated.id ? updated : w) })); setDirty(true); - }, []); + }, [pushUndo]); const handleIfaceChange = useCallback((updated: Interface) => { setIface(updated); setDirty(true); }, []); + const handleStateVarsChange = useCallback((statevars: StateVar[]) => { + setIface(f => ({ ...f, statevars })); + setDirty(true); + initLocalState(statevars); + }, []); + + const handleLogicChange = useCallback((logic: LogicGraph) => { + setIface(f => ({ ...f, logic })); + setDirty(true); + }, []); + + // Instantiate local state variables when the edited panel loads/changes so + // they can be previewed live in the canvas just like real signals. + useEffect(() => { + initLocalState(iface.statevars); + }, [iface.id]); + + // ── Keyboard shortcuts ───────────────────────────────────────────────────── + + const handleKeyDown = useCallback((e: KeyboardEvent) => { + const target = e.target as Element; + if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT') return; + if (e.key === '?') { openHelp('edit'); return; } + + // The logic editor manages its own undo/redo/clipboard/delete shortcuts. + if (centerTab === 'logic') return; + + // Plot panels use split/close controls instead of free-form widget editing; + // only undo/redo apply here. + if (iface.kind === 'plot') { + if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) { e.preventDefault(); undo(); return; } + if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) { e.preventDefault(); redo(); return; } + return; + } + + const curWidgets = widgetsRef.current; + + if ((e.key === 'Delete' || e.key === 'Backspace') && selectedIds.length > 0) { + pushUndo(); + setIface(f => ({ ...f, widgets: curWidgets.filter(w => !selectedIds.includes(w.id)) })); + setSelectedIds([]); + setDirty(true); + return; + } + + // Copy + if ((e.ctrlKey || e.metaKey) && e.key === 'c' && selectedIds.length > 0) { + e.preventDefault(); + clipboard.current = curWidgets + .filter(w => selectedIds.includes(w.id)) + .map(w => ({ ...w })); + return; + } + + // Paste + if ((e.ctrlKey || e.metaKey) && e.key === 'v' && clipboard.current.length > 0) { + e.preventDefault(); + pushUndo(); + const offset = 20; + const pasted = clipboard.current.map(w => ({ + ...w, + id: genWidgetId(), + x: w.x + offset, + y: w.y + offset, + })); + setIface(f => ({ ...f, widgets: [...curWidgets, ...pasted] })); + setSelectedIds(pasted.map(w => w.id)); + setDirty(true); + return; + } + + if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) { e.preventDefault(); undo(); return; } + if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) { e.preventDefault(); redo(); return; } + if ((e.ctrlKey || e.metaKey) && e.key === 'a') { + e.preventDefault(); + setSelectedIds(curWidgets.map(w => w.id)); + return; + } + // Arrow nudge + if (['ArrowLeft','ArrowRight','ArrowUp','ArrowDown'].includes(e.key) && selectedIds.length > 0) { + e.preventDefault(); + const step = e.shiftKey ? (snapGrid || 10) * 2 : (snapGrid || 1); + const dx = e.key === 'ArrowLeft' ? -step : e.key === 'ArrowRight' ? step : 0; + const dy = e.key === 'ArrowUp' ? -step : e.key === 'ArrowDown' ? step : 0; + handleWidgetsChange(curWidgets.map(w => + selectedIds.includes(w.id) ? { ...w, x: w.x + dx, y: w.y + dy } : w + )); + } + }, [selectedIds, snapGrid, undo, redo, handleWidgetsChange, openHelp, pushUndo, iface.kind, centerTab]); + + useEffect(() => { + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [handleKeyDown]); + // ── Align / distribute ───────────────────────────────────────────────────── - function doAlign(mode: string) { - const aligned = alignWidgets(iface.widgets, selectedIds, mode); + const doAlign = useCallback((mode: string) => { + const aligned = alignWidgets(iface.widgets || [], selectedIds, mode); handleWidgetsChange(aligned); - } + }, [iface.widgets, selectedIds, handleWidgetsChange]); - function doDistribute(axis: 'h' | 'v') { - const distributed = distributeWidgets(iface.widgets, selectedIds, axis); + const doDistribute = useCallback((axis: 'h' | 'v') => { + const distributed = distributeWidgets(iface.widgets || [], selectedIds, axis); handleWidgetsChange(distributed); - } + }, [iface.widgets, selectedIds, handleWidgetsChange]); // ── Insert static widget ─────────────────────────────────────────────────── - function insertWidget(type: string) { + const insertWidget = useCallback((type: string) => { setShowInsertMenu(false); const [defW, defH] = DEFAULT_SIZES[type] ?? [150, 60]; const newWidget: Widget = { @@ -182,22 +325,26 @@ export default function EditMode({ initial, onDone }: Props) { w: defW, h: defH, signals: [], - options: type === 'textlabel' ? { label: 'Label' } : {}, + options: + type === 'textlabel' ? { label: 'Label' } : + type === 'button' ? { label: 'Button', mode: 'oneshot' } : + {}, }; - handleWidgetsChange([...iface.widgets, newWidget]); + handleWidgetsChange([...(iface.widgets || []), newWidget]); setSelectedIds([newWidget.id]); - } + }, [iface.widgets, handleWidgetsChange]); // ── Save / export / import ───────────────────────────────────────────────── - async function handleSave() { + const handleSave = useCallback(async (saveTag = '') => { setSaving(true); setError(null); try { const xml = serializeInterface(iface); - const url = iface.id + const base = iface.id ? `/api/v1/interfaces/${encodeURIComponent(iface.id)}` : '/api/v1/interfaces'; + const url = saveTag ? `${base}?tag=${encodeURIComponent(saveTag)}` : base; const method = iface.id ? 'PUT' : 'POST'; const res = await fetch(url, { method, @@ -210,15 +357,141 @@ export default function EditMode({ initial, onDone }: Props) { setIface(f => ({ ...f, id: json.id })); } setDirty(false); + setTag(''); window.dispatchEvent(new CustomEvent('uopi:refresh-interfaces')); + if (showHistory) loadVersions(); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setSaving(false); } - } + // loadVersions is stable (declared below); intentionally omitted from deps. + }, [iface, showHistory]); - function handleExport() { + // ── Version history ──────────────────────────────────────────────────────── + + const loadVersions = useCallback(async () => { + if (!iface.id) { setVersions([]); return; } + setHistoryLoading(true); + try { + const res = await fetch(`/api/v1/interfaces/${encodeURIComponent(iface.id)}/versions`); + if (!res.ok) throw new Error(`Load versions failed (${res.status})`); + setVersions(await res.json()); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setHistoryLoading(false); + } + }, [iface.id]); + + const toggleHistory = useCallback(() => { + setShowHistory(s => { + if (!s) loadVersions(); + return !s; + }); + }, [loadVersions]); + + // Load a past revision into the editor non-destructively: the current id is + // preserved, so saving the restored content creates a new revision on top. + const loadVersion = useCallback(async (version: number) => { + if (!iface.id) return; + if (dirty && !confirm('You have unsaved changes. Load this version anyway?')) return; + try { + const res = await fetch(`/api/v1/interfaces/${encodeURIComponent(iface.id)}/versions/${version}`); + if (!res.ok) throw new Error(`Load version failed (${res.status})`); + const restored = parseInterface(await res.text()); + restored.id = iface.id; // keep editing the same interface + setIface(restored); + setSelectedIds([]); + setDirty(true); + setCanvasNonce(n => n + 1); + undoStack.current = []; + redoStack.current = []; + setHistoryLen(0); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }, [iface.id, dirty]); + + // Edit (or clear) the label of an existing revision in place. + const editVersionTag = useCallback(async (version: number, currentTag: string) => { + if (!iface.id) return; + const next = prompt('Label for this version (leave empty to clear):', currentTag ?? ''); + if (next === null) return; + try { + const res = await fetch( + `/api/v1/interfaces/${encodeURIComponent(iface.id)}/versions/${version}/tag?tag=${encodeURIComponent(next)}`, + { method: 'PUT' }, + ); + if (!res.ok) throw new Error(`Tag update failed (${res.status})`); + loadVersions(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }, [iface.id, loadVersions]); + + // Make a past revision the current one (saved as a new revision on top). + const promoteVersion = useCallback(async (version: number) => { + if (!iface.id) return; + if (dirty && !confirm('You have unsaved changes that will be discarded. Set this version as current?')) return; + try { + const res = await fetch( + `/api/v1/interfaces/${encodeURIComponent(iface.id)}/versions/${version}/promote`, + { method: 'POST' }, + ); + if (!res.ok) throw new Error(`Promote failed (${res.status})`); + // Reload the now-current interface content into the editor. + const cur = await fetch(`/api/v1/interfaces/${encodeURIComponent(iface.id)}`); + const reloaded = parseInterface(await cur.text()); + setIface(reloaded); + setSelectedIds([]); + setDirty(false); + setCanvasNonce(n => n + 1); + undoStack.current = []; + redoStack.current = []; + setHistoryLen(0); + window.dispatchEvent(new CustomEvent('uopi:refresh-interfaces')); + loadVersions(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }, [iface.id, dirty, loadVersions]); + + // Fork a revision into a brand-new interface. + const forkVersion = useCallback(async (version: number) => { + if (!iface.id) return; + try { + const res = await fetch( + `/api/v1/interfaces/${encodeURIComponent(iface.id)}/versions/${version}/fork`, + { method: 'POST' }, + ); + if (!res.ok) throw new Error(`Fork failed (${res.status})`); + const json = await res.json(); + window.dispatchEvent(new CustomEvent('uopi:refresh-interfaces')); + alert(`Forked into new interface "${json.id}". Open it from the interface list.`); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }, [iface.id]); + + // ── Auto-snapshot: every 5 minutes, if there are unsaved changes on an + // already-persisted interface, save a new revision automatically. ────────── + const saveRef = useRef(handleSave); + saveRef.current = handleSave; + const autoSaveState = useRef({ dirty, id: iface.id, saving }); + autoSaveState.current = { dirty, id: iface.id, saving }; + + useEffect(() => { + const interval = setInterval(() => { + const s = autoSaveState.current; + if (s.dirty && s.id && !s.saving) { + saveRef.current('auto'); + } + }, 5 * 60 * 1000); + return () => clearInterval(interval); + }, []); + + const handleExport = useCallback(() => { const xml = serializeInterface(iface); const blob = new Blob([xml], { type: 'application/xml' }); const url = URL.createObjectURL(blob); @@ -227,9 +500,9 @@ export default function EditMode({ initial, onDone }: Props) { a.download = `${iface.name.replace(/[^a-z0-9]/gi, '_')}.xml`; a.click(); URL.revokeObjectURL(url); - } + }, [iface]); - function handleImport() { + const handleImport = useCallback(() => { const input = document.createElement('input'); input.type = 'file'; input.accept = '.xml,application/xml,text/xml'; @@ -248,78 +521,30 @@ export default function EditMode({ initial, onDone }: Props) { } }; input.click(); - } + }, []); - function handleLeave() { + const handleLeave = useCallback(async () => { if (dirty && !confirm('You have unsaved changes. Leave without saving?')) return; + // Brand-new panel that was never saved: nothing to show — return to the + // previously open panel (signalled by passing null). + if (!iface.id) { onDone(null); return; } + // Existing panel closed with unsaved edits: reopen the last saved (active) + // version rather than carrying the discarded in-memory changes into view. + if (dirty) { + try { + const res = await fetch(`/api/v1/interfaces/${encodeURIComponent(iface.id)}`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + onDone(parseInterface(await res.text())); + } catch { + onDone(null); + } + return; + } onDone(iface); - } - - // Clipboard - const clipboard = useRef([]); - - // ── Keyboard shortcuts ───────────────────────────────────────────────────── - - function handleKeyDown(e: KeyboardEvent) { - const target = e.target as Element; - if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT') return; - if (e.key === '?') { openHelp('edit'); return; } - - if ((e.key === 'Delete' || e.key === 'Backspace') && selectedIds.length > 0) { - pushUndo(); - setIface(f => ({ ...f, widgets: f.widgets.filter(w => !selectedIds.includes(w.id)) })); - setSelectedIds([]); - setDirty(true); - return; - } - - // Copy - if ((e.ctrlKey || e.metaKey) && e.key === 'c' && selectedIds.length > 0) { - e.preventDefault(); - clipboard.current = iface.widgets - .filter(w => selectedIds.includes(w.id)) - .map(w => ({ ...w })); - return; - } - - // Paste - if ((e.ctrlKey || e.metaKey) && e.key === 'v' && clipboard.current.length > 0) { - e.preventDefault(); - pushUndo(); - const offset = 20; - const pasted = clipboard.current.map(w => ({ - ...w, - id: genWidgetId(), - x: w.x + offset, - y: w.y + offset, - })); - setIface(f => ({ ...f, widgets: [...f.widgets, ...pasted] })); - setSelectedIds(pasted.map(w => w.id)); - setDirty(true); - return; - } - - if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) { e.preventDefault(); undo(); return; } - if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) { e.preventDefault(); redo(); return; } - if ((e.ctrlKey || e.metaKey) && e.key === 'a') { - e.preventDefault(); - setSelectedIds(iface.widgets.map(w => w.id)); - return; - } - // Arrow nudge - if (['ArrowLeft','ArrowRight','ArrowUp','ArrowDown'].includes(e.key) && selectedIds.length > 0) { - e.preventDefault(); - const step = e.shiftKey ? (snapGrid || 10) * 2 : (snapGrid || 1); - const dx = e.key === 'ArrowLeft' ? -step : e.key === 'ArrowRight' ? step : 0; - const dy = e.key === 'ArrowUp' ? -step : e.key === 'ArrowDown' ? step : 0; - handleWidgetsChange(iface.widgets.map(w => - selectedIds.includes(w.id) ? { ...w, x: w.x + dx, y: w.y + dy } : w - )); - } - } + }, [dirty, onDone, iface]); const selectedWidget = selectedIds.length === 1 - ? iface.widgets.find(w => w.id === selectedIds[0]) ?? null + ? (iface.widgets || []).find(w => w.id === selectedIds[0]) ?? null : null; const canUndo = historyLen > 0; @@ -327,7 +552,7 @@ export default function EditMode({ initial, onDone }: Props) { const multiSelected = selectedIds.length > 1; return ( -
+
{/* ── Toolbar ── */}
@@ -362,17 +587,19 @@ export default function EditMode({ initial, onDone }: Props) { Grid - {/* Insert static widget */} -
- - {showInsertMenu && ( -
setShowInsertMenu(false)}> - {STATIC_WIDGET_TYPES.map(({ type, label }) => ( - - ))} -
- )} -
+ {/* Insert static widget — not applicable to split plot panels */} + {iface.kind !== 'plot' && ( +
+ + {showInsertMenu && ( +
setShowInsertMenu(false)}> + {STATIC_WIDGET_TYPES.map(({ type, label }) => ( + + ))} +
+ )} +
+ )} {/* Align/distribute — only when ≥2 selected */} {selectedIds.length >= 2 && ( @@ -402,7 +629,20 @@ export default function EditMode({ initial, onDone }: Props) { - + @@ -411,24 +651,127 @@ export default function EditMode({ initial, onDone }: Props) { {/* ── Three-panel body ── */}
- -
- -
- + {/* The logic tab is a self-contained flow editor with its own palette and + inspector, so the layout-only side panes are hidden while it is open. */} + {centerTab !== 'logic' && ( + + +
+ + )} +
+
+ + +
+ {centerTab === 'logic' ? ( + + ) : iface.kind === 'plot' ? ( + + ) : ( + + )} +
+ {centerTab !== 'logic' && ( + +
+ + + )} + {showHistory && ( +
+
+ Version History + +
+
+ setTag((e.target as HTMLInputElement).value)} + /> + +
+
+ {historyLoading &&
Loading…
} + {!historyLoading && versions.length === 0 && ( +
No saved versions yet.
+ )} + {!historyLoading && versions.map(v => ( +
+
+ v{v.version} + {v.tag && {v.tag}} + {v.current && current} +
+
+ {new Date(v.savedAt).toLocaleString()} +
+
+ {!v.current && ( + + )} + {!v.current && ( + + )} + + +
+
+ ))} +
+
+ )}
{showHelp && ( diff --git a/web/src/InterfaceList.tsx b/web/src/InterfaceList.tsx index 0690eab..5cb55a6 100644 --- a/web/src/InterfaceList.tsx +++ b/web/src/InterfaceList.tsx @@ -1,32 +1,49 @@ import { h } from 'preact'; import { useState, useEffect, useRef } from 'preact/hooks'; -import type { Interface } from './lib/types'; - -interface ServerInterface { - id: string; - name: string; - version: number; -} +import { useAuth, canWrite } from './lib/auth'; +import type { Interface, InterfaceListItem, Folder } from './lib/types'; +import ShareDialog from './ShareDialog'; interface Props { onLoad: (xml: string) => void; onSelect?: (id: string) => void; onEdit?: (iface?: Interface) => void; + onNewPlot?: () => void; onEditId?: (id: string) => void; width?: number; + collapsed: boolean; + onToggleCollapse: () => void; } -export default function InterfaceList({ onLoad, onSelect, onEdit, onEditId, width }: Props) { - const [collapsed, setCollapsed] = useState(false); - const [interfaces, setInterfaces] = useState([]); +export default function InterfaceList({ onLoad, onSelect, onEdit, onNewPlot, onEditId, width, collapsed, onToggleCollapse }: Props) { + const [interfaces, setInterfaces] = useState([]); + const [folders, setFolders] = useState([]); const [loading, setLoading] = useState(true); + const [share, setShare] = useState<{ id: string; name: string } | null>(null); + const [expanded, setExpanded] = useState>({}); const fileInputRef = useRef(null); + const me = useAuth(); + const writable = canWrite(me.level); function refresh() { setLoading(true); - fetch('/api/v1/interfaces') - .then(r => r.ok ? r.json() : []) - .then(data => setInterfaces(data)) + Promise.all([ + fetch('/api/v1/interfaces').then(r => r.ok ? r.json() : []), + fetch('/api/v1/folders').then(r => r.ok ? r.json() : []), + ]) + .then(([ifaceData, folderData]) => { + const list = Array.isArray(ifaceData) ? ifaceData : []; + const normalized: InterfaceListItem[] = list.map((item: any) => ({ + id: String(item.id || item.ID || ''), + name: String(item.name || item.Name || ''), + version: Number(item.version || item.Version || 0), + owner: item.owner ? String(item.owner) : '', + folder: item.folder ? String(item.folder) : '', + perm: (item.perm as InterfaceListItem['perm']) || 'write', + })).filter((item: InterfaceListItem) => item.id); + setInterfaces(normalized); + setFolders(Array.isArray(folderData) ? folderData : []); + }) .catch(() => {}) .finally(() => setLoading(false)); } @@ -71,13 +88,89 @@ export default function InterfaceList({ onLoad, onSelect, onEdit, onEditId, widt window.open(`?fs=${encodeURIComponent(id)}`, '_blank'); } + async function handleNewFolder(parent: string) { + const name = prompt('New folder name:'); + if (!name || !name.trim()) return; + const res = await fetch('/api/v1/folders', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: name.trim(), parent }), + }); + if (res.ok) refresh(); + } + + async function handleDeleteFolder(id: string, name: string) { + if (!confirm(`Delete folder "${name}"? Its panels and subfolders move up one level.`)) return; + const res = await fetch(`/api/v1/folders/${encodeURIComponent(id)}`, { method: 'DELETE' }); + if (res.ok) refresh(); + } + + // The caller may manage sharing when they can write and either own the panel + // or it is unmanaged (no owner recorded yet). + function canShare(item: InterfaceListItem): boolean { + return writable && (!item.owner || item.owner === me.user); + } + + function toggle(id: string) { + setExpanded(prev => ({ ...prev, [id]: !prev[id] })); + } + + function renderPanel(item: InterfaceListItem) { + return ( +
  • + onSelect?.(item.id)} title={item.owner ? `Owner: ${item.owner}` : undefined}> + {item.name || item.id} + {item.perm === 'read' && ro} + +
    + {item.perm === 'write' && } + + {writable && } + {canShare(item) && } + {item.perm === 'write' && } +
    +
  • + ); + } + + // Render a folder and everything beneath it (subfolders first, then panels). + function renderFolder(folder: Folder) { + const children = folders.filter(f => f.parent === folder.id); + const panels = interfaces.filter(i => i.folder === folder.id); + const open = expanded[folder.id] !== false; // default expanded + return ( +
  • +
    + toggle(folder.id)}> + {open ? '▾' : '▸'} {folder.name} + + {folder.perm === 'write' && ( +
    + + +
    + )} +
    + {open && ( +
      + {children.map(renderFolder)} + {panels.map(renderPanel)} +
    + )} +
  • + ); + } + + const rootFolders = folders.filter(f => !f.parent); + const rootPanels = interfaces.filter(i => !i.folder); + return ( ); } diff --git a/web/src/LogicEditor.tsx b/web/src/LogicEditor.tsx new file mode 100644 index 0000000..ffff047 --- /dev/null +++ b/web/src/LogicEditor.tsx @@ -0,0 +1,807 @@ +import { h, Fragment } from 'preact'; +import { useState, useEffect, useRef } from 'preact/hooks'; +import SearchableSelect from './SearchableSelect'; +import { checkExpr } from './lib/expr'; +import type { LogicGraph, LogicNode, LogicNodeKind, LogicWire, StateVar } from './lib/types'; + +interface DataSource { name: string; } +interface SignalInfo { name: string; } + +// Split a "ds:name" reference on the FIRST ':' only (EPICS PV names contain ':'). +function splitRef(ref: string): { ds: string; name: string } { + const i = ref.indexOf(':'); + if (i < 0) return { ds: '', name: '' }; + return { ds: ref.slice(0, i), name: ref.slice(i + 1) }; +} + +interface Props { + graph: LogicGraph; + onChange: (graph: LogicGraph) => void; + // Panel-local state variables, offered as quick write targets / signals. + statevars?: StateVar[]; + // Edit local state variables directly from the logic editor (the layout-side + // SignalTree is hidden while the logic tab is open). + onStateVarsChange?: (vars: StateVar[]) => void; +} + +// Visual geometry of a node block. Expressed in rem (relative to the root font +// size) so the whole flow editor scales coherently on high-DPI screens / when +// the base font is enlarged for accessibility, instead of staying pixel-tiny. +const REM = (() => { + if (typeof document === 'undefined') return 16; + return parseFloat(getComputedStyle(document.documentElement).fontSize) || 16; +})(); +const NODE_W = 11.5 * REM; // node width +const PORT_TOP = 2 * REM; // y of the first port row, relative to node top +const PORT_GAP = 1.375 * REM; // vertical spacing between stacked output ports +const PORT_R = 0.375 * REM; // port radius (half the 0.75rem dot) + +interface PaletteEntry { + kind: LogicNodeKind; + label: string; + params: Record; +} + +const PALETTE: PaletteEntry[] = [ + { kind: 'trigger.button', label: 'Button', params: { name: 'action' } }, + { kind: 'trigger.threshold', label: 'Threshold', params: { signal: '', op: '>', value: '0' } }, + { kind: 'trigger.change', label: 'On change', params: { signal: '' } }, + { kind: 'trigger.timer', label: 'Timer', params: { interval: '1000' } }, + { kind: 'trigger.loop', label: 'Panel loop', params: { interval: '1000' } }, + { kind: 'gate.and', label: 'AND gate', params: {} }, + { kind: 'flow.if', label: 'If / else', params: { cond: '' } }, + { kind: 'flow.loop', label: 'Loop', params: { mode: 'count', count: '3', cond: '' } }, + { kind: 'action.write', label: 'Write', params: { target: '', expr: '' } }, + { kind: 'action.delay', label: 'Delay', params: { ms: '500' } }, + { kind: 'action.accumulate', label: 'Accumulate', params: { array: 'data', expr: '' } }, + { kind: 'action.export', label: 'Export CSV', params: { array: 'data', filename: '' } }, + { kind: 'action.clear', label: 'Clear array', params: { array: 'data' } }, + { kind: 'action.log', label: 'Log', params: { expr: '', label: '' } }, +]; + +const KIND_LABEL: Record = { + 'trigger.button': 'Button', + 'trigger.threshold': 'Threshold', + 'trigger.change': 'On change', + 'trigger.timer': 'Timer', + 'trigger.loop': 'Panel loop', + 'gate.and': 'AND gate', + 'flow.if': 'If / else', + 'flow.loop': 'Loop', + 'action.write': 'Write', + 'action.delay': 'Delay', + 'action.accumulate': 'Accumulate', + 'action.export': 'Export CSV', + 'action.clear': 'Clear array', + 'action.log': 'Log', +}; + +const THRESHOLD_OPS = ['>', '<', '>=', '<=', '==', '!='] as const; + +function isTrigger(kind: LogicNodeKind): boolean { return kind.startsWith('trigger.'); } +function category(kind: LogicNodeKind): 'trigger' | 'gate' | 'flow' | 'action' { + if (kind.startsWith('trigger.')) return 'trigger'; + if (kind.startsWith('gate.')) return 'gate'; + if (kind.startsWith('flow.')) return 'flow'; + return 'action'; +} +function hasInput(kind: LogicNodeKind): boolean { return !isTrigger(kind); } + +interface Port { id: string; label: string; } +function outputs(kind: LogicNodeKind): Port[] { + if (kind === 'flow.if') return [{ id: 'then', label: 'then' }, { id: 'else', label: 'else' }]; + if (kind === 'flow.loop') return [{ id: 'body', label: 'body' }, { id: 'done', label: 'done' }]; + return [{ id: 'out', label: '' }]; +} +function nodeHeight(kind: LogicNodeKind): number { + return PORT_TOP + outputs(kind).length * PORT_GAP; +} + +function genId(): string { return 'n_' + Math.random().toString(36).slice(2, 9); } + +// Port anchors in canvas coordinates. +function inAnchor(n: LogicNode) { return { x: n.x, y: n.y + PORT_TOP }; } +function outAnchor(n: LogicNode, port: string) { + const ports = outputs(n.kind); + const i = Math.max(0, ports.findIndex(p => p.id === port)); + return { x: n.x + NODE_W, y: n.y + PORT_TOP + i * PORT_GAP }; +} + +function wirePathStr(x1: number, y1: number, x2: number, y2: number): string { + const dx = Math.max(40, Math.abs(x2 - x1) / 2); + return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`; +} + +export default function LogicEditor({ graph, onChange, statevars, onStateVarsChange }: Props) { + const nodes = graph.nodes; + const wires = graph.wires; + + const [selectedNode, setSelectedNode] = useState(null); + const [selectedWire, setSelectedWire] = useState(null); + + const [dataSources, setDataSources] = useState([]); + const [dsSignals, setDsSignals] = useState>({}); + + const canvasRef = useRef(null); + const dragNode = useRef<{ id: string; dx: number; dy: number; pushed: boolean } | null>(null); + const [pendingWire, setPendingWire] = useState<{ from: string; port: string; x: number; y: number } | null>(null); + const pendingRef = useRef(pendingWire); + pendingRef.current = pendingWire; + + // Undo/redo history + clipboard, scoped to the logic graph (separate from + // EditMode's widget/layout history). `graphRef` always holds the latest graph + // so the ref-based stacks read a stable value across renders. + const graphRef = useRef(graph); + graphRef.current = graph; + const undoStack = useRef([]); + const redoStack = useRef([]); + const clipboard = useRef<{ nodes: LogicNode[]; wires: LogicWire[] } | null>(null); + const [, setHistTick] = useState(0); // re-render so toolbar enabled-state updates + const bump = () => setHistTick(t => t + 1); + const canUndo = undoStack.current.length > 0; + const canRedo = redoStack.current.length > 0; + + 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 (!ds || ds === 'local' || ds === 'sys' || 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 {} + } + + // 'sys' exposes the engine's built-in time signals ({sys:time}, {sys:dt}). + const dsOptions = ['local', 'sys', ...dataSources]; + function signalOptions(ds: string): string[] { + if (ds === 'local') return (statevars ?? []).map(v => v.name); + if (ds === 'sys') return ['time', 'dt']; + return dsSignals[ds] ?? []; + } + + const selected = nodes.find(n => n.id === selectedNode) ?? null; + + useEffect(() => { + if (selected?.kind === 'trigger.threshold' || selected?.kind === 'trigger.change') { + loadSignals(splitRef(selected.params.signal ?? '').ds); + } + if (selected?.kind === 'action.write') loadSignals(splitRef(selected.params.target ?? '').ds); + }, [selectedNode, selected?.kind]); + + // ── History ──────────────────────────────────────────────────────────────── + // Push the current graph onto the undo stack (clearing redo). Call right + // before a discrete edit; for continuous gestures (node drag) call once. + function pushUndo() { + undoStack.current = [...undoStack.current.slice(-49), graphRef.current]; + redoStack.current = []; + bump(); + } + function undo() { + if (undoStack.current.length === 0) return; + const prev = undoStack.current[undoStack.current.length - 1]; + redoStack.current = [graphRef.current, ...redoStack.current]; + undoStack.current = undoStack.current.slice(0, -1); + setSelectedNode(null); + setSelectedWire(null); + onChange(prev); + bump(); + } + function redo() { + if (redoStack.current.length === 0) return; + const next = redoStack.current[0]; + undoStack.current = [...undoStack.current, graphRef.current]; + redoStack.current = redoStack.current.slice(1); + setSelectedNode(null); + setSelectedWire(null); + onChange(next); + bump(); + } + + // ── Graph mutators ───────────────────────────────────────────────────────── + // Apply a new graph, recording an undo entry unless `record` is false (used + // for the intermediate frames of a node drag, which record once on first move). + function setGraph(next: LogicGraph, record = true) { + if (record) pushUndo(); + onChange(next); + } + function setNodes(next: LogicNode[], record = true) { setGraph({ nodes: next, wires }, record); } + function setWires(next: LogicWire[]) { setGraph({ nodes, wires: next }); } + + function addNode(entry: PaletteEntry, x?: number, y?: number) { + const node: LogicNode = { + id: genId(), + kind: entry.kind, + x: x ?? 40 + (nodes.length % 5) * 30, + y: y ?? 40 + (nodes.length % 5) * 30, + params: { ...entry.params }, + }; + setGraph({ nodes: [...nodes, node], wires }); + setSelectedNode(node.id); + setSelectedWire(null); + } + + function patchParams(id: string, patch: Record) { + setNodes(nodes.map(n => (n.id === id ? { ...n, params: { ...n.params, ...patch } } : n))); + } + function moveNode(id: string, x: number, y: number, record = false) { + setNodes(nodes.map(n => (n.id === id ? { ...n, x, y } : n)), record); + } + function deleteNode(id: string) { + setGraph({ + nodes: nodes.filter(n => n.id !== id), + wires: wires.filter(w => w.from !== id && w.to !== id), + }); + if (selectedNode === id) setSelectedNode(null); + } + function addWire(from: string, port: string, to: string) { + if (from === to) return; + if (wires.some(w => w.from === from && (w.fromPort ?? 'out') === port && w.to === to)) return; + setWires([...wires, { from, to, ...(port !== 'out' ? { fromPort: port } : {}) }]); + } + function deleteWire(idx: number) { + setWires(wires.filter((_, i) => i !== idx)); + if (selectedWire === idx) setSelectedWire(null); + } + + // ── Clipboard ──────────────────────────────────────────────────────────── + // Copy the selected node (params deep-cloned). Single-selection only, but the + // paste remap is written generically so it extends to multi-select. + function copySelection() { + const n = graphRef.current.nodes.find(x => x.id === selectedNode); + if (!n) return; + clipboard.current = { nodes: [{ ...n, params: { ...n.params } }], wires: [] }; + } + function pasteClipboard() { + const clip = clipboard.current; + if (!clip || clip.nodes.length === 0) return; + const idMap = new Map(); + const cur = graphRef.current; + const newNodes = clip.nodes.map(n => { + const id = genId(); + idMap.set(n.id, id); + return { ...n, id, x: n.x + 30, y: n.y + 30, params: { ...n.params } }; + }); + const newWires = clip.wires + .filter(w => idMap.has(w.from) && idMap.has(w.to)) + .map(w => ({ ...w, from: idMap.get(w.from)!, to: idMap.get(w.to)! })); + setGraph({ nodes: [...cur.nodes, ...newNodes], wires: [...cur.wires, ...newWires] }); + if (newNodes.length === 1) { setSelectedNode(newNodes[0].id); setSelectedWire(null); } + } + + // ── Pointer geometry ─────────────────────────────────────────────────────── + function toCanvas(e: MouseEvent): { x: number; y: number } { + const el = canvasRef.current!; + const rect = el.getBoundingClientRect(); + return { x: e.clientX - rect.left + el.scrollLeft, y: e.clientY - rect.top + el.scrollTop }; + } + + // ── Node dragging ────────────────────────────────────────────────────────── + function startNodeDrag(e: MouseEvent, node: LogicNode) { + e.stopPropagation(); + const p = toCanvas(e); + dragNode.current = { id: node.id, dx: p.x - node.x, dy: p.y - node.y, pushed: false }; + setSelectedNode(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); + // Record a single undo entry per drag (on the first move, so a plain + // click-to-select doesn't create a spurious history step). + 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); + } + + // ── Wire drawing ─────────────────────────────────────────────────────────── + function startWire(e: MouseEvent, node: LogicNode, port: string) { + e.stopPropagation(); + const p = toCanvas(e); + setPendingWire({ from: node.id, port, 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 }); + } + // Tear down an in-progress wire drag. Called both on a plain release over + // empty canvas (window mouseup) and after a connection is made — the latter + // is essential because the target port's mouseup calls stopPropagation, so + // the window listener never fires and would otherwise leave a phantom wire + // following the cursor. + function endWire() { + pendingRef.current = null; + window.removeEventListener('mousemove', onWireMove); + window.removeEventListener('mouseup', onWireUp); + setPendingWire(null); + } + function onWireUp() { + endWire(); + } + function finishWire(target: LogicNode) { + const cur = pendingRef.current; + if (cur && hasInput(target.kind)) addWire(cur.from, cur.port, target.id); + endWire(); + } + + // ── Palette drag-and-drop ──────────────────────────────────────────────── + const DRAG_MIME = 'application/x-uopi-logic-node'; + function onCanvasDragOver(e: DragEvent) { + if (Array.from(e.dataTransfer?.types ?? []).includes(DRAG_MIME)) { + e.preventDefault(); + if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; + } + } + function onCanvasDrop(e: DragEvent) { + const kind = e.dataTransfer?.getData(DRAG_MIME); + if (!kind) return; + e.preventDefault(); + const entry = PALETTE.find(p => p.kind === kind); + if (!entry) return; + const p = toCanvas(e); + // Drop point becomes the node's top-left, offset so the cursor lands inside. + addNode(entry, Math.max(0, p.x - NODE_W / 2), Math.max(0, p.y - PORT_TOP / 2)); + } + + // ── Keyboard shortcuts (delete / undo / redo / copy / paste) ──────────────── + useEffect(() => { + function onKey(e: KeyboardEvent) { + const t = e.target as Element; + // While typing in a field, leave native text editing shortcuts alone. + if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT')) return; + const mod = e.ctrlKey || e.metaKey; + if (mod && e.key.toLowerCase() === 'z' && !e.shiftKey) { e.preventDefault(); undo(); return; } + if (mod && (e.key.toLowerCase() === 'y' || (e.key.toLowerCase() === 'z' && e.shiftKey))) { e.preventDefault(); redo(); return; } + if (mod && e.key.toLowerCase() === 'c') { e.preventDefault(); copySelection(); return; } + if (mod && e.key.toLowerCase() === 'v') { e.preventDefault(); pasteClipboard(); return; } + if (e.key !== 'Delete' && e.key !== 'Backspace') return; + if (selectedWire !== null) deleteWire(selectedWire); + else if (selectedNode) deleteNode(selectedNode); + } + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [selectedNode, selectedWire, nodes, wires]); + + const byId = new Map(nodes.map(n => [n.id, n])); + + // Array names already referenced by accumulate/export/clear nodes, offered as + // autocomplete suggestions so the same array is reused across nodes. + const arrayNames = Array.from(new Set( + nodes.map(n => n.params.array).filter((a): a is string => !!a) + )); + + // Append a {ds:name} reference into a node's expression field. + function insertRef(id: string, field: string, ds: string, sig: string) { + const cur = (byId.get(id)?.params[field]) ?? ''; + patchParams(id, { [field]: `${cur}{${ds}:${sig}}` }); + } + + return ( +
    +
    +
    + + +
    +
    Triggers
    + {PALETTE.filter(e => category(e.kind) === 'trigger').map(paletteBtn)} +
    Logic
    + {PALETTE.filter(e => category(e.kind) === 'gate' || category(e.kind) === 'flow').map(paletteBtn)} +
    Actions
    + {PALETTE.filter(e => category(e.kind) === 'action').map(paletteBtn)} +
    + Triggers start a flow. Drag a node's right port to another node's left port to connect. + In expressions, reference signals as {'{ds:name}'} and local vars by name. +
    + {onStateVarsChange && ( + + )} + + {arrayNames.map(a => +
    + +
    { setSelectedNode(null); setSelectedWire(null); }} + onDragOver={onCanvasDragOver} + onDrop={onCanvasDrop}> +
    + + {wires.map((w, idx) => { + const a = byId.get(w.from); + const b = byId.get(w.to); + if (!a || !b) return null; + const p1 = outAnchor(a, w.fromPort ?? 'out'); + const p2 = inAnchor(b); + return ( + { e.stopPropagation(); setSelectedWire(idx); setSelectedNode(null); }} /> + ); + })} + {pendingWire && (() => { + const a = byId.get(pendingWire.from); + if (!a) return null; + const p1 = outAnchor(a, pendingWire.port); + return ; + })()} + + + {nodes.map(node => ( +
    startNodeDrag(e, node)} + onMouseUp={() => { if (pendingRef.current) finishWire(node); }}> +
    + {KIND_LABEL[node.kind]} + +
    +
    {nodeSummary(node)}
    + + {hasInput(node.kind) && ( +
    e.stopPropagation()} + onMouseUp={(e) => { e.stopPropagation(); finishWire(node); }} /> + )} + {outputs(node.kind).map((port, i) => ( + + {port.label && ( + {port.label} + )} +
    startWire(e, node, port.id)} /> + + ))} +
    + ))} +
    +
    + +
    + {!selected &&
    Select a node to edit it, or add one from the palette.
    } + {selected && ( + +
    {KIND_LABEL[selected.kind]}
    + + {selected.kind === 'trigger.button' && ( +
    + + patchParams(selected.id, { name: (e.target as HTMLInputElement).value })} /> +

    Fires when a button widget's Action is set to this name.

    +
    + )} + + {(selected.kind === 'trigger.threshold' || selected.kind === 'trigger.change') && (() => { + const { ds, name } = splitRef(selected.params.signal ?? ''); + return ( + +
    + + { loadSignals(newDs); patchParams(selected.id, { signal: `${newDs}:` }); }} /> +
    +
    + + patchParams(selected.id, { signal: `${ds}:${sig}` })} + placeholder={ds ? 'Search signals…' : 'Select data source first'} /> +
    + {selected.kind === 'trigger.threshold' && ( +
    +
    + + +
    +
    + + patchParams(selected.id, { value: (e.target as HTMLInputElement).value })} /> +
    +
    + )} + {selected.kind === 'trigger.change' &&

    Fires whenever the signal's value changes.

    } +
    + ); + })()} + + {(selected.kind === 'trigger.timer' || selected.kind === 'trigger.loop') && ( +
    + + patchParams(selected.id, { interval: (e.target as HTMLInputElement).value })} /> + {selected.kind === 'trigger.loop' &&

    Runs once on panel load, then on every interval.

    } +
    + )} + + {selected.kind === 'gate.and' && ( +

    Wire two or more triggers into this gate. The flow continues only when all + inputs are satisfied (e.g. a Button click while a Threshold is currently true).

    + )} + + {selected.kind === 'flow.if' && ( + patchParams(selected.id, { cond: v })} + onInsert={(ds, sig) => insertRef(selected.id, 'cond', ds, sig)} + dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals} + hint="True → 'then' port, false → 'else' port. e.g. {stub:level} > 5 && temp < 100. {sys:time}=epoch seconds, {sys:dt}=seconds since the trigger last fired." /> + )} + + {selected.kind === 'flow.loop' && ( + +
    + + +
    + {(selected.params.mode ?? 'count') === 'count' ? ( +
    + + patchParams(selected.id, { count: (e.target as HTMLInputElement).value })} /> +
    + ) : ( + patchParams(selected.id, { cond: v })} + onInsert={(ds, sig) => insertRef(selected.id, 'cond', ds, sig)} + dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals} + hint="Loops the 'body' port while true (capped). e.g. {stub:level} < 90" /> + )} +

    Runs the body port repeatedly, then continues on done.

    +
    + )} + + {selected.kind === 'action.write' && (() => { + const { ds, name } = splitRef(selected.params.target ?? ''); + return ( + +
    + + { loadSignals(newDs); patchParams(selected.id, { target: `${newDs}:` }); }} /> +
    +
    + + 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="e.g. ({stub:a} - {stub:b}) / {sys:dt} for a rate — or a constant like 42. {sys:time}=epoch seconds, {sys:dt}=seconds since the trigger last fired." /> +
    + ); + })()} + + {selected.kind === 'action.delay' && ( +
    + + patchParams(selected.id, { ms: (e.target as HTMLInputElement).value })} /> +
    + )} + + {selected.kind === 'action.accumulate' && ( + +
    + + patchParams(selected.id, { array: (e.target as HTMLInputElement).value })} /> +
    + patchParams(selected.id, { expr: v })} + onInsert={(ds2, sig) => insertRef(selected.id, 'expr', ds2, sig)} + dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals} + hint="Appends this value (with a timestamp) to the array. e.g. {stub:level}, or {sys:time} for the clock." /> +
    + )} + + {selected.kind === 'action.export' && ( + +
    + + patchParams(selected.id, { array: (e.target as HTMLInputElement).value })} /> +
    +
    + + patchParams(selected.id, { filename: (e.target as HTMLInputElement).value })} /> +

    Downloads the array as CSV (timestamp_ms, iso, value).

    +
    +
    + )} + + {selected.kind === 'action.clear' && ( +
    + + patchParams(selected.id, { array: (e.target as HTMLInputElement).value })} /> +

    Empties the named array (e.g. to start a fresh capture).

    +
    + )} + + {selected.kind === 'action.log' && ( + +
    + + patchParams(selected.id, { label: (e.target as HTMLInputElement).value })} /> +
    + patchParams(selected.id, { expr: v })} + onInsert={(ds2, sig) => insertRef(selected.id, 'expr', ds2, sig)} + dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals} + hint="Logs this value to the browser console — handy for debugging a flow." /> +
    + )} + + +
    + )} +
    +
    + ); + + function paletteBtn(entry: PaletteEntry) { + return ( + + ); + } +} + +// Expression input with a signal-insert helper and live validation. +function ExprField({ label, value, onChange, onInsert, dsOptions, signalOptions, loadSignals, hint }: { + label: string; + value: string; + onChange: (v: string) => void; + onInsert: (ds: string, sig: string) => void; + dsOptions: string[]; + signalOptions: (ds: string) => string[]; + loadSignals: (ds: string) => void; + hint: string; +}) { + const [insDs, setInsDs] = useState(''); + const err = checkExpr(value); + return ( +
    + + onChange((e.target as HTMLInputElement).value)} /> + {err &&

    {err}

    } +
    + { setInsDs(ds); loadSignals(ds); }} placeholder="ds…" /> + onInsert(insDs, sig)} + placeholder={insDs ? '+ insert signal' : 'pick ds first'} /> +
    +

    {hint}

    +
    + ); +} + +// One-line summary shown in a node block's body. +function nodeSummary(n: LogicNode): string { + switch (n.kind) { + case 'trigger.button': return n.params.name || '(unnamed)'; + case 'trigger.threshold': return `${n.params.signal || '?'} ${n.params.op || '>'} ${n.params.value || '0'}`; + case 'trigger.change': return `Δ ${n.params.signal || '?'}`; + case 'trigger.timer': return `every ${n.params.interval || '?'} ms`; + case 'trigger.loop': return `load + every ${n.params.interval || '?'} ms`; + case 'gate.and': return 'all inputs'; + case 'flow.if': return n.params.cond || '(no condition)'; + case 'flow.loop': return (n.params.mode ?? 'count') === 'count' + ? `repeat ${n.params.count || '0'}×` + : `while ${n.params.cond || '?'}`; + case 'action.write': return `${n.params.target || '?'} = ${n.params.expr || ''}`; + case 'action.delay': return `wait ${n.params.ms || '0'} ms`; + case 'action.accumulate': return `${n.params.array || '?'} ← ${n.params.expr || ''}`; + case 'action.export': return `export ${n.params.array || '?'} → csv`; + case 'action.clear': return `clear ${n.params.array || '?'}`; + case 'action.log': return `log ${n.params.label ? n.params.label + ': ' : ''}${n.params.expr || ''}`; + default: return ''; + } +} + +// Inline editor for the panel's local state variables, shown in the logic +// palette so they can be created without leaving the (full-width) logic tab. +function LocalVars({ statevars, onChange }: { + statevars: StateVar[]; + onChange: (vars: StateVar[]) => void; +}) { + const [open, setOpen] = useState(false); + const [name, setName] = useState(''); + const [type, setType] = useState<'number' | 'bool' | 'string'>('number'); + const [initial, setInitial] = useState('0'); + + function add() { + const n = name.trim(); + if (!n) return; + onChange([...statevars.filter(v => v.name !== n), { name: n, type, initial }]); + setName(''); + setInitial('0'); + } + + return ( +
    +
    Local vars
    + {statevars.length === 0 &&
    None yet.
    } + {statevars.map(v => ( +
    + {v.name} + +
    + ))} + {!open && ( + + )} + {open && ( +
    + setName((e.target as HTMLInputElement).value)} /> + + setInitial((e.target as HTMLInputElement).value)} /> +
    + + +
    +
    + )} +
    + ); +} diff --git a/web/src/PlotPanel.tsx b/web/src/PlotPanel.tsx deleted file mode 100644 index 707a974..0000000 --- a/web/src/PlotPanel.tsx +++ /dev/null @@ -1,424 +0,0 @@ -import { h } from 'preact'; -import { useEffect, useRef, useState } from 'preact/hooks'; -import uPlot from 'uplot'; -import { getSignalStore, getMetaStore } from './lib/stores'; -import type { SignalRef, SignalMeta } from './lib/types'; - -// ── Public types ────────────────────────────────────────────────────────────── - -export interface PlotSignalStyle { - color: string; - lineWidth: number; // px; 0 = no line (markers-only) - lineDash: number[]; // [] = solid, [8,4] = dashed, [2,4] = dotted - markerSize: number; // diameter px; 0 = no markers -} - -export interface PlotSignal { - ref: SignalRef; - style: PlotSignalStyle; -} - -interface Props { - signals: PlotSignal[]; - onRemove: (ref: SignalRef) => void; - onStyleChange: (ref: SignalRef, style: PlotSignalStyle) => void; -} - -// ── Constants ───────────────────────────────────────────────────────────────── - -interface Buf { ts: number[]; vals: number[]; } - -const RING_MAX = 200_000; - -const TIME_WINDOWS = [ - { label: '10s', secs: 10 }, - { label: '30s', secs: 30 }, - { label: '1m', secs: 60 }, - { label: '5m', secs: 300 }, - { label: '15m', secs: 900 }, - { label: '1h', secs: 3600 }, -]; - -const LINE_WIDTHS: Array<{ label: string; value: number }> = [ - { label: '✕', value: 0 }, - { label: '1', value: 1 }, - { label: '1.5', value: 1.5 }, - { label: '2', value: 2 }, - { label: '3', value: 3 }, -]; - -const LINE_DASHES: Array<{ label: string; value: number[] }> = [ - { label: '━━', value: [] }, - { label: '╍╍', value: [8, 4] }, - { label: '⋯', value: [2, 4] }, -]; - -const MARKER_SIZES: Array<{ label: string; value: number }> = [ - { label: '✕', value: 0 }, - { label: 'S', value: 3 }, - { label: 'M', value: 5 }, - { label: 'L', value: 8 }, -]; - -// ── Pure helpers ────────────────────────────────────────────────────────────── - -interface Stat { min: number; max: number; mean: number; last: number; } - -function pushSample(buf: Buf, t: number, v: number) { - buf.ts.push(t); - buf.vals.push(v); - if (buf.ts.length > RING_MAX) { - buf.ts.splice(0, buf.ts.length - RING_MAX); - buf.vals.splice(0, buf.vals.length - RING_MAX); - } -} - -function buildData(signals: PlotSignal[], bufs: Map, windowSec: number): uPlot.AlignedData { - const cutoff = Date.now() / 1000 - windowSec; - - const allTs = new Set(); - for (const s of signals) { - const buf = bufs.get(`${s.ref.ds}\0${s.ref.name}`); - if (buf) for (const t of buf.ts) if (t >= cutoff) allTs.add(t); - } - const sorted = Array.from(allTs).sort((a, b) => a - b); - if (sorted.length === 0) return [[], ...signals.map(() => [])] as uPlot.AlignedData; - - return [ - sorted, - ...signals.map(s => { - const buf = bufs.get(`${s.ref.ds}\0${s.ref.name}`); - if (!buf || buf.ts.length === 0) return sorted.map(() => null); - - // Step-hold: carry the most recent sample forward so signals with - // different update rates align correctly on the shared time axis. - let bi = 0; - let hold: number | null = null; - while (bi < buf.ts.length && buf.ts[bi] < cutoff) hold = buf.vals[bi++]; - - return sorted.map(t => { - while (bi < buf.ts.length && buf.ts[bi] <= t) hold = buf.vals[bi++]; - return hold; - }); - }), - ] as uPlot.AlignedData; -} - -function computeStats(buf: Buf | undefined, windowSec: number): Stat | null { - if (!buf || buf.ts.length === 0) return null; - const cutoff = Date.now() / 1000 - windowSec; - let min = Infinity, max = -Infinity, sum = 0, n = 0, last = NaN; - buf.ts.forEach((t, i) => { - if (t >= cutoff) { const v = buf.vals[i]; if (v < min) min = v; if (v > max) max = v; sum += v; n++; last = v; } - }); - return n > 0 ? { min, max, mean: sum / n, last } : null; -} - -function fmtN(v: number | undefined | null): string { - if (v === undefined || v === null || !isFinite(v)) return '—'; - const a = Math.abs(v); - if (a === 0) return '0'; - if (a >= 10000 || (a < 0.001 && a > 0)) return v.toExponential(3); - return v.toPrecision(4).replace(/\.?0+$/, ''); -} - -function dashEq(a: number[], b: number[]): boolean { - return a.length === b.length && a.every((v, i) => v === b[i]); -} - -function styleKey(s: PlotSignal): string { - return `${s.ref.ds}:${s.ref.name}:${s.style.color}:${s.style.lineWidth}:${s.style.lineDash.join(',')}:${s.style.markerSize}`; -} - -function makeSeries(s: PlotSignal): uPlot.Series { - const { color, lineWidth, lineDash, markerSize } = s.style; - return { - label: s.ref.name, - stroke: color, - width: lineWidth, - dash: lineDash.length > 0 ? lineDash : undefined, - points: { - show: markerSize > 0, - size: markerSize, - stroke: color, - fill: color, - }, - spanGaps: false, - }; -} - -// ── Component ───────────────────────────────────────────────────────────────── - -export default function PlotPanel({ signals, onRemove, onStyleChange }: Props) { - const chartRef = useRef(null); - const [windowSec, setWindowSec] = useState(60); - const [stats, setStats] = useState>([]); - const [metas, setMetas] = useState>(new Map()); - const [editingKey, setEditingKey] = useState(null); - - const bufsRef = useRef>(new Map()); - const signalsKey = signals.map(styleKey).join('|'); - - useEffect(() => { - if (!chartRef.current) return; - const el = chartRef.current; - - for (const s of signals) { - const key = `${s.ref.ds}\0${s.ref.name}`; - if (!bufsRef.current.has(key)) bufsRef.current.set(key, { ts: [], vals: [] }); - } - - if (signals.length === 0) return; - - let currentWindow = windowSec; - - const uplot = new uPlot( - { - width: Math.max(el.clientWidth, 200), - height: Math.max(el.clientHeight, 100), - legend: { show: false }, - cursor: { show: true }, - axes: [ - { stroke: '#94a3b8', grid: { stroke: '#2d3748' }, ticks: { stroke: '#475569' } }, - { - stroke: '#94a3b8', - grid: { stroke: '#2d3748' }, - ticks: { stroke: '#475569' }, - size: 55, - values: (_u: uPlot, vals: (number | null)[]) => - vals.map(v => (v === null || v === undefined) ? '' : fmtN(v)), - }, - ], - scales: { - x: { - time: true, - range: (): [number, number] => { - const now = Date.now() / 1000; - return [now - currentWindow, now]; - }, - }, - y: { - auto: true, - range: (_u: uPlot, min: number, max: number): [number, number] => { - if (!isFinite(min) || !isFinite(max) || min === max) { - const mid = isFinite(min) ? min : 0; - return [mid - 1, mid + 1]; - } - return [min, max]; - }, - }, - }, - series: [{}, ...signals.map(makeSeries)], - }, - buildData(signals, bufsRef.current, windowSec), - el, - ); - - const unsubs: Array<() => void> = []; - let dirty = false; - - signals.forEach(s => { - const key = `${s.ref.ds}\0${s.ref.name}`; - - const unsubV = getSignalStore(s.ref).subscribe(sv => { - if (sv.value === null || sv.ts === null) return; - const v = typeof sv.value === 'number' ? sv.value : - Array.isArray(sv.value) ? sv.value[0] : - parseFloat(String(sv.value)); - if (!isFinite(v)) return; - const buf = bufsRef.current.get(key); - if (buf) { pushSample(buf, new Date(sv.ts).getTime() / 1000, v); dirty = true; } - }); - unsubs.push(unsubV); - - const unsubM = getMetaStore(s.ref).subscribe(m => { - if (m) setMetas(prev => new Map(prev).set(key, m)); - }); - unsubs.push(unsubM); - }); - - (el as any)._setWindow = (secs: number) => { currentWindow = secs; dirty = true; }; - - let lastDrawTime = 0; - let wasVisible = false; - let rafId = requestAnimationFrame(function tick(ts) { - const isVisible = el.clientWidth > 0 && el.clientHeight > 0; - const becameVisible = isVisible && !wasVisible; - wasVisible = isVisible; - if (isVisible && (dirty || becameVisible || ts - lastDrawTime >= 1000)) { - dirty = false; - lastDrawTime = ts; - if (becameVisible) { - const nw = el.clientWidth, nh = el.clientHeight; - if (nw > 0 && nh > 0) uplot.setSize({ width: nw, height: nh }); - } - uplot.setData(buildData(signals, bufsRef.current, currentWindow)); - setStats(signals.map(s => - computeStats(bufsRef.current.get(`${s.ref.ds}\0${s.ref.name}`), currentWindow) - )); - } - rafId = requestAnimationFrame(tick); - }); - - const ro = new ResizeObserver(() => { - const w = el.clientWidth, h = el.clientHeight; - if (w > 0 && h > 0) uplot.setSize({ width: w, height: h }); - }); - ro.observe(el); - - return () => { - cancelAnimationFrame(rafId); - ro.disconnect(); - unsubs.forEach(u => u()); - uplot.destroy(); - delete (el as any)._setWindow; - }; - }, [signalsKey]); - - useEffect(() => { - if (chartRef.current) (chartRef.current as any)._setWindow?.(windowSec); - }, [windowSec]); - - // ── Render ────────────────────────────────────────────────────────────────── - - return ( -
    -
    - Live Plot -
    - {TIME_WINDOWS.map(tw => ( - - ))} -
    -
    - -
    - {signals.length === 0 ? ( -
    - Right-click any widget in the HMI view and choose Plot to add signals here. -
    - ) : ( -
    -
    - {signals.map((s, i) => { - const key = `${s.ref.ds}\0${s.ref.name}`; - const st = stats[i] ?? null; - const meta = metas.get(key); - const unit = meta?.unit ?? ''; - const isEditing = editingKey === key; - const { color, lineWidth, lineDash, markerSize } = s.style; - - function update(patch: Partial) { - onStyleChange(s.ref, { ...s.style, ...patch }); - } - - return ( -
    - {/* Header row */} -
    - - - {s.ref.name} - - - -
    - - {/* Stats */} - - - - - - - - - - -
    Last{st ? `${fmtN(st.last)}${unit ? ' ' + unit : ''}` : '—'}
    Min{st ? fmtN(st.min) : '—'}
    Max{st ? fmtN(st.max) : '—'}
    Mean{st ? fmtN(st.mean) : '—'}
    - - {/* Inline style editor */} - {isEditing && ( -
    - {/* Color */} -
    - Color - update({ color: (e.target as HTMLInputElement).value })} - /> -
    - - {/* Line width */} -
    - Width -
    - {LINE_WIDTHS.map(lw => ( - - ))} -
    -
    - - {/* Line dash */} -
    - Line -
    - {LINE_DASHES.map(ld => ( - - ))} -
    -
    - - {/* Markers */} -
    - Markers -
    - {MARKER_SIZES.map(ms => ( - - ))} -
    -
    -
    - )} -
    - ); - })} -
    -
    -
    - )} -
    -
    - ); -} diff --git a/web/src/PlotPanelCanvas.tsx b/web/src/PlotPanelCanvas.tsx new file mode 100644 index 0000000..c8f2f23 --- /dev/null +++ b/web/src/PlotPanelCanvas.tsx @@ -0,0 +1,107 @@ +import { h } from 'preact'; +import { useEffect } from 'preact/hooks'; +import type { Interface, Widget, SignalRef, PlotLayout } from './lib/types'; +import PlotWidget from './widgets/PlotWidget'; +import SplitLayout from './SplitLayout'; +import { genWidgetId } from './EditCanvas'; +import { splitLeaf, removeLeaf, setRatioAtPath, countLeaves } from './lib/plotLayout'; + +interface Props { + iface: Interface; + selectedIds: string[]; + onSelect: (ids: string[]) => void; + /** Apply a combined widgets + layout change as one undoable step. */ + onChange: (widgets: Widget[], layout: PlotLayout) => void; +} + +function newPlotWidget(): Widget { + return { + id: genWidgetId(), + type: 'plot', + x: 0, y: 0, w: 800, h: 500, + signals: [], + options: { plotType: 'timeseries', timeWindow: '60', legend: 'bottom' }, + }; +} + +export default function PlotPanelCanvas({ iface, selectedIds, onSelect, onChange }: Props) { + const layout = iface.layout ?? { type: 'leaf', widget: iface.widgets[0]?.id ?? '' }; + const byId = new Map(iface.widgets.map(w => [w.id, w])); + const single = countLeaves(layout) <= 1; + + // When there is only one pane, it is always the selected one. Also recover + // from a selection that points at a widget no longer present in this panel. + useEffect(() => { + const onlyId = single ? (layout.type === 'leaf' ? layout.widget : iface.widgets[0]?.id) : null; + if (onlyId && selectedIds[0] !== onlyId) { + onSelect([onlyId]); + } else if (!single && selectedIds.length === 1 && !byId.has(selectedIds[0])) { + onSelect([]); + } + }, [single, layout, selectedIds.join(',')]); + + function handleSplit(widgetId: string, dir: 'h' | 'v') { + const w = newPlotWidget(); + const nextLayout = splitLeaf(layout, widgetId, dir, w.id); + onChange([...iface.widgets, w], nextLayout); + onSelect([w.id]); + } + + function handleClose(widgetId: string) { + if (single) return; + const nextLayout = removeLeaf(layout, widgetId); + onChange(iface.widgets.filter(w => w.id !== widgetId), nextLayout); + onSelect([]); + } + + function handleResize(path: number[], ratio: number) { + onChange(iface.widgets, setRatioAtPath(layout, path, ratio)); + } + + function handleDrop(e: DragEvent, widget: Widget) { + e.preventDefault(); + e.stopPropagation(); + const json = e.dataTransfer?.getData('application/json'); + if (!json) return; + let sig: SignalRef; + try { sig = JSON.parse(json); } catch { return; } + if (widget.signals.some(s => s.ds === sig.ds && s.name === sig.name)) return; + const updated = { ...widget, signals: [...widget.signals, sig] }; + onChange(iface.widgets.map(w => w.id === updated.id ? updated : w), layout); + onSelect([updated.id]); + } + + function renderLeaf(widgetId: string) { + const widget = byId.get(widgetId); + const selected = selectedIds.includes(widgetId); + return ( +
    onSelect([widgetId])} + onDragOver={(e: DragEvent) => { e.preventDefault(); if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; }} + onDrop={(e: DragEvent) => widget && handleDrop(e, widget)} + > + {selected &&
    editing
    } + {widget + ? + :
    missing plot
    } + +
    e.stopPropagation()}> + + + +
    + + {widget && widget.signals.length === 0 && ( +
    Drag a signal here, then configure it in the properties pane →
    + )} +
    + ); + } + + return ( +
    + +
    + ); +} diff --git a/web/src/PropertiesPane.tsx b/web/src/PropertiesPane.tsx index eeb5e8c..33e0683 100644 --- a/web/src/PropertiesPane.tsx +++ b/web/src/PropertiesPane.tsx @@ -1,5 +1,5 @@ import { h } from 'preact'; -import { useState } from 'preact/hooks'; +import { useState, useEffect } from 'preact/hooks'; import type { Widget, Interface } from './lib/types'; interface Props { @@ -21,17 +21,25 @@ function Field({ label, children }: { label: string; children: any }) { } function TextInput({ value, onCommit }: { value: string; onCommit: (v: string) => void }) { - const [local, setLocal] = useState(value); + const [local, setLocal] = useState(value || ''); + // Sync when external value changes (e.g. different widget selected) - if (local !== value && document.activeElement?.tagName !== 'INPUT') { - setLocal(value); - } + useEffect(() => { + setLocal(value || ''); + }, [value]); + return ( setLocal((e.target as HTMLInputElement).value)} onChange={(e) => onCommit((e.target as HTMLInputElement).value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + onCommit((e.target as HTMLInputElement).value); + (e.target as HTMLInputElement).blur(); + } + }} /> ); } @@ -79,31 +87,37 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
    {/* Canvas-level properties */}
    -
    Canvas
    +
    {iface.kind === 'plot' ? 'Plot panel' : 'Canvas'}
    onIfaceChange({ ...iface, name: v })} /> - - onIfaceChange({ ...iface, w: parseInt((e.target as HTMLInputElement).value, 10) || iface.w })} - /> - - - onIfaceChange({ ...iface, h: parseInt((e.target as HTMLInputElement).value, 10) || iface.h })} - /> - + {/* Plot panels fill the viewport via the split layout — fixed px size + is not meaningful, so width/height are hidden. */} + {iface.kind !== 'plot' && ( +
    + + onIfaceChange({ ...iface, w: parseInt((e.target as HTMLInputElement).value, 10) || iface.w })} + /> + + + onIfaceChange({ ...iface, h: parseInt((e.target as HTMLInputElement).value, 10) || iface.h })} + /> + +
    + )}
    {w && ( @@ -253,6 +267,23 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange, + +
    + + Fires a Button trigger node (define flows in the Logic tab) +
    +
    )} diff --git a/web/src/SearchableSelect.tsx b/web/src/SearchableSelect.tsx new file mode 100644 index 0000000..24bb9e8 --- /dev/null +++ b/web/src/SearchableSelect.tsx @@ -0,0 +1,58 @@ +import { h } from 'preact'; +import { useState, useMemo } from 'preact/hooks'; + +// A dropdown with an inline search box. Extracted from SyntheticWizard so the +// same searchable picker can be reused (e.g. the logic editor's signal field). +export default function SearchableSelect({ + value, + options, + onSelect, + placeholder = 'Select…', +}: { + value: string; + options: string[]; + onSelect: (val: string) => void; + placeholder?: string; +}) { + const [open, setOpen] = useState(false); + const [filter, setFilter] = useState(''); + + const filtered = useMemo(() => { + const f = filter.toLowerCase(); + return options.filter(o => o.toLowerCase().includes(f)); + }, [options, filter]); + + return ( +
    +
    setOpen(!open)}> + {value || {placeholder}} + {open ? '▴' : '▾'} +
    + {open && ( +
    + setFilter((e.target as HTMLInputElement).value)} + onClick={(e) => e.stopPropagation()} + /> +
    + {filtered.length === 0 &&
    No matches
    } + {filtered.map(opt => ( +
    { onSelect(opt); setOpen(false); setFilter(''); }} + > + {opt} +
    + ))} +
    +
    + )} + {open &&
    setOpen(false)} />} +
    + ); +} diff --git a/web/src/ShareDialog.tsx b/web/src/ShareDialog.tsx new file mode 100644 index 0000000..1d8def4 --- /dev/null +++ b/web/src/ShareDialog.tsx @@ -0,0 +1,181 @@ +import { h } from 'preact'; +import { useState, useEffect } from 'preact/hooks'; +import type { PanelACL, Grant, Folder } from './lib/types'; + +interface Props { + ifaceId: string; + ifaceName: string; + folders: Folder[]; + onClose: () => void; + onSaved: () => void; +} + +// ShareDialog edits a single panel's sharing settings: its folder, public +// visibility, and explicit per-user / per-group grants. Only the owner can +// reach it (the caller is gated upstream in InterfaceList). +export default function ShareDialog({ ifaceId, ifaceName, folders, onClose, onSaved }: Props) { + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + const [owner, setOwner] = useState(''); + const [folder, setFolder] = useState(''); + const [pub, setPub] = useState<'' | 'read' | 'write'>(''); + const [grants, setGrants] = useState([]); + const [userGroups, setUserGroups] = useState([]); + + // New-grant row state. + const [newKind, setNewKind] = useState<'user' | 'group'>('user'); + const [newName, setNewName] = useState(''); + const [newPerm, setNewPerm] = useState<'read' | 'write'>('read'); + + useEffect(() => { + Promise.all([ + fetch(`/api/v1/interfaces/${encodeURIComponent(ifaceId)}/acl`).then(r => r.ok ? r.json() : null), + fetch('/api/v1/usergroups').then(r => r.ok ? r.json() : []), + ]) + .then(([acl, groups]: [PanelACL | null, string[]]) => { + if (acl) { + setOwner(acl.owner || ''); + setFolder(acl.folder || ''); + setPub(acl.public || ''); + setGrants(Array.isArray(acl.grants) ? acl.grants : []); + } + setUserGroups(Array.isArray(groups) ? groups : []); + }) + .catch(() => setError('Failed to load sharing settings.')) + .finally(() => setLoading(false)); + }, [ifaceId]); + + function addGrant() { + const name = newName.trim(); + if (!name) return; + // Replace an existing grant for the same kind+name rather than duplicating. + const next = grants.filter(g => !(g.kind === newKind && g.name === name)); + next.push({ kind: newKind, name, perm: newPerm }); + setGrants(next); + setNewName(''); + } + + function removeGrant(idx: number) { + setGrants(grants.filter((_, i) => i !== idx)); + } + + async function save() { + setSaving(true); + setError(''); + try { + const res = await fetch(`/api/v1/interfaces/${encodeURIComponent(ifaceId)}/acl`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ folder, public: pub, grants }), + }); + if (!res.ok) { + const msg = await res.json().catch(() => null); + throw new Error(msg?.error || `Save failed (${res.status})`); + } + onSaved(); + onClose(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + setSaving(false); + } + } + + return ( +
    +
    e.stopPropagation()} style="max-width: 560px;"> +
    + Share — {ifaceName || ifaceId} + +
    + +
    + {loading ? ( +

    Loading…

    + ) : ( +
    + {owner && ( +

    Owner: {owner}

    + )} + +
    Folder
    +
    + +
    + +
    Public access
    +
    + +
    + +
    Shared with
    + {grants.length === 0 ? ( +

    Not shared with anyone yet.

    + ) : ( +
      + {grants.map((g, idx) => ( +
    • + + + {g.kind} + + {g.name} + ({g.perm}) + + +
    • + ))} +
    + )} + +
    + + {newKind === 'group' ? ( + + ) : ( + setNewName((e.target as HTMLInputElement).value)} + onKeyDown={e => e.key === 'Enter' && addGrant()} + /> + )} + + +
    + + {error &&

    {error}

    } +
    + )} +
    + + +
    +
    + ); +} diff --git a/web/src/SignalTree.tsx b/web/src/SignalTree.tsx index 4213f4c..0aa16c1 100644 --- a/web/src/SignalTree.tsx +++ b/web/src/SignalTree.tsx @@ -1,6 +1,6 @@ import { h, Fragment } from 'preact'; import { useState, useEffect, useRef, useMemo } from 'preact/hooks'; -import type { SignalRef } from './lib/types'; +import type { SignalRef, StateVar } from './lib/types'; import SyntheticWizard from './SyntheticWizard'; import SyntheticEditor from './SyntheticEditor'; import GroupsTree from './GroupsTree'; @@ -46,9 +46,14 @@ function saveCustom(signals: Array<{ ds: string; name: string }>) { interface Props { onDragStart?: (sig: SignalRef) => void; width?: number; + // Current interface id — scopes panel-visibility synthetic signals. + panelId?: string; + // Panel-local state variables and a setter (edit mode only). + statevars?: StateVar[]; + onStateVarsChange?: (vars: StateVar[]) => void; } -export default function SignalTree({ onDragStart, width }: Props) { +export default function SignalTree({ onDragStart, width, panelId, statevars, onStateVarsChange }: Props) { const [treeTab, setTreeTab] = useState('sources'); const [groupBy, setGroupBy] = useState('datasource'); const [collapsed, setCollapsed] = useState(false); @@ -79,9 +84,14 @@ export default function SignalTree({ onDragStart, width }: Props) { await Promise.all( dsList.map(async ({ name }) => { try { - const r = await fetch(`/api/v1/signals?ds=${encodeURIComponent(name)}`); + // Synthetic signals are filtered server-side by panel scope. + const url = name === 'synthetic' && panelId + ? `/api/v1/signals?ds=synthetic&panel=${encodeURIComponent(panelId)}` + : `/api/v1/signals?ds=${encodeURIComponent(name)}`; + const r = await fetch(url); if (r.ok) { - const sigs: SignalInfo[] = await r.json(); + const data = await r.json(); + const sigs = Array.isArray(data) ? data : (data && typeof data === 'object' && Array.isArray(data.signals) ? data.signals : []); all.push(...sigs.map(s => ({ ...s, ds: name }))); } } catch (e) { console.error(`Failed to load ${name}:`, e); } @@ -90,8 +100,10 @@ export default function SignalTree({ onDragStart, width }: Props) { setRawSignals(all); // Check if Channel Finder is available - const cf = await fetch('/api/v1/channel-finder?q=').catch(() => null); - setCfAvailable(cf?.status === 200); + const cf = await fetch('/api/v1/channel-finder?q=') + .then(r => (r.ok ? r.json() : null)) + .catch(() => null); + setCfAvailable(!!cf?.available); } catch (e) { console.error('Failed to load signals:', e); } finally { @@ -99,7 +111,41 @@ export default function SignalTree({ onDragStart, width }: Props) { } } - useEffect(() => { loadAllSignals(); }, []); + useEffect(() => { loadAllSignals(); }, [panelId]); + + // ── Local state variables (edit mode) ────────────────────────────────────── + const [showAddLocal, setShowAddLocal] = useState(false); + const [localOpen, setLocalOpen] = useState(true); + const [lvName, setLvName] = useState(''); + const [lvType, setLvType] = useState<'number' | 'bool' | 'string'>('number'); + const [lvInitial, setLvInitial] = useState('0'); + + function addLocal() { + const name = lvName.trim(); + if (!name || !onStateVarsChange) return; + const next = (statevars ?? []).filter(v => v.name !== name); + next.push({ name, type: lvType, initial: lvInitial }); + onStateVarsChange(next); + setLvName(''); + setLvInitial('0'); + setShowAddLocal(false); + } + + function removeLocal(name: string) { + if (!onStateVarsChange) return; + onStateVarsChange((statevars ?? []).filter(v => v.name !== name)); + } + + function makeLocalDraggable(name: string) { + return { + draggable: true as const, + onDragStart: (e: DragEvent) => { + const ref: SignalRef = { ds: 'local', name }; + e.dataTransfer?.setData('application/json', JSON.stringify(ref)); + onDragStart?.(ref); + }, + }; + } // Merge custom signals that might not be in ListSignals yet const allSignals = useMemo(() => { @@ -243,6 +289,84 @@ export default function SignalTree({ onDragStart, width }: Props) { )} + {treeTab === 'sources' && onStateVarsChange && ( +
    +
    + setLocalOpen(o => !o)}> + {localOpen ? '▾' : '▸'} + + setLocalOpen(o => !o)}> + Local State + + {(statevars ?? []).length} + +
    + {localOpen && ( +
    + {showAddLocal && ( +
    + setLvName((e.target as HTMLInputElement).value)} + onKeyDown={(e: KeyboardEvent) => { + if (e.key === 'Enter') addLocal(); + if (e.key === 'Escape') setShowAddLocal(false); + }} + /> + + setLvInitial((e.target as HTMLInputElement).value)} + onKeyDown={(e: KeyboardEvent) => { if (e.key === 'Enter') addLocal(); }} + /> + + +
    + )} + {(statevars ?? []).length === 0 && !showAddLocal && ( +

    No local variables.

    + )} + {(statevars ?? []).map(v => ( +
    + {v.name} + {v.type ?? 'number'} + +
    + ))} +
    + )} +
    + )} + {treeTab === 'sources' && (
    @@ -219,14 +211,16 @@ export default function ViewMode({ onEdit, initialInterface }: Props) { onEdit?.(newPlotPanel())} onEditId={handleEditById} width={leftW} + collapsed={listCollapsed} + onToggleCollapse={() => setListCollapsed(c => !c)} onSelect={async (id) => { try { const res = await fetch(`/api/v1/interfaces/${encodeURIComponent(id)}`); if (!res.ok) throw new Error(`HTTP ${res.status}`); handleLoad(await res.text()); - setViewTab('hmi'); } catch (err) { setParseError(`Failed to load interface "${id}": ${err instanceof Error ? err.message : err}`); } @@ -235,34 +229,13 @@ export default function ViewMode({ onEdit, initialInterface }: Props) {
    - {/* Tab bar */} -
    - - -
    - - {/* Keep both panels mounted so ring-buffer data is never lost on tab switch */} -
    +
    -
    - -
    diff --git a/web/src/lib/auth.ts b/web/src/lib/auth.ts new file mode 100644 index 0000000..3860bbf --- /dev/null +++ b/web/src/lib/auth.ts @@ -0,0 +1,43 @@ +import { createContext } from 'preact'; +import { useContext } from 'preact/hooks'; +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: [] }; + +// AuthContext carries the resolved identity + global access level for the +// current user throughout the app. +export const AuthContext = createContext(DEFAULT_ME); + +export function useAuth(): Me { + return useContext(AuthContext); +} + +// canWrite reports whether the given level permits creating/modifying panels +// and writing signal values. +export function canWrite(level: AccessLevel): boolean { + return level === 'write'; +} + +// canRead reports whether the given level permits viewing panels at all. +export function canRead(level: AccessLevel): boolean { + return level !== 'none'; +} + +// fetchMe loads the caller's identity from the backend. On failure it falls +// back to the trusted-LAN default so the UI stays usable in dev/unproxied mode. +export async function fetchMe(): Promise { + try { + const res = await fetch('/api/v1/me'); + if (!res.ok) return DEFAULT_ME; + const data = await res.json(); + return { + user: typeof data.user === 'string' ? data.user : '', + level: (data.level as AccessLevel) ?? 'write', + groups: Array.isArray(data.groups) ? data.groups : [], + }; + } catch { + return DEFAULT_ME; + } +} diff --git a/web/src/lib/expr.ts b/web/src/lib/expr.ts new file mode 100644 index 0000000..8a6221d --- /dev/null +++ b/web/src/lib/expr.ts @@ -0,0 +1,268 @@ +// Small, safe expression evaluator for panel logic. +// +// Supports numbers, booleans (true/false → 1/0), arithmetic (+ - * / %), +// comparison (< <= > >= == !=), boolean (&& || !), ternary (a ? b : c), +// parentheses, and a handful of math functions. Two kinds of variable +// reference are resolved live at evaluation time: +// {ds:name} a data-source signal value (the brace content is split on the +// FIRST ':' so EPICS PV names like "MY:PV:NAME" work). +// bareIdent a panel-local state variable (data source 'local'). +// +// Booleans are represented as numbers: comparisons / logical ops yield 1 or 0, +// and any nonzero value is truthy. The evaluator never touches the DOM or eval; +// it walks a parsed AST against a caller-supplied resolver. + +export interface RefLite { ds: string; name: string } + +type Node = + | { t: 'num'; v: number } + | { t: 'sig'; ds: string; name: string } + | { t: 'var'; name: string } + | { t: 'un'; op: string; a: Node } + | { t: 'bin'; op: string; a: Node; b: Node } + | { t: 'tern'; c: Node; a: Node; b: Node } + | { t: 'call'; fn: string; args: Node[] }; + +const FUNCS: Record number> = { + abs: a => Math.abs(a[0]), + min: a => Math.min(...a), + max: a => Math.max(...a), + sqrt: a => Math.sqrt(a[0]), + floor: a => Math.floor(a[0]), + ceil: a => Math.ceil(a[0]), + round: a => Math.round(a[0]), + sign: a => Math.sign(a[0]), + pow: a => Math.pow(a[0], a[1]), + log: a => Math.log(a[0]), + exp: a => Math.exp(a[0]), + sin: a => Math.sin(a[0]), + cos: a => Math.cos(a[0]), +}; + +// ── Tokenizer ──────────────────────────────────────────────────────────────── + +interface Tok { k: string; v?: string } + +function tokenize(src: string): Tok[] { + const toks: Tok[] = []; + let i = 0; + const two = ['<=', '>=', '==', '!=', '&&', '||']; + while (i < src.length) { + const c = src[i]; + if (c === ' ' || c === '\t' || c === '\n' || c === '\r') { i++; continue; } + // signal ref {ds:name} + if (c === '{') { + const end = src.indexOf('}', i); + if (end < 0) throw new Error('unterminated { in expression'); + toks.push({ k: 'sig', v: src.slice(i + 1, end) }); + i = end + 1; + continue; + } + // number + if ((c >= '0' && c <= '9') || (c === '.' && /[0-9]/.test(src[i + 1] ?? ''))) { + let j = i + 1; + while (j < src.length && /[0-9.]/.test(src[j])) j++; + toks.push({ k: 'num', v: src.slice(i, j) }); + i = j; + continue; + } + // identifier + if (/[A-Za-z_]/.test(c)) { + let j = i + 1; + while (j < src.length && /[A-Za-z0-9_]/.test(src[j])) j++; + toks.push({ k: 'ident', v: src.slice(i, j) }); + i = j; + continue; + } + // two-char operators + const pair = src.slice(i, i + 2); + if (two.includes(pair)) { toks.push({ k: pair }); i += 2; continue; } + // single-char operators / punctuation + if ('+-*/%<>!()?:,'.includes(c)) { toks.push({ k: c }); i++; continue; } + throw new Error(`unexpected character '${c}' in expression`); + } + return toks; +} + +// ── Parser (recursive descent) ──────────────────────────────────────────────── + +function parse(src: string): Node { + const toks = tokenize(src); + let p = 0; + const peek = () => toks[p]; + const eat = (k?: string): Tok => { + const t = toks[p]; + if (!t || (k && t.k !== k)) throw new Error(`expected '${k}' in expression`); + p++; + return t; + }; + + function primary(): Node { + const t = peek(); + if (!t) throw new Error('unexpected end of expression'); + if (t.k === 'num') { eat(); return { t: 'num', v: parseFloat(t.v!) }; } + if (t.k === 'sig') { + eat(); + const raw = t.v!; + const idx = raw.indexOf(':'); + const ds = idx < 0 ? raw : raw.slice(0, idx); + const name = idx < 0 ? '' : raw.slice(idx + 1); + return { t: 'sig', ds, name }; + } + if (t.k === 'ident') { + eat(); + const id = t.v!; + if (id === 'true') return { t: 'num', v: 1 }; + if (id === 'false') return { t: 'num', v: 0 }; + if (peek()?.k === '(') { + eat('('); + const args: Node[] = []; + if (peek()?.k !== ')') { + args.push(ternary()); + while (peek()?.k === ',') { eat(','); args.push(ternary()); } + } + eat(')'); + return { t: 'call', fn: id, args }; + } + return { t: 'var', name: id }; + } + if (t.k === '(') { eat('('); const e = ternary(); eat(')'); return e; } + throw new Error(`unexpected token '${t.k}' in expression`); + } + + function unary(): Node { + const t = peek(); + if (t && (t.k === '-' || t.k === '!')) { eat(); return { t: 'un', op: t.k, a: unary() }; } + return primary(); + } + + function bin(next: () => Node, ops: string[]): () => Node { + return () => { + let a = next(); + while (peek() && ops.includes(peek().k)) { + const op = eat().k; + a = { t: 'bin', op, a, b: next() }; + } + return a; + }; + } + + const mul = bin(unary, ['*', '/', '%']); + const add = bin(mul, ['+', '-']); + const cmp = bin(add, ['<', '<=', '>', '>=']); + const eq = bin(cmp, ['==', '!=']); + const and = bin(eq, ['&&']); + const or = bin(and, ['||']); + + function ternary(): Node { + const c = or(); + if (peek()?.k === '?') { + eat('?'); + const a = ternary(); + eat(':'); + const b = ternary(); + return { t: 'tern', c, a, b }; + } + return c; + } + + const root = ternary(); + if (p < toks.length) throw new Error('trailing tokens in expression'); + return root; +} + +// Parsed-AST cache so the engine can re-evaluate hot expressions cheaply. +const cache = new Map(); + +function parseCached(src: string): Node { + let n = cache.get(src); + if (n === undefined) { + try { n = parse(src); } catch (e) { n = e as Error; } + cache.set(src, n); + } + if (n instanceof Error) throw n; + return n; +} + +// ── Evaluation ───────────────────────────────────────────────────────────── + +export type Resolver = (ds: string, name: string) => number; + +function ev(n: Node, R: Resolver): number { + switch (n.t) { + case 'num': return n.v; + case 'sig': return R(n.ds, n.name); + case 'var': return R('local', n.name); + case 'un': return n.op === '-' ? -ev(n.a, R) : (ev(n.a, R) === 0 ? 1 : 0); + case 'tern': return ev(n.c, R) !== 0 ? ev(n.a, R) : ev(n.b, R); + case 'call': { + const fn = FUNCS[n.fn]; + if (!fn) throw new Error(`unknown function '${n.fn}'`); + return fn(n.args.map(a => ev(a, R))); + } + case 'bin': { + const a = ev(n.a, R), b = ev(n.b, R); + switch (n.op) { + case '+': return a + b; + case '-': return a - b; + case '*': return a * b; + case '/': return a / b; + case '%': return a % b; + case '<': return a < b ? 1 : 0; + case '<=': return a <= b ? 1 : 0; + case '>': return a > b ? 1 : 0; + case '>=': return a >= b ? 1 : 0; + case '==': return a === b ? 1 : 0; + case '!=': return a !== b ? 1 : 0; + case '&&': return (a !== 0 && b !== 0) ? 1 : 0; + case '||': return (a !== 0 || b !== 0) ? 1 : 0; + default: throw new Error(`unknown operator '${n.op}'`); + } + } + } +} + +/** Evaluate an expression string. Returns NaN if it cannot be parsed/evaluated. */ +export function evalExpr(src: string, resolve: Resolver): number { + try { + return ev(parseCached(src), resolve); + } catch { + return NaN; + } +} + +/** True if the expression evaluates to a truthy (nonzero, non-NaN) value. */ +export function evalBool(src: string, resolve: Resolver): boolean { + const v = evalExpr(src, resolve); + return !isNaN(v) && v !== 0; +} + +/** Collect every signal/local reference an expression reads, for subscription. */ +export function collectRefs(src: string): RefLite[] { + const out: RefLite[] = []; + let root: Node; + try { root = parseCached(src); } catch { return out; } + const seen = new Set(); + const add = (ds: string, name: string) => { + const k = `${ds}\0${name}`; + if (!seen.has(k)) { seen.add(k); out.push({ ds, name }); } + }; + const walk = (n: Node) => { + switch (n.t) { + case 'sig': add(n.ds, n.name); break; + case 'var': add('local', n.name); break; + case 'un': walk(n.a); break; + case 'bin': walk(n.a); walk(n.b); break; + case 'tern': walk(n.c); walk(n.a); walk(n.b); break; + case 'call': n.args.forEach(walk); break; + } + }; + walk(root); + return out; +} + +/** Validate an expression; returns an error message or null if it parses. */ +export function checkExpr(src: string): string | null { + if (!src.trim()) return null; + try { parse(src); return null; } catch (e) { return e instanceof Error ? e.message : String(e); } +} diff --git a/web/src/lib/fft.ts b/web/src/lib/fft.ts new file mode 100644 index 0000000..682eace --- /dev/null +++ b/web/src/lib/fft.ts @@ -0,0 +1,89 @@ +// Small FFT helpers used by the FFT and waterfall (spectrogram) plot types. +// Scalar live signals are irregularly sampled, so callers first resample onto a +// uniform grid, then take the magnitude spectrum. + +function nextPow2(n: number): number { + let p = 1; + while (p < n) p <<= 1; + return p; +} + +// In-place iterative Cooley-Tukey FFT. Array length must be a power of two. +function transform(re: number[], im: number[]): void { + const n = re.length; + if (n <= 1) return; + + // Bit-reversal permutation. + for (let i = 1, j = 0; i < n; i++) { + let bit = n >> 1; + for (; j & bit; bit >>= 1) j ^= bit; + j ^= bit; + if (i < j) { + const tr = re[i]; re[i] = re[j]; re[j] = tr; + const ti = im[i]; im[i] = im[j]; im[j] = ti; + } + } + + for (let len = 2; len <= n; len <<= 1) { + const ang = -2 * Math.PI / len; + const wr = Math.cos(ang), wi = Math.sin(ang); + for (let i = 0; i < n; i += len) { + let cr = 1, ci = 0; + for (let k = 0; k < len >> 1; k++) { + const a = i + k, b = i + k + (len >> 1); + const tr = re[b] * cr - im[b] * ci; + const ti = re[b] * ci + im[b] * cr; + re[b] = re[a] - tr; im[b] = im[a] - ti; + re[a] += tr; im[a] += ti; + const ncr = cr * wr - ci * wi; + ci = cr * wi + ci * wr; + cr = ncr; + } + } + } +} + +// Magnitude spectrum (first half, single-sided) of a uniformly sampled signal. +// DC is removed and a Hann window applied to reduce spectral leakage. +export function fftMag(values: number[]): number[] { + const len = values.length; + if (len < 2) return []; + const n = nextPow2(len); + const re = new Array(n).fill(0); + const im = new Array(n).fill(0); + let mean = 0; + for (let i = 0; i < len; i++) mean += values[i]; + mean /= len; + for (let i = 0; i < len; i++) { + const w = 0.5 - 0.5 * Math.cos((2 * Math.PI * i) / (len - 1)); + re[i] = (values[i] - mean) * w; + } + transform(re, im); + const half = n >> 1; + const mag = new Array(half); + for (let k = 0; k < half; k++) mag[k] = Math.hypot(re[k], im[k]) / half; + return mag; +} + +// Linearly resample (ts, vals) onto n evenly spaced points spanning the data. +// Returns the sampled series and the uniform sample interval dt (seconds). +export function resampleUniform(ts: number[], vals: number[], n: number): { sampled: number[]; dt: number } { + if (ts.length < 2) return { sampled: vals.slice(0, n), dt: 0 }; + const t0 = ts[0]; + const t1 = ts[ts.length - 1]; + const dt = (t1 - t0) / (n - 1); + const sampled = new Array(n); + let j = 0; + for (let i = 0; i < n; i++) { + const t = t0 + i * dt; + while (j < ts.length - 1 && ts[j + 1] <= t) j++; + if (j >= ts.length - 1) { + sampled[i] = vals[ts.length - 1]; + } else { + const span = ts[j + 1] - ts[j]; + const frac = span > 0 ? (t - ts[j]) / span : 0; + sampled[i] = vals[j] + (vals[j + 1] - vals[j]) * frac; + } + } + return { sampled, dt }; +} diff --git a/web/src/lib/localstate.ts b/web/src/lib/localstate.ts new file mode 100644 index 0000000..4c70707 --- /dev/null +++ b/web/src/lib/localstate.ts @@ -0,0 +1,82 @@ +// Panel-local state variables (ds === 'local'). +// +// Definitions live in the interface XML and travel with the panel, but the live +// value is instantiated per browser/panel instance starting from the variable's +// initial value — nothing is persisted server-side and no WebSocket traffic is +// involved. The panel logic (a future feature) and write-capable widgets can +// update these values locally. +// +// This module deliberately depends only on the store primitives and types so it +// can be imported by both stores.ts and ws.ts without creating an import cycle. + +import { writable, type Readable, type Writable } from './store'; +import type { SignalValue, SignalMeta, StateVar } from './types'; + +const valueStores = new Map>(); +const metaStores = new Map>(); + +const DEFAULT_VALUE: SignalValue = { value: null, quality: 'unknown', ts: null }; + +function valueW(name: string): Writable { + let s = valueStores.get(name); + if (!s) { + s = writable(DEFAULT_VALUE); + valueStores.set(name, s); + } + return s; +} + +function metaW(name: string): Writable { + let s = metaStores.get(name); + if (!s) { + s = writable(null); + metaStores.set(name, s); + } + return s; +} + +// coerce parses a state variable's stored string into a live value. +function coerce(v: StateVar): any { + switch (v.type) { + case 'bool': + return v.initial === 'true' || v.initial === '1'; + case 'string': + return v.initial; + default: { + const n = parseFloat(v.initial); + return isNaN(n) ? 0 : n; + } + } +} + +// initLocalState (re)instantiates every state variable to its initial value and +// publishes metadata. Call this whenever a panel is loaded. +export function initLocalState(vars: StateVar[] | undefined): void { + for (const v of vars ?? []) { + valueW(v.name).set({ + value: coerce(v), + quality: 'good', + ts: new Date().toISOString(), + }); + metaW(v.name).set({ + type: v.type ?? 'number', + unit: v.unit, + displayLow: v.low ?? 0, + displayHigh: v.high ?? 100, + writable: true, + }); + } +} + +export function getLocalValueStore(name: string): Readable { + return valueW(name); +} + +export function getLocalMetaStore(name: string): Readable { + return metaW(name); +} + +// writeLocalState updates a local variable's live value in place. +export function writeLocalState(name: string, value: any): void { + valueW(name).set({ value, quality: 'good', ts: new Date().toISOString() }); +} diff --git a/web/src/lib/logic.ts b/web/src/lib/logic.ts new file mode 100644 index 0000000..eb6730a --- /dev/null +++ b/web/src/lib/logic.ts @@ -0,0 +1,375 @@ +// Panel logic engine. +// +// A panel may define a LogicGraph (see types.ts): a Node-RED-style flow of +// trigger, gate, control-flow and action nodes connected by wires. This +// singleton engine runs the graph client-side in view mode only: Canvas calls +// `load(iface.logic)` when a panel mounts and `clear()` when it unmounts. +// +// Triggers are flow entry points. When one activates, the engine follows the +// outgoing wires executing downstream nodes: +// gate.and — passes only when all incoming triggers are satisfied. +// flow.if — evaluates `cond`, continues on the 'then' or 'else' port. +// flow.loop — repeats the 'body' port (count / while, capped), then 'done'. +// action.write— evaluates `expr` and writes the result to `target`. +// action.delay— awaits `ms` before continuing. +// action.accumulate / export / clear — collect expression values into a named +// in-memory array and download it as CSV. +// action.log — logs an expression value to the console. +// +// Expressions reference signals inline as {ds:name} and panel-local vars as +// bare identifiers; their values are read from a live cache kept up to date by +// subscriptions started at load() for every referenced signal. Two built-in +// system signals are served synthetically (never subscribed): {sys:time} is the +// current epoch time in seconds and {sys:dt} is the seconds elapsed since the +// firing trigger last fired (useful in On change / Timer / Panel loop flows, +// e.g. to compute a rate). + +import { wsClient } from './ws'; +import { getSignalStore } from './stores'; +import type { SignalRef, SignalValue, LogicGraph, LogicNode } from './types'; +import { evalExpr, evalBool, collectRefs, type Resolver } from './expr'; + +// Guards against runaway flows (cycles / pathological loops). +const MAX_STEPS = 100000; +const MAX_LOOP = 100000; + +// Split a "ds:name" reference on the FIRST ':' only (EPICS PV names contain ':'). +function parseRef(target: string): SignalRef | null { + const t = target.trim(); + if (!t) return null; + const i = t.indexOf(':'); + if (i < 0) return { ds: 'local', name: t }; // bare name → panel-local var + return { ds: t.slice(0, i), name: t.slice(i + 1) }; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function toNum(v: any): number { + if (typeof v === 'number') return v; + if (typeof v === 'boolean') return v ? 1 : 0; + const n = parseFloat(v); + return isNaN(n) ? NaN : n; +} + +function refKey(ds: string, name: string): string { + return `${ds}\0${name}`; +} + +interface WireOut { to: string; port: string } +// One flow run, started by an activating trigger. `dt` is the seconds elapsed +// since that trigger last fired (0 on its first fire); `resolve` is an +// expression resolver that exposes the system signals {sys:time}/{sys:dt}. +interface RunCtx { firedTrigger: string; steps: { n: number }; dt: number; resolve: Resolver } + +class LogicEngine { + private graph: LogicGraph = { nodes: [], wires: [] }; + private byId = new Map(); + // Outgoing wires grouped by source node id. + private out = new Map(); + // Incoming source node ids grouped by target node id (for gate evaluation). + private incoming = new Map(); + // Live signal value cache (key "ds\0name"), kept fresh by subscriptions. + private live = new Map(); + // Current truth of each level-style trigger (threshold), for AND-gates. + private levelState = new Map(); + // Per-node previous values for edge/change detection. + private prevBool = new Map(); + private prevVal = new Map(); + // signal key → trigger node ids that react to it (threshold / change). + private watchers = new Map(); + // Named in-memory data arrays, filled by action.accumulate and dumped by + // action.export. Each sample keeps the wall-clock time it was recorded. + private arrays = new Map>(); + // Wall-clock time (ms) each trigger last activated, for the {sys:dt} signal. + private lastFire = new Map(); + private cleanups: Array<() => void> = []; + + // Resolve an expression reference. Two built-in system signals are served + // synthetically (never subscribed): {sys:time} = current epoch seconds, + // {sys:dt} = seconds since the firing trigger last fired. Everything else + // reads from the live signal cache. + private sysResolve(ds: string, name: string, dt: number): number { + if (ds === 'sys') { + if (name === 'time') return Date.now() / 1000; + if (name === 'dt') return dt; + return NaN; + } + return toNum(this.live.get(refKey(ds, name))); + } + + /** Activate a panel's logic graph. Replaces any previously loaded graph. */ + load(graph: LogicGraph | undefined): void { + this.clear(); + this.graph = graph ?? { nodes: [], wires: [] }; + + this.byId = new Map(this.graph.nodes.map(n => [n.id, n])); + this.out = new Map(); + this.incoming = new Map(); + for (const w of this.graph.wires) { + (this.out.get(w.from) ?? this.out.set(w.from, []).get(w.from)!) + .push({ to: w.to, port: w.fromPort ?? 'out' }); + (this.incoming.get(w.to) ?? this.incoming.set(w.to, []).get(w.to)!) + .push(w.from); + } + + this.subscribeRefs(); + + for (const node of this.graph.nodes) { + switch (node.kind) { + case 'trigger.timer': { + const id = setInterval(() => this.activate(node.id), this.intervalOf(node)); + this.cleanups.push(() => clearInterval(id)); + break; + } + case 'trigger.loop': { + this.activate(node.id); // run once on load + const id = setInterval(() => this.activate(node.id), this.intervalOf(node)); + this.cleanups.push(() => clearInterval(id)); + break; + } + // button/threshold/change are driven imperatively / by subscriptions. + } + } + } + + /** Deactivate all triggers and subscriptions. Called when a panel unmounts. */ + clear(): void { + for (const c of this.cleanups) { try { c(); } catch {} } + this.cleanups = []; + this.graph = { nodes: [], wires: [] }; + this.byId = new Map(); + this.out = new Map(); + this.incoming = new Map(); + this.live = new Map(); + this.levelState = new Map(); + this.prevBool = new Map(); + this.prevVal = new Map(); + this.watchers = new Map(); + this.arrays = new Map(); + this.lastFire = new Map(); + } + + /** Fire every button-trigger node whose `name` param matches. */ + runAction(name: string): void { + if (!name) return; + for (const node of this.graph.nodes) { + if (node.kind === 'trigger.button' && (node.params.name ?? '') === name) { + this.activate(node.id); + } + } + } + + // ── Subscriptions ────────────────────────────────────────────────────────── + + // Subscribe to every signal referenced anywhere in the graph (explicit + // threshold/change signals + inline {ds:name}/local refs in expressions), + // feeding the live cache and waking threshold/change triggers. + private subscribeRefs(): void { + const refs = new Map(); + // {sys:*} signals are served synthetically by sysResolve — never subscribed. + const want = (r: SignalRef) => { if (r.ds === 'sys') return; if (r.name || r.ds === 'local') refs.set(refKey(r.ds, r.name), r); }; + + for (const node of this.graph.nodes) { + switch (node.kind) { + case 'trigger.threshold': + case 'trigger.change': { + const r = parseRef(node.params.signal ?? ''); + if (r) { + want(r); + const key = refKey(r.ds, r.name); + (this.watchers.get(key) ?? this.watchers.set(key, []).get(key)!).push(node.id); + } + break; + } + case 'flow.if': collectRefs(node.params.cond ?? '').forEach(want); break; + case 'flow.loop': if ((node.params.mode ?? 'count') === 'while') collectRefs(node.params.cond ?? '').forEach(want); break; + case 'action.write': + case 'action.accumulate': + case 'action.log': collectRefs(node.params.expr ?? '').forEach(want); break; + } + } + + for (const [key, ref] of refs) { + const unsub = getSignalStore(ref).subscribe((v: SignalValue) => { + this.live.set(key, v.value); + this.onSignal(key, v.value); + }); + this.cleanups.push(unsub); + } + } + + // Drive threshold (rising edge) and change triggers when a watched signal updates. + private onSignal(key: string, value: any): void { + for (const id of this.watchers.get(key) ?? []) { + const node = this.byId.get(id); + if (!node) continue; + if (node.kind === 'trigger.threshold') { + const cur = this.testThreshold(toNum(value), node.params.op ?? '>', toNum(node.params.value ?? '0')); + this.levelState.set(id, cur); + if (cur && !(this.prevBool.get(id) ?? false)) this.activate(id); + this.prevBool.set(id, cur); + } else if (node.kind === 'trigger.change') { + const had = this.prevVal.has(id); + const prev = this.prevVal.get(id); + this.prevVal.set(id, value); + if (had && value !== prev) this.activate(id); + } + } + } + + private testThreshold(val: number, op: string, cmp: number): boolean { + if (isNaN(val)) return false; + switch (op) { + case '>': return val > cmp; + case '<': return val < cmp; + case '>=': return val >= cmp; + case '<=': return val <= cmp; + case '==': return val === cmp; + case '!=': return val !== cmp; + default: return false; + } + } + + private intervalOf(node: LogicNode): number { + return Math.max(50, parseInt(node.params.interval ?? '1000', 10) || 1000); + } + + // ── Execution ─────────────────────────────────────────────────────────────── + + // A trigger activated: walk its outgoing 'out' wires. + private activate(triggerId: string): void { + const now = Date.now(); + const last = this.lastFire.get(triggerId); + const dt = last === undefined ? 0 : (now - last) / 1000; + this.lastFire.set(triggerId, now); + const ctx: RunCtx = { + firedTrigger: triggerId, + steps: { n: 0 }, + dt, + resolve: (ds, name) => this.sysResolve(ds, name, dt), + }; + void this.follow(triggerId, 'out', ctx); + } + + // Run every wire leaving `fromId` on output port `port`. + private async follow(fromId: string, port: string, ctx: RunCtx): Promise { + for (const w of this.out.get(fromId) ?? []) { + if (w.port === port) await this.run(w.to, ctx); + } + } + + // Execute one node, then continue downstream as appropriate. + private async run(nodeId: string, ctx: RunCtx): Promise { + if (ctx.steps.n++ > MAX_STEPS) return; + const node = this.byId.get(nodeId); + if (!node) return; + + switch (node.kind) { + case 'gate.and': + if (this.gateSatisfied(node.id, ctx.firedTrigger)) await this.follow(node.id, 'out', ctx); + return; + + case 'flow.if': { + const branch = evalBool(node.params.cond ?? '', ctx.resolve) ? 'then' : 'else'; + await this.follow(node.id, branch, ctx); + return; + } + + case 'flow.loop': { + const mode = node.params.mode ?? 'count'; + if (mode === 'count') { + const n = Math.min(MAX_LOOP, Math.max(0, Math.floor(toNum(node.params.count ?? '0')) || 0)); + for (let i = 0; i < n && ctx.steps.n <= MAX_STEPS; i++) await this.follow(node.id, 'body', ctx); + } else { + let i = 0; + while (i < MAX_LOOP && ctx.steps.n <= MAX_STEPS && evalBool(node.params.cond ?? '', ctx.resolve)) { + await this.follow(node.id, 'body', ctx); + i++; + } + } + await this.follow(node.id, 'done', ctx); + return; + } + + case 'action.write': { + const ref = parseRef(node.params.target ?? ''); + const val = evalExpr(node.params.expr ?? '', ctx.resolve); + if (ref && !isNaN(val)) wsClient.write(ref, val); + await this.follow(node.id, 'out', ctx); + return; + } + + case 'action.delay': + await sleep(Math.max(0, parseInt(node.params.ms ?? '0', 10) || 0)); + await this.follow(node.id, 'out', ctx); + return; + + case 'action.accumulate': { + const name = (node.params.array ?? '').trim(); + const val = evalExpr(node.params.expr ?? '', ctx.resolve); + if (name && !isNaN(val)) { + const arr = this.arrays.get(name) ?? this.arrays.set(name, []).get(name)!; + arr.push({ t: Date.now(), v: val }); + } + await this.follow(node.id, 'out', ctx); + return; + } + + case 'action.export': { + this.exportArray((node.params.array ?? '').trim(), node.params.filename ?? ''); + await this.follow(node.id, 'out', ctx); + return; + } + + case 'action.clear': { + const name = (node.params.array ?? '').trim(); + if (name) this.arrays.delete(name); + await this.follow(node.id, 'out', ctx); + return; + } + + case 'action.log': { + const val = evalExpr(node.params.expr ?? '', ctx.resolve); + const label = (node.params.label ?? '').trim(); + console.log(`[logic]${label ? ' ' + label + ':' : ''}`, val); + await this.follow(node.id, 'out', ctx); + return; + } + + default: + // A trigger reached mid-walk (unusual): just pass through. + await this.follow(node.id, 'out', ctx); + } + } + + // Dump a named array to a CSV file the browser downloads (timestamp + value). + private exportArray(name: string, filename: string): void { + if (!name) return; + const arr = this.arrays.get(name) ?? []; + const rows = arr.map(p => `${p.t},${new Date(p.t).toISOString()},${p.v}`); + const csv = 'timestamp_ms,iso,value\n' + rows.join('\n') + '\n'; + const safe = (filename || name).replace(/[^a-z0-9_.-]/gi, '_'); + const fname = safe.toLowerCase().endsWith('.csv') ? safe : `${safe}.csv`; + const blob = new Blob([csv], { type: 'text/csv' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = fname; + a.click(); + URL.revokeObjectURL(url); + } + + // An AND-gate fires when every incoming trigger is currently satisfied: the + // activating trigger counts as satisfied, and level triggers (threshold) use + // their current truth. Momentary triggers that are not firing now are false. + private gateSatisfied(gateId: string, firedTrigger: string): boolean { + const inputs = this.incoming.get(gateId) ?? []; + if (inputs.length === 0) return false; + return inputs.every(src => src === firedTrigger || this.levelState.get(src) === true); + } +} + +// Singleton instance, mirrored on wsClient's module-level pattern. +export const logicEngine = new LogicEngine(); diff --git a/web/src/lib/plotLayout.ts b/web/src/lib/plotLayout.ts new file mode 100644 index 0000000..4b07105 --- /dev/null +++ b/web/src/lib/plotLayout.ts @@ -0,0 +1,72 @@ +import type { PlotLayout } from './types'; + +/** Collect all widget ids referenced by leaves, in layout order. */ +export function collectWidgetIds(layout: PlotLayout): string[] { + if (layout.type === 'leaf') return [layout.widget]; + return [...collectWidgetIds(layout.a), ...collectWidgetIds(layout.b)]; +} + +/** Count the number of leaves (panes). */ +export function countLeaves(layout: PlotLayout): number { + if (layout.type === 'leaf') return 1; + return countLeaves(layout.a) + countLeaves(layout.b); +} + +/** + * Replace the leaf for `widgetId` with a split: the existing leaf becomes child + * `a` and a new leaf for `newWidgetId` becomes child `b` (ratio 0.5). + */ +export function splitLeaf( + layout: PlotLayout, + widgetId: string, + dir: 'h' | 'v', + newWidgetId: string, +): PlotLayout { + if (layout.type === 'leaf') { + if (layout.widget !== widgetId) return layout; + return { + type: 'split', + dir, + ratio: 0.5, + a: { type: 'leaf', widget: widgetId }, + b: { type: 'leaf', widget: newWidgetId }, + }; + } + return { + ...layout, + a: splitLeaf(layout.a, widgetId, dir, newWidgetId), + b: splitLeaf(layout.b, widgetId, dir, newWidgetId), + }; +} + +/** + * Remove the leaf for `widgetId`, collapsing its parent split into the sibling + * subtree. Returns the original layout if the leaf is the sole remaining pane. + */ +export function removeLeaf(layout: PlotLayout, widgetId: string): PlotLayout { + if (layout.type === 'leaf') return layout; // can't remove the root leaf + // If a direct child is the target leaf, collapse to the sibling. + if (layout.a.type === 'leaf' && layout.a.widget === widgetId) return layout.b; + if (layout.b.type === 'leaf' && layout.b.widget === widgetId) return layout.a; + return { + ...layout, + a: removeLeaf(layout.a, widgetId), + b: removeLeaf(layout.b, widgetId), + }; +} + +/** + * Set the ratio of the split node addressed by `path` (a list of 0/1 = a/b + * choices from the root). + */ +export function setRatioAtPath( + layout: PlotLayout, + path: number[], + ratio: number, +): PlotLayout { + if (layout.type === 'leaf') return layout; + if (path.length === 0) return { ...layout, ratio }; + const [head, ...rest] = path; + if (head === 0) return { ...layout, a: setRatioAtPath(layout.a, rest, ratio) }; + return { ...layout, b: setRatioAtPath(layout.b, rest, ratio) }; +} diff --git a/web/src/lib/stores.ts b/web/src/lib/stores.ts index 2873a6f..b992c5c 100644 --- a/web/src/lib/stores.ts +++ b/web/src/lib/stores.ts @@ -1,5 +1,6 @@ import { readable, writable, type Readable } from './store'; import { wsClient } from './ws'; +import { getLocalValueStore, getLocalMetaStore } from './localstate'; import type { SignalRef, SignalValue, SignalMeta } from './types'; // ── Default values ──────────────────────────────────────────────────────────── @@ -52,6 +53,9 @@ function refKey(ref: SignalRef): string { * The store stays alive once created (simplest correct behaviour for Phase 4). */ export function getSignalStore(ref: SignalRef): Readable { + // Panel-local variables are served entirely client-side (no WS subscription). + if (ref.ds === 'local') return getLocalValueStore(ref.name); + const key = refKey(ref); const existing = valueStores.get(key); if (existing) return existing; @@ -72,6 +76,8 @@ export function getSignalStore(ref: SignalRef): Readable { * Metadata is sent once by the server on subscribe. */ export function getMetaStore(ref: SignalRef): Readable { + if (ref.ds === 'local') return getLocalMetaStore(ref.name); + const key = refKey(ref); const existing = metaStores.get(key); if (existing) return existing; diff --git a/web/src/lib/templates.ts b/web/src/lib/templates.ts new file mode 100644 index 0000000..b9dc3ad --- /dev/null +++ b/web/src/lib/templates.ts @@ -0,0 +1,31 @@ +import type { Interface } from './types'; + +/** + * Build a blank "plot panel": a special interface whose viewport is filled by + * plots arranged in a recursive split layout. It starts with a single + * full-viewport plot; the user splits panes and drops signals in the editor. + */ +export function newPlotPanel(): Interface { + const wid = `w${Date.now()}`; + return { + id: '', + name: 'New Plot Panel', + version: 1, + w: 1200, + h: 800, + kind: 'plot', + layout: { type: 'leaf', widget: wid }, + widgets: [ + { + id: wid, + type: 'plot', + x: 0, + y: 0, + w: 800, + h: 500, + signals: [], + options: { plotType: 'timeseries', timeWindow: '60', legend: 'bottom' }, + }, + ], + }; +} diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 78f411e..cb2ddda 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -48,6 +48,107 @@ export type GroupNode = | { kind: 'folder'; id: string; label: string; children: GroupNode[] } | { kind: 'signal'; ds: string; name: string }; +// Recursive split-layout tree for plot panels. Leaves reference a plot widget +// by id; splits divide their container into child `a` (fraction `ratio`) and +// child `b` (the remainder). dir 'h' = left/right (vertical divider), 'v' = +// top/bottom (horizontal divider). +export type PlotLayout = + | { type: 'leaf'; widget: string } + | { type: 'split'; dir: 'h' | 'v'; ratio: number; a: PlotLayout; b: PlotLayout }; + +// A panel-local state variable. Its definition travels with the interface XML, +// but its live value is instantiated per browser/panel instance starting from +// `initial` (the value is NOT persisted server-side). Referenced as a signal +// via ds 'local'. +export interface StateVar { + name: string; + type?: 'number' | 'bool' | 'string'; // default 'number' + initial: string; // initial value, stored as a string + unit?: string; + low?: number; + high?: number; +} + +// ── Panel logic (Node-RED–style flow graph) ────────────────────────────────── + +// The kind of a logic node. +// +// Triggers are flow entry points: +// trigger.button — fired when a button widget's Action names this node +// (params: name). +// trigger.threshold — fired on the rising edge of (signal `op` value) +// (params: signal, op, value). +// trigger.timer — fired every `interval` ms while the panel is in view +// (params: interval). +// trigger.loop — fired once on panel load, then every `interval` ms +// (params: interval). +// trigger.change — fired whenever a signal's value changes +// (params: signal). +// +// Gates combine/guard flow: +// gate.and — passes to its output only when ALL incoming triggers +// are currently satisfied (no params). +// +// Control flow (use labelled output ports on the wires): +// flow.if — evaluate `cond`; continue on the 'then' or 'else' port +// (params: cond). +// flow.loop — repeat the 'body' port then continue on 'done' +// (params: mode 'count'|'while', count, cond). +// +// Actions: +// action.write — evaluate `expr` and write the result to `target` +// ("ds:name" or a bare local var) (params: target, expr). +// action.delay — pause `ms` before continuing downstream (params: ms). +// action.accumulate — evaluate `expr` and append the value (with a timestamp) +// to the named in-memory data array (params: array, expr). +// action.export — download the named array as a CSV file (params: array, +// filename). +// action.clear — empty the named array (params: array). +// action.log — evaluate `expr` and log it to the browser console for +// debugging a flow (params: expr, label). +// +// Expressions (cond/expr) may reference signals inline as {ds:name} and panel- +// local state variables as bare identifiers. See lib/expr.ts. +export type LogicNodeKind = + | 'trigger.button' + | 'trigger.threshold' + | 'trigger.timer' + | 'trigger.loop' + | 'trigger.change' + | 'gate.and' + | 'flow.if' + | 'flow.loop' + | 'action.write' + | 'action.delay' + | 'action.accumulate' + | 'action.export' + | 'action.clear' + | 'action.log'; + +// A block on the flow canvas. `params` holds kind-specific fields as strings. +export interface LogicNode { + id: string; + kind: LogicNodeKind; + x: number; + y: number; + params: Record; +} + +// A directed connection from one node's output to another node's input. +// `fromPort` selects which labelled output it leaves (default 'out'); flow.if +// uses 'then'/'else' and flow.loop uses 'body'/'done'. +export interface LogicWire { + from: string; // source node id + fromPort?: string; // source output port (default 'out') + to: string; // target node id +} + +// The panel's complete logic flow. +export interface LogicGraph { + nodes: LogicNode[]; + wires: LogicWire[]; +} + // A complete HMI interface definition export interface Interface { id: string; @@ -56,6 +157,74 @@ export interface Interface { w: number; h: number; widgets: Widget[]; + // 'panel' (default) = free-form canvas; 'plot' = split-layout plot panel. + kind?: 'panel' | 'plot'; + // Present only when kind === 'plot': the viewport split tree. + layout?: PlotLayout; + // Panel-local state variable definitions (live value instantiated client-side). + statevars?: StateVar[]; + // Panel logic flow graph (run client-side in view mode). + logic?: LogicGraph; +} + +// ── Access control ─────────────────────────────────────────────────────────── + +// Global access level for the current user, as reported by /api/v1/me. +// 'write' = full access (the default for any non-blacklisted user) +// 'readonly' = may view but not create/modify/delete panels or write signals +// 'none' = no access +export type AccessLevel = 'none' | 'readonly' | 'write'; + +// Identity + access info for the calling user (from /api/v1/me). +export interface Me { + user: string; + level: AccessLevel; + groups: string[]; +} + +// Effective permission a user holds on a single panel or folder. +// 'none' = invisible +// 'read' = may view +// 'write' = may view and modify +export type Perm = 'none' | 'read' | 'write'; + +// A share grant: gives a single user or a named user-group read/write access to +// a panel or folder. +export interface Grant { + kind: 'user' | 'group'; + name: string; + perm: 'read' | 'write'; +} + +// Sharing record for a panel (from GET /api/v1/interfaces/{id}/acl). +export interface PanelACL { + owner: string; + folder: string; + public: '' | 'read' | 'write'; + grants: Grant[]; + perm: Perm; // the caller's own effective permission +} + +// A folder in the panel-organisation hierarchy (from GET /api/v1/folders). +export interface Folder { + id: string; + name: string; + parent: string; + owner: string; + public?: '' | 'read' | 'write'; + grants?: Grant[]; + perm: Perm; // the caller's own effective permission +} + +// An entry in the interface list (GET /api/v1/interfaces), enriched with the +// owner, containing folder, and the caller's permission. +export interface InterfaceListItem { + id: string; + name: string; + version: number; + owner?: string; + folder?: string; + perm: Perm; } // ── Synthetic signals ──────────────────────────────────────────────────────── @@ -83,4 +252,9 @@ export interface SignalDef { displayHigh?: number; writable?: boolean; }; + // Visibility scope (default 'panel'). 'owner'/'panel' are stamped/filtered + // server-side; owner is set from the trusted identity on create. + visibility?: 'panel' | 'user' | 'global'; + owner?: string; + panel?: string; // bound interface id when visibility === 'panel' } diff --git a/web/src/lib/ws.ts b/web/src/lib/ws.ts index a752997..b64646a 100644 --- a/web/src/lib/ws.ts +++ b/web/src/lib/ws.ts @@ -1,4 +1,5 @@ import { writable, type Readable } from './store'; +import { writeLocalState } from './localstate'; import type { SignalRef, SignalValue, SignalMeta, HistoryPoint } from './types'; // ── Types ──────────────────────────────────────────────────────────────────── @@ -259,6 +260,11 @@ class WsClient { } write(ref: SignalRef, value: any): void { + // Panel-local variables never go to the server — update them in place. + if (ref.ds === 'local') { + writeLocalState(ref.name, value); + return; + } console.log('[ws] write', ref.ds, ref.name, value, 'ws state:', this.ws?.readyState); this._send({ type: 'write', ds: ref.ds, name: ref.name, value }); } diff --git a/web/src/lib/xml.ts b/web/src/lib/xml.ts index 9aa0312..823dfdb 100644 --- a/web/src/lib/xml.ts +++ b/web/src/lib/xml.ts @@ -1,4 +1,4 @@ -import type { Interface, Widget, SignalRef } from './types'; +import type { Interface, Widget, SignalRef, PlotLayout, StateVar, LogicGraph } from './types'; /** Escape a string for use as an XML attribute value. */ function xmlEsc(s: string): string { @@ -9,14 +9,67 @@ function xmlEsc(s: string): string { .replace(/>/g, '>'); } +/** Serialize a panel-logic flow graph to indented XML lines. */ +function serializeLogic(graph: LogicGraph, lines: string[]): void { + lines.push(` `); + for (const node of graph.nodes) { + lines.push( + ` ` + ); + for (const [key, value] of Object.entries(node.params)) { + lines.push(` `); + } + lines.push(` `); + } + for (const wire of graph.wires) { + const portAttr = wire.fromPort && wire.fromPort !== 'out' + ? ` port="${xmlEsc(wire.fromPort)}"` : ''; + lines.push(` `); + } + lines.push(` `); +} + +/** Serialize a PlotLayout subtree to indented XML lines. */ +function serializeLayout(node: PlotLayout, indent: string, lines: string[]): void { + if (node.type === 'leaf') { + lines.push(`${indent}`); + return; + } + lines.push(`${indent}`); + serializeLayout(node.a, indent + ' ', lines); + serializeLayout(node.b, indent + ' ', lines); + lines.push(`${indent}`); +} + /** Serialize an Interface object to an XML string. */ export function serializeInterface(iface: Interface): string { const lines: string[] = []; lines.push(``); + const kindAttr = iface.kind === 'plot' ? ` kind="plot"` : ''; lines.push( `` + `version="${iface.version}" width="${iface.w}" height="${iface.h}"${kindAttr}>` ); + if (iface.kind === 'plot' && iface.layout) { + lines.push(` `); + serializeLayout(iface.layout, ' ', lines); + lines.push(` `); + } + for (const sv of iface.statevars ?? []) { + const attrs = [ + `name="${xmlEsc(sv.name)}"`, + `type="${xmlEsc(sv.type ?? 'number')}"`, + `initial="${xmlEsc(sv.initial ?? '')}"`, + ]; + if (sv.unit) attrs.push(`unit="${xmlEsc(sv.unit)}"`); + if (sv.low !== undefined) attrs.push(`low="${sv.low}"`); + if (sv.high !== undefined) attrs.push(`high="${sv.high}"`); + lines.push(` `); + } + if (iface.logic && (iface.logic.nodes.length > 0 || iface.logic.wires.length > 0)) { + serializeLogic(iface.logic, lines); + } for (const w of iface.widgets) { lines.push( ` * */ +/** Parse a / element into a PlotLayout node. */ +function parseLayout(el: Element): PlotLayout { + if (el.tagName === 'leaf') { + return { type: 'leaf', widget: el.getAttribute('widget') ?? '' }; + } + // split + const dir = el.getAttribute('dir') === 'v' ? 'v' : 'h'; + const ratio = parseFloat(el.getAttribute('ratio') ?? '0.5'); + const children = Array.from(el.children).filter( + c => c.tagName === 'split' || c.tagName === 'leaf' + ); + const a = children[0] ? parseLayout(children[0]) : { type: 'leaf', widget: '' } as PlotLayout; + const b = children[1] ? parseLayout(children[1]) : { type: 'leaf', widget: '' } as PlotLayout; + return { type: 'split', dir, ratio: isNaN(ratio) ? 0.5 : ratio, a, b }; +} + +/** Parse a element into a LogicGraph (nodes + wires). */ +function parseLogic(logicEl: Element): LogicGraph { + const nodes: LogicGraph['nodes'] = []; + const wires: LogicGraph['wires'] = []; + + for (const el of Array.from(logicEl.children)) { + if (el.tagName === 'node') { + const id = el.getAttribute('id') ?? ''; + if (!id) continue; + const params: Record = {}; + for (const child of Array.from(el.children)) { + if (child.tagName !== 'param') continue; + const key = child.getAttribute('key'); + const value = child.getAttribute('value'); + if (key !== null && value !== null) params[key] = value; + } + nodes.push({ + id, + kind: (el.getAttribute('kind') ?? 'action.write') as LogicGraph['nodes'][number]['kind'], + x: parseFloat(el.getAttribute('x') ?? '0'), + y: parseFloat(el.getAttribute('y') ?? '0'), + params, + }); + } else if (el.tagName === 'wire') { + const from = el.getAttribute('from'); + const to = el.getAttribute('to'); + const port = el.getAttribute('port'); + if (from && to) wires.push({ from, to, ...(port ? { fromPort: port } : {}) }); + } + } + + return { nodes, wires }; +} + export function parseInterface(xml: string): Interface { const parser = new DOMParser(); const doc = parser.parseFromString(xml, 'application/xml'); @@ -66,6 +169,38 @@ export function parseInterface(xml: string): Interface { const version = parseInt(root.getAttribute('version') ?? '1', 10); const w = parseInt(root.getAttribute('width') ?? '1200', 10); const h = parseInt(root.getAttribute('height') ?? '800', 10); + const kind = root.getAttribute('kind') === 'plot' ? 'plot' : undefined; + + let layout: PlotLayout | undefined; + if (kind === 'plot') { + const layoutEl = Array.from(root.children).find(el => el.tagName === 'layout'); + const rootNode = layoutEl + ? Array.from(layoutEl.children).find(el => el.tagName === 'split' || el.tagName === 'leaf') + : undefined; + if (rootNode) layout = parseLayout(rootNode); + } + + const statevars: StateVar[] = []; + for (const el of Array.from(root.children)) { + if (el.tagName !== 'statevar') continue; + const name = el.getAttribute('name') ?? ''; + if (!name) continue; + const type = el.getAttribute('type'); + const low = el.getAttribute('low'); + const high = el.getAttribute('high'); + const unit = el.getAttribute('unit'); + statevars.push({ + name, + type: type === 'bool' || type === 'string' ? type : 'number', + initial: el.getAttribute('initial') ?? '', + ...(unit ? { unit } : {}), + ...(low !== null ? { low: parseFloat(low) } : {}), + ...(high !== null ? { high: parseFloat(high) } : {}), + }); + } + + const logicEl = Array.from(root.children).find(el => el.tagName === 'logic'); + const logic = logicEl ? parseLogic(logicEl) : null; const widgets: Widget[] = []; @@ -100,5 +235,11 @@ export function parseInterface(xml: string): Interface { widgets.push({ id, type, x, y, w, h, signals, options }); } - return { id: ifaceId, name, version, w, h, widgets }; + return { + id: ifaceId, name, version, w, h, widgets, + ...(kind ? { kind } : {}), + ...(layout ? { layout } : {}), + ...(statevars.length > 0 ? { statevars } : {}), + ...(logic && (logic.nodes.length > 0 || logic.wires.length > 0) ? { logic } : {}), + }; } diff --git a/web/src/styles.css b/web/src/styles.css index c81f882..232b65b 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -164,6 +164,42 @@ body { background: #374151; } +/* ── 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; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100vh; + text-align: center; + color: #e2e8f0; + padding: 2rem; +} + +.access-denied h1 { + font-size: 1.5rem; + margin-bottom: 0.5rem; + color: #fca5a5; +} + +.access-denied p { + max-width: 32rem; + color: #94a3b8; +} + .content { display: flex; flex: 1; @@ -257,8 +293,17 @@ body { color: #e2e8f0; } +.panel-body { + display: flex; + flex-direction: column; + flex: 1; + overflow: hidden; + min-height: 0; +} + .panel-actions { display: flex; + flex-wrap: wrap; gap: 0.4rem; padding: 0.5rem 0.6rem; border-bottom: 1px solid #2d3748; @@ -834,6 +879,15 @@ body { background: #1e40af; } +/* Read-only: user lacks permission to write this signal (.sv-select shares + .sv-input). */ +.sv-input:disabled, +.sv-btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} +.sv-btn:disabled:hover { background: #2563eb; } + /* ── Button widget ─────────────────────────────────────────────────────── */ .button-widget { @@ -868,6 +922,19 @@ body { transform: scale(0.97); } +/* Read-only: user lacks permission to write this signal. */ +.button-widget .btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} +.button-widget .btn:disabled:hover { + background: #1e3a5f; + border-color: #2d5a9e; +} +.button-widget .btn:disabled:active { + transform: none; +} + /* ── ImageWidget ───────────────────────────────────────────────────────── */ .image-widget { @@ -1001,6 +1068,7 @@ body { border-radius: 4px; cursor: pointer; font-family: system-ui, sans-serif; + white-space: nowrap; } .toolbar-btn:hover { background: #374151; } @@ -1042,6 +1110,384 @@ body { min-height: 0; } +/* ── Center column tabs (Layout / Logic) ────────────────────────────────── */ + +.edit-center { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.center-tabs { + display: flex; + gap: 0; + background: #0f1117; + border-bottom: 1px solid #1e2433; + flex: 0 0 auto; +} + +.center-tab { + padding: 0.4rem 1rem; + font-size: 0.8rem; + color: #94a3b8; + background: transparent; + border: none; + border-right: 1px solid #1e2433; + border-bottom: 2px solid transparent; + cursor: pointer; +} + +.center-tab:hover { color: #e2e8f0; background: #161b27; } + +.center-tab-active { + color: #e2e8f0; + border-bottom-color: #3b82f6; + background: #161b27; +} + +/* ── Logic flow editor (Node-RED style) ─────────────────────────────────── */ + +.flow-editor { + flex: 1; + display: flex; + min-height: 0; + overflow: hidden; +} + +/* Palette of node types to add */ +.flow-palette { + width: 10rem; + flex: 0 0 auto; + border-right: 1px solid #1e2433; + background: #0f1117; + padding: 0.5rem; + display: flex; + flex-direction: column; + gap: 0.4rem; + overflow-y: auto; +} + +.flow-palette-toolbar { + display: flex; + gap: 0.35rem; + margin-bottom: 0.15rem; +} +.flow-palette-toolbar .toolbar-btn { flex: 1; } + +.flow-palette-title { + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: #64748b; +} + +.flow-palette-btn { + text-align: left; + padding: 0.4rem 0.6rem; + font-size: 0.8rem; + color: #e2e8f0; + background: #161b27; + border: 1px solid #1e2433; + border-left-width: 3px; + border-radius: 4px; + cursor: pointer; +} + +.flow-palette-btn:hover { background: #1c2230; } +.flow-palette-trigger { border-left-color: #f59e0b; } +.flow-palette-gate { border-left-color: #a855f7; } +.flow-palette-flow { border-left-color: #10b981; } +.flow-palette-action { border-left-color: #3b82f6; } +.flow-palette-hint { margin-top: 0.5rem; font-size: 0.7rem; line-height: 1.4; } +.flow-palette-hint code { background: #1e2433; padding: 0 0.2em; border-radius: 3px; } + +/* Scrollable node canvas */ +.flow-canvas { + flex: 1; + min-width: 0; + position: relative; + overflow: auto; + background: + repeating-linear-gradient(0deg, transparent 0 19px, #161b2733 19px 20px), + repeating-linear-gradient(90deg, transparent 0 19px, #161b2733 19px 20px), + #0b0e14; +} + +.flow-canvas-inner { + position: relative; + width: 125rem; + height: 87.5rem; +} + +.flow-wires { + position: absolute; + top: 0; left: 0; + width: 100%; height: 100%; + pointer-events: none; + overflow: visible; +} + +.flow-wire { + fill: none; + stroke: #475569; + stroke-width: 2; + pointer-events: stroke; + cursor: pointer; +} +.flow-wire:hover { stroke: #64748b; } +.flow-wire-selected { stroke: #ef4444; stroke-width: 2.5; } +.flow-wire-pending { stroke: #3b82f6; stroke-dasharray: 5 4; } + +/* Node block */ +.flow-node { + position: absolute; + background: #161b27; + border: 1px solid #1e2433; + border-radius: 6px; + box-shadow: 0 2px 6px #0006; + cursor: move; + user-select: none; +} +.flow-node-trigger { border-top: 3px solid #f59e0b; } +.flow-node-gate { border-top: 3px solid #a855f7; } +.flow-node-flow { border-top: 3px solid #10b981; } +.flow-node-action { border-top: 3px solid #3b82f6; } +.flow-node-selected { border-color: #3b82f6; box-shadow: 0 0 0 2px #3b82f680; } + +.flow-node-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.3rem 0.5rem; +} +.flow-node-title { font-size: 0.78rem; font-weight: 600; color: #e2e8f0; } +.flow-node-del { + background: none; border: none; color: #64748b; + cursor: pointer; font-size: 0.75rem; line-height: 1; padding: 0; +} +.flow-node-del:hover { color: #ef4444; } + +.flow-node-body { + padding: 0 0.5rem 0.4rem; + font-size: 0.7rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Ports — input/output positions are set inline (in rem-scaled px) so they + stay aligned with the node geometry on any root font size. */ +.flow-port { + position: absolute; + width: 0.75rem; + height: 0.75rem; + border-radius: 50%; + background: #0b0e14; + border: 2px solid #64748b; + cursor: crosshair; +} +.flow-port:hover { border-color: #3b82f6; background: #3b82f6; } +.flow-port-then { border-color: #10b981; } +.flow-port-body { border-color: #10b981; } +.flow-port-else { border-color: #ef4444; } + +.flow-port-label { + position: absolute; + right: 10px; + font-size: 0.6rem; + color: #64748b; + pointer-events: none; +} + +/* Expression field: signal-insert helper row */ +.flow-expr-insert { + display: flex; + gap: 0.35rem; + margin-top: 0.35rem; +} +.flow-expr-insert > * { flex: 1; min-width: 0; } +.prop-input-error { border-color: #ef4444; } + +/* Inspector */ +.flow-inspector { + width: 15rem; + flex: 0 0 auto; + border-left: 1px solid #1e2433; + background: #0f1117; + padding: 0.75rem; + overflow-y: auto; +} + +/* Local-variable editor in the palette */ +.flow-localvars { + margin-top: 0.5rem; + border-top: 1px solid #1e2433; + padding-top: 0.5rem; + display: flex; + flex-direction: column; + gap: 0.3rem; +} +.flow-localvars-empty { padding: 0; } +.flow-localvar-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.35rem; + padding: 0.2rem 0.4rem; + background: #161b27; + border: 1px solid #1e2433; + border-left: 3px solid #14b8a6; + border-radius: 4px; +} +.flow-localvar-name { + font-size: 0.78rem; + color: #e2e8f0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.flow-localvar-add { text-align: center; border-left-color: #14b8a6; } +.flow-localvar-form { + display: flex; + flex-direction: column; + gap: 0.3rem; +} +.flow-localvar-actions { + display: flex; + gap: 0.35rem; +} +.flow-localvar-actions .panel-btn { flex: 1; } + +/* ── History pane ───────────────────────────────────────────────────────── */ + +.history-pane { + width: 280px; + flex-shrink: 0; + display: flex; + flex-direction: column; + background: #11151f; + border-left: 1px solid #1f2733; + overflow: hidden; +} + +.history-pane-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px; + font-weight: 600; + font-size: 13px; + border-bottom: 1px solid #1f2733; + color: #cbd5e1; +} + +.history-save-row { + display: flex; + gap: 6px; + padding: 10px 12px; + border-bottom: 1px solid #1f2733; +} + +.history-tag-input { + flex: 1; + min-width: 0; + background: #0a0d14; + border: 1px solid #2a3543; + border-radius: 4px; + color: #e2e8f0; + padding: 4px 8px; + font-size: 12px; +} + +.history-list { + flex: 1; + overflow-y: auto; + padding: 6px; +} + +.history-empty { + padding: 16px 12px; + color: #64748b; + font-size: 12px; + text-align: center; +} + +.history-item { + padding: 8px 10px; + border: 1px solid #1f2733; + border-radius: 6px; + margin-bottom: 6px; + background: #0e1219; +} + +.history-item-current { + border-color: #2563eb; + background: #0f1830; +} + +.history-item-main { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.history-version { + font-weight: 600; + font-size: 12px; + color: #93c5fd; +} + +.history-tag { + font-size: 11px; + padding: 1px 6px; + border-radius: 10px; + background: #1e293b; + color: #cbd5e1; +} + +.history-current-badge { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 1px 6px; + border-radius: 10px; + background: #2563eb; + color: #fff; +} + +.history-item-meta { + font-size: 11px; + color: #64748b; + margin-top: 3px; +} + +.history-item-actions { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 6px; +} + +.history-action-btn { + flex: 1 1 auto; + font-size: 11px; + padding: 3px 6px; + background: #1e293b; + border: 1px solid #2a3543; + border-radius: 4px; + color: #cbd5e1; + cursor: pointer; + white-space: nowrap; +} + +.history-action-btn:hover { + background: #2a3543; +} + /* ── Edit Canvas ────────────────────────────────────────────────────────── */ .edit-canvas-container { @@ -1792,6 +2238,47 @@ body { .iface-delete { color: #ef4444 !important; } .iface-delete:hover { background: rgba(239, 68, 68, 0.15) !important; } +/* ── Panel sharing / folders (access control) ───────────────────────────── */ + +.iface-badge { + display: inline-block; + margin-left: 6px; + padding: 0 4px; + font-size: 0.6rem; + line-height: 1.3; + color: #94a3b8; + background: #1e293b; + border: 1px solid #334155; + border-radius: 3px; + vertical-align: middle; +} + +.iface-folder { list-style: none; } + +.iface-folder-header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.iface-folder-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + cursor: pointer; + color: #cbd5e1; + font-weight: 600; +} + +.iface-folder:hover .iface-item-actions { display: flex; } + +.iface-sublist { + margin-left: 0.75rem; + border-left: 1px solid #2d3748; + padding-left: 0.4rem; +} + /* ── Phase 7 additions ───────────────────────────────────────────────────── */ /* Rubber-band selection rectangle */ @@ -2395,39 +2882,6 @@ kbd { overflow: hidden; } -.view-tabs { - display: flex; - align-items: center; - gap: 2px; - padding: 0 0.5rem; - height: 2.25rem; - background: #0f1117; - border-bottom: 1px solid #1e293b; - flex-shrink: 0; -} - -.view-tab { - background: none; - border: none; - border-bottom: 2px solid transparent; - color: #64748b; - font-size: 0.82rem; - padding: 0 0.75rem; - height: 100%; - cursor: pointer; - transition: color 0.15s, border-color 0.15s; - white-space: nowrap; -} - -.view-tab:hover { - color: #cbd5e1; -} - -.view-tab-active { - color: #e2e8f0; - border-bottom-color: #4a9eff; -} - /* ── InfoPanel ─────────────────────────────────────────────────────────── */ .info-panel { @@ -2527,220 +2981,6 @@ kbd { line-height: 1.4; } -/* ── PlotPanel ─────────────────────────────────────────────────────────── */ - -.plot-panel { - display: flex; - flex-direction: column; - flex: 1; - min-height: 0; - background: #0f1117; -} - -.plot-panel-toolbar { - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0 0.75rem; - height: 2.5rem; - background: #0f1117; - border-bottom: 1px solid #1e293b; - flex-shrink: 0; -} - -.plot-panel-title { - font-size: 0.8rem; - font-weight: 600; - color: #94a3b8; - margin-right: 0.5rem; - white-space: nowrap; -} - -.plot-window-btns { - display: flex; - gap: 2px; -} - -.plot-panel-body { - display: flex; - flex: 1; - min-height: 0; - overflow: hidden; -} - -.plot-empty-hint { - flex: 1; - display: flex; - align-items: center; - justify-content: center; - color: #475569; - font-size: 0.88rem; - text-align: center; - padding: 2rem; -} - -.plot-panel-content { - display: flex; - flex: 1; - min-width: 0; - min-height: 0; - overflow: hidden; -} - -.plot-panel-legend { - width: 160px; - flex-shrink: 0; - overflow-y: auto; - border-right: 1px solid #1e293b; - padding: 0.5rem 0; - display: flex; - flex-direction: column; - gap: 0.25rem; -} - -.plot-legend-item { - padding: 0.4rem 0.6rem; - border-radius: 4px; -} -.plot-legend-item:hover { background: #1a1f2e; } - -.plot-legend-hdr { - display: flex; - align-items: center; - gap: 0.35rem; - margin-bottom: 0.3rem; -} - -.plot-legend-swatch { - width: 10px; - height: 10px; - border-radius: 2px; - flex-shrink: 0; -} - -.plot-legend-name { - flex: 1; - font-size: 0.75rem; - color: #cbd5e1; - font-family: ui-monospace, monospace; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.plot-legend-icon-btn { - background: none; - border: none; - color: #475569; - cursor: pointer; - font-size: 0.75rem; - padding: 0 0.15rem; - line-height: 1; - opacity: 0.6; - border-radius: 3px; -} -.plot-legend-icon-btn:hover { opacity: 1; color: #94a3b8; } -.plot-legend-icon-btn.active { opacity: 1; color: #4a9eff; } - -/* kept for back-compat — remove button is now .plot-legend-icon-btn */ -.plot-legend-rm { - background: none; - border: none; - color: #475569; - cursor: pointer; - font-size: 0.7rem; - padding: 0 0.15rem; - opacity: 0.6; -} -.plot-legend-rm:hover { opacity: 1; color: #f87171; } - -/* ── Per-signal style editor ────────────────────────────────────────────── */ - -.plot-style-editor { - margin-top: 0.4rem; - padding-top: 0.4rem; - border-top: 1px solid #1e293b; - display: flex; - flex-direction: column; - gap: 0.3rem; -} - -.plot-style-row { - display: flex; - align-items: center; - gap: 0.4rem; -} - -.plot-style-label { - font-size: 0.68rem; - color: #64748b; - width: 2.8rem; - flex-shrink: 0; -} - -.plot-style-color { - width: 28px; - height: 20px; - border: 1px solid #2d3748; - border-radius: 3px; - padding: 0; - cursor: pointer; - background: none; -} - -.plot-style-btns { - display: flex; - gap: 2px; -} - -.plot-style-btn { - background: #1e293b; - border: 1px solid #2d3748; - color: #64748b; - cursor: pointer; - font-size: 0.68rem; - padding: 1px 5px; - border-radius: 3px; - line-height: 1.4; - min-width: 1.6rem; - text-align: center; -} -.plot-style-btn:hover { color: #cbd5e1; border-color: #475569; } -.plot-style-btn.active { background: #1e3a5f; border-color: #4a9eff; color: #e2e8f0; } - -.plot-stats-tbl { - width: 100%; - border-collapse: collapse; - font-size: 0.72rem; -} -.plot-stats-tbl td { - padding: 0.05rem 0; - color: #475569; -} -.plot-stats-tbl td:first-child { - width: 44px; - color: #334155; - font-size: 0.68rem; -} -.plot-stats-tbl td:last-child { - color: #94a3b8; - font-family: ui-monospace, monospace; -} - -.plot-panel-chart { - flex: 1; - min-width: 0; - min-height: 0; - overflow: hidden; -} - -/* uPlot inside plot-panel needs full dimensions */ -.plot-panel-chart .uplot, -.plot-panel-chart .u-wrap { - width: 100% !important; - height: 100% !important; -} - /* ── Zoom control ─────────────────────────────────────────────────────────── */ .zoom-control { @@ -2800,6 +3040,15 @@ kbd { margin-left: 0.25rem; } +/* Muted placeholder text inside the trigger. Unlike .hint it adds no padding, + so an empty select is exactly as tall as one showing a selected value. */ +.search-select-placeholder { + color: #475569; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .search-select-dropdown { position: absolute; top: 100%; @@ -2893,11 +3142,190 @@ kbd { } .panel-btn.icon-only { - padding: 0; - width: 1.6rem; + padding: 0 0.35rem; + min-width: 1.6rem; + width: auto; height: 1.6rem; display: flex; align-items: center; justify-content: center; font-size: 0.9rem; } + +.app-root { + display: flex; + flex-direction: column; + height: 100vh; + width: 100%; + overflow: hidden; +} + +.view-panel-container { + display: none; + flex: 1; + min-height: 0; + position: relative; +} + +.view-panel-container.active { + display: flex; + flex-direction: column; +} + +/* ── Plot panels (split layout) ─────────────────────────────────────────── */ + +/* Edit surface: fills the editor body flex slot. */ +.plot-panel-edit { + flex: 1; + min-width: 0; + min-height: 0; + position: relative; + overflow: hidden; + display: flex; + background: #0a0d14; +} + +/* View/fullscreen surface: fills the (relative) canvas container. */ +.plot-panel-view { + position: absolute; + inset: 0; + display: flex; +} + +.split-root { + flex: 1; + display: flex; + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; +} + +.split-row { display: flex; flex-direction: row; flex: 1; min-width: 0; min-height: 0; } +.split-col { display: flex; flex-direction: column; flex: 1; min-width: 0; min-height: 0; } + +.split-child { + display: flex; + min-width: 0; + min-height: 0; + overflow: hidden; +} + +.split-leaf { + flex: 1; + display: flex; + position: relative; + min-width: 0; + min-height: 0; +} + +.split-divider { + flex: 0 0 auto; + background: #1e293b; + transition: background 0.12s; +} +.split-divider-h { width: 6px; cursor: col-resize; } +.split-divider-v { height: 6px; cursor: row-resize; } +.split-divider:hover { background: #4a9eff; } +.split-divider-static { pointer-events: none; } + +/* Neutralize the free-form absolute positioning of plot widgets so they fill + their split pane responsively (the chart uses a ResizeObserver). */ +.split-leaf .plot-widget { + position: relative !important; + left: auto !important; + top: auto !important; + width: 100% !important; + height: 100% !important; + border-radius: 0; +} +.split-leaf .chart-area { + width: 100% !important; + height: 100% !important; +} + +/* Editable pane chrome. */ +.plot-pane { + position: relative; + flex: 1; + display: flex; + min-width: 0; + min-height: 0; + overflow: hidden; + background: #0f1117; + outline: 1px solid transparent; +} +/* Dim panes that are not the one currently being edited. */ +.plot-panel-edit .plot-pane:not(.plot-pane-selected)::after { + content: ''; + position: absolute; + inset: 0; + background: rgba(10, 14, 22, 0.45); + z-index: 4; + pointer-events: none; +} + +.plot-pane-selected { + outline: 3px solid #4a9eff; + outline-offset: -3px; + box-shadow: inset 0 0 0 1px rgba(74, 158, 255, 0.6), + inset 0 0 18px rgba(74, 158, 255, 0.35); +} + +.plot-pane-badge { + position: absolute; + top: 4px; + left: 4px; + z-index: 6; + padding: 1px 7px; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: #0b1018; + background: #4a9eff; + border-radius: 3px; + pointer-events: none; +} + +.plot-pane-empty, +.plot-pane-hint { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + color: #475569; + font-size: 0.82rem; + text-align: center; + padding: 1rem; + pointer-events: none; +} +.plot-pane-hint { top: auto; bottom: 0.6rem; transform: translateX(-50%); } + +.plot-pane-overlay { + position: absolute; + top: 4px; + right: 4px; + display: flex; + gap: 2px; + z-index: 5; + opacity: 0; + transition: opacity 0.12s; +} +.plot-pane:hover .plot-pane-overlay, +.plot-pane-selected .plot-pane-overlay { + opacity: 1; +} + +.plot-pane-btn { + background: #1e293b; + border: 1px solid #2d3748; + color: #94a3b8; + cursor: pointer; + font-size: 0.8rem; + line-height: 1; + padding: 3px 6px; + border-radius: 3px; +} +.plot-pane-btn:hover { color: #e2e8f0; border-color: #4a9eff; } +.plot-pane-btn:disabled { opacity: 0.35; cursor: default; } diff --git a/web/src/widgets/Button.tsx b/web/src/widgets/Button.tsx index 3f4ab42..7364233 100644 --- a/web/src/widgets/Button.tsx +++ b/web/src/widgets/Button.tsx @@ -1,8 +1,10 @@ import { h } from 'preact'; import { useState, useEffect } from 'preact/hooks'; import { wsClient } from '../lib/ws'; -import { getSignalStore } from '../lib/stores'; -import type { Widget } from '../lib/types'; +import { getSignalStore, getMetaStore } from '../lib/stores'; +import { logicEngine } from '../lib/logic'; +import { useAuth, canWrite } from '../lib/auth'; +import type { Widget, SignalMeta } from '../lib/types'; interface Props { widget: Widget; onContextMenu?: (e: MouseEvent) => void; } @@ -15,8 +17,12 @@ export default function Button({ widget, onContextMenu }: Props) { const mode = widget.options['mode'] ?? 'oneshot'; const resetTimeSec = parseFloat(widget.options['resetTime'] ?? '1'); const confirmOpt = widget.options['confirm'] === 'true'; + // Optional panel-logic sequence fired on click (in addition to any signal write). + const action = widget.options['action'] ?? ''; + const me = useAuth(); const [signalVal, setSignalVal] = useState(null); + const [meta, setMeta] = useState(null); // Subscribe to signal only for persistent mode (to track whether it is active) useEffect(() => { @@ -24,6 +30,20 @@ export default function Button({ widget, onContextMenu }: Props) { return getSignalStore(sigRef).subscribe(sv => setSignalVal(sv.value)); }, [sigRef?.ds, sigRef?.name, mode]); + // Track the target signal's writability so the button can be disabled when + // the user has no permission to write it (real signals only; local panel + // vars are written client-side and have no backend metadata). + useEffect(() => { + if (!sigRef || sigRef.ds === 'local') return; + return getMetaStore(sigRef).subscribe(setMeta); + }, [sigRef?.ds, sigRef?.name]); + + // A button that drives a real signal is disabled when the user cannot write + // it (read-only access, or the signal reports it is not writable). Buttons + // that only run a logic action — or target a local var — stay enabled. + const drivesRealSignal = !!sigRef && sigRef.ds !== 'local'; + const writeAllowed = !drivesRealSignal || (canWrite(me.level) && meta?.writable !== false); + const parsedSend = isNaN(parseFloat(sendValue)) ? sendValue : parseFloat(sendValue); const parsedReset = isNaN(parseFloat(resetValue)) ? resetValue : parseFloat(resetValue); @@ -38,18 +58,23 @@ export default function Button({ widget, onContextMenu }: Props) { } function execute() { - if (mode === 'setReset') { - doWrite(parsedSend); - setTimeout(() => doWrite(parsedReset), Math.max(0, resetTimeSec) * 1000); - } else if (mode === 'persistent') { - doWrite(isPressed ? parsedReset : parsedSend); - } else { - doWrite(parsedSend); + if (sigRef) { + if (mode === 'setReset') { + doWrite(parsedSend); + setTimeout(() => doWrite(parsedReset), Math.max(0, resetTimeSec) * 1000); + } else if (mode === 'persistent') { + doWrite(isPressed ? parsedReset : parsedSend); + } else { + doWrite(parsedSend); + } } + if (action) logicEngine.runAction(action); } function handleClick() { - if (!sigRef) return; + // A button may drive a signal, a logic sequence, or both. + if (!sigRef && !action) return; + if (!writeAllowed) return; if (confirmOpt) { if (window.confirm(`Send ${label}?`)) execute(); } else { @@ -63,7 +88,9 @@ export default function Button({ widget, onContextMenu }: Props) { style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`} onContextMenu={onContextMenu} > - + ) : ( <> @@ -121,13 +133,15 @@ export default function SetValue({ widget, onContextMenu }: Props) { class="sv-input" type={isNumeric ? 'number' : 'text'} value={inputValue} + disabled={!writeAllowed} onInput={(e: Event) => setInputValue((e.target as HTMLInputElement).value)} onKeyDown={handleKeydown} placeholder="value" /> {displayValue()} {unit && {unit}} - + )}
    diff --git a/workspace/data/acl.json b/workspace/data/acl.json new file mode 100644 index 0000000..5998f24 --- /dev/null +++ b/workspace/data/acl.json @@ -0,0 +1,10 @@ +{ + "panels": {}, + "folders": { + "fld-68b0bb9c23c9fd3b": { + "id": "fld-68b0bb9c23c9fd3b", + "name": "Test", + "owner": "" + } + } +} \ No newline at end of file diff --git a/workspace/run.sh b/workspace/run.sh index 110f2ed..2387a4f 100755 --- a/workspace/run.sh +++ b/workspace/run.sh @@ -128,7 +128,7 @@ fi # ── Start uopi ──────────────────────────────────────────────────────────────── echo "" -echo "Starting uopi (http://localhost:8080)..." +echo "Starting uopi (http://localhost:8088)..." echo " UOPI_CA_DEBUG=$UOPI_CA_DEBUG" echo " Config: $WORKSPACE/uopi.toml" echo " Log will appear below. Press Ctrl-C to stop."