Major changes: logic add to panel, local variables, panel histor, users management...

This commit is contained in:
Martino Ferrari
2026-06-18 17:37:04 +02:00
parent 71430bc3b0
commit aba394b84d
54 changed files with 6104 additions and 1166 deletions
+22 -1
View File
@@ -11,12 +11,14 @@ import (
"os/signal" "os/signal"
"syscall" "syscall"
"github.com/uopi/uopi/internal/access"
"github.com/uopi/uopi/internal/broker" "github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/config" "github.com/uopi/uopi/internal/config"
"github.com/uopi/uopi/internal/datasource/epics" "github.com/uopi/uopi/internal/datasource/epics"
"github.com/uopi/uopi/internal/datasource/pva" "github.com/uopi/uopi/internal/datasource/pva"
"github.com/uopi/uopi/internal/datasource/stub" "github.com/uopi/uopi/internal/datasource/stub"
"github.com/uopi/uopi/internal/datasource/synthetic" "github.com/uopi/uopi/internal/datasource/synthetic"
"github.com/uopi/uopi/internal/panelacl"
"github.com/uopi/uopi/internal/server" "github.com/uopi/uopi/internal/server"
"github.com/uopi/uopi/internal/storage" "github.com/uopi/uopi/internal/storage"
"github.com/uopi/uopi/web" "github.com/uopi/uopi/web"
@@ -49,6 +51,12 @@ func main() {
os.Exit(1) 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") webFS, err := fs.Sub(web.FS, "dist")
if err != nil { if err != nil {
log.Error("failed to sub web dist", "err", err) 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 { if err := srv.Start(ctx); err != nil {
fmt.Fprintf(os.Stderr, "server error: %v\n", err) fmt.Fprintf(os.Stderr, "server error: %v\n", err)
-32
View File
@@ -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())
}
+150
View File
@@ -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
}
+557 -11
View File
@@ -2,17 +2,22 @@
package api package api
import ( import (
"crypto/rand"
"encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
"io" "io"
"log/slog" "log/slog"
"net/http" "net/http"
"net/url" "net/url"
"strconv"
"strings" "strings"
"github.com/uopi/uopi/internal/access"
"github.com/uopi/uopi/internal/broker" "github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/datasource" "github.com/uopi/uopi/internal/datasource"
"github.com/uopi/uopi/internal/datasource/synthetic" "github.com/uopi/uopi/internal/datasource/synthetic"
"github.com/uopi/uopi/internal/panelacl"
"github.com/uopi/uopi/internal/storage" "github.com/uopi/uopi/internal/storage"
) )
@@ -21,17 +26,21 @@ type Handler struct {
broker *broker.Broker broker *broker.Broker
synthetic *synthetic.Synthetic // nil if not enabled synthetic *synthetic.Synthetic // nil if not enabled
store *storage.Store store *storage.Store
policy *access.Policy
acl *panelacl.Store
channelFinderURL string // empty if not configured channelFinderURL string // empty if not configured
archiverURL string // empty if not configured archiverURL string // empty if not configured
log *slog.Logger log *slog.Logger
} }
// New creates an API Handler. synth may be nil if the synthetic DS is disabled. // New creates an API Handler. synth may be nil if the synthetic DS is disabled.
func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, 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{ return &Handler{
broker: b, broker: b,
synthetic: synth, synthetic: synth,
store: store, store: store,
policy: policy,
acl: acl,
channelFinderURL: channelFinderURL, channelFinderURL: channelFinderURL,
archiverURL: archiverURL, archiverURL: archiverURL,
log: log, 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. // Register mounts all API routes onto mux under the given prefix.
// Requires Go 1.22+ for method+path routing. // Requires Go 1.22+ for method+path routing.
func (h *Handler) Register(mux *http.ServeMux, prefix string) { func (h *Handler) Register(mux *http.ServeMux, prefix string) {
mux.HandleFunc("GET "+prefix+"/me", h.getMe)
mux.HandleFunc("GET "+prefix+"/datasources", h.listDataSources) mux.HandleFunc("GET "+prefix+"/datasources", h.listDataSources)
mux.HandleFunc("GET "+prefix+"/signals", h.listSignals) mux.HandleFunc("GET "+prefix+"/signals", h.listSignals)
mux.HandleFunc("GET "+prefix+"/signals/search", h.searchSignals) mux.HandleFunc("GET "+prefix+"/signals/search", h.searchSignals)
@@ -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+"/archiver/search", h.archiverSearch)
mux.HandleFunc("GET "+prefix+"/interfaces", h.listInterfaces) mux.HandleFunc("GET "+prefix+"/interfaces", h.listInterfaces)
mux.HandleFunc("POST "+prefix+"/interfaces", h.createInterface) 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("GET "+prefix+"/interfaces/{id}", h.getInterface)
mux.HandleFunc("PUT "+prefix+"/interfaces/{id}", h.updateInterface) mux.HandleFunc("PUT "+prefix+"/interfaces/{id}", h.updateInterface)
mux.HandleFunc("DELETE "+prefix+"/interfaces/{id}", h.deleteInterface) mux.HandleFunc("DELETE "+prefix+"/interfaces/{id}", h.deleteInterface)
mux.HandleFunc("POST "+prefix+"/interfaces/{id}/clone", h.cloneInterface) 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 // Signal group tree
mux.HandleFunc("GET "+prefix+"/groups", h.getGroups) mux.HandleFunc("GET "+prefix+"/groups", h.getGroups)
mux.HandleFunc("PUT "+prefix+"/groups", h.putGroups) 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) 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 ────────────────────────────────────────────────────────────── // ── /datasources ──────────────────────────────────────────────────────────────
type dsInfo struct { type dsInfo struct {
@@ -101,6 +188,22 @@ func (h *Handler) listSignals(w http.ResponseWriter, r *http.Request) {
return 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) ds, ok := h.broker.Source(dsName)
if !ok { if !ok {
jsonError(w, http.StatusNotFound, "unknown data source: "+dsName) 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) 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 ─────────────────────────────────────────────────────────── // ── /signals/search ───────────────────────────────────────────────────────────
func (h *Handler) searchSignals(w http.ResponseWriter, r *http.Request) { func (h *Handler) searchSignals(w http.ResponseWriter, r *http.Request) {
@@ -139,12 +256,23 @@ func (h *Handler) searchSignals(w http.ResponseWriter, r *http.Request) {
sources = h.broker.DataSources() sources = h.broker.DataSources()
} }
user := caller(r)
panel := r.URL.Query().Get("panel")
var out []signalInfo var out []signalInfo
for _, ds := range sources { for _, ds := range sources {
metas, err := ds.ListSignals(r.Context()) 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 { if err != nil {
continue continue
} }
}
for _, m := range metas { for _, m := range metas {
if q == "" || strings.Contains(strings.ToLower(m.Name), q) || strings.Contains(strings.ToLower(m.Description), q) { if q == "" || strings.Contains(strings.ToLower(m.Name), q) || strings.Contains(strings.ToLower(m.Description), q) {
si := metaToSignalInfo(m) si := metaToSignalInfo(m)
@@ -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 // channelFinder proxies a query to the EPICS Channel Finder service when one
// is configured, returning a flat list of PV names. Returns 501 otherwise. // is configured, returning a flat list of PV names. Returns 501 otherwise.
func (h *Handler) channelFinder(w http.ResponseWriter, r *http.Request) { 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 == "" { if h.channelFinderURL == "" {
jsonError(w, http.StatusNotImplemented, "Channel Finder not configured (set channel_finder_url in config)") jsonError(w, http.StatusNotImplemented, "Channel Finder not configured (set channel_finder_url in config)")
return return
} }
q := r.URL.Query().Get("q")
cfURL := strings.TrimRight(h.channelFinderURL, "/") + "/resources/channels" cfURL := strings.TrimRight(h.channelFinderURL, "/") + "/resources/channels"
if q != "" { if q != "" {
cfURL += "?~name=*" + q + "*" cfURL += "?~name=*" + q + "*"
} else if r.URL.RawQuery != "" && r.URL.RawQuery != "q=" {
cfURL += "?" + r.URL.RawQuery
} else { } else {
// Empty query check: return empty results to signal CFS is configured. // Structured query (e.g. ~name=, ~tag=, property filters) passed through.
jsonOK(w, []struct{}{}) cfURL += "?" + r.URL.RawQuery
return
} }
resp, err := http.Get(cfURL) //nolint:noctx resp, err := http.Get(cfURL) //nolint:noctx
@@ -281,6 +415,16 @@ func (h *Handler) putGroups(w http.ResponseWriter, r *http.Request) {
// ── /interfaces ─────────────────────────────────────────────────────────────── // ── /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) { func (h *Handler) listInterfaces(w http.ResponseWriter, r *http.Request) {
list, err := h.store.List() list, err := h.store.List()
if err != nil { if err != nil {
@@ -288,7 +432,21 @@ func (h *Handler) listInterfaces(w http.ResponseWriter, r *http.Request) {
jsonError(w, http.StatusInternalServerError, err.Error()) jsonError(w, http.StatusInternalServerError, err.Error())
return 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) { 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()) jsonError(w, http.StatusBadRequest, "read body: "+err.Error())
return return
} }
id, err := h.store.Create(body) id, err := h.store.Create(body, r.URL.Query().Get("tag"))
if err != nil { if err != nil {
h.log.Error("create interface", "err", err) h.log.Error("create interface", "err", err)
jsonError(w, http.StatusBadRequest, err.Error()) jsonError(w, http.StatusBadRequest, err.Error())
return 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.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated) w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(map[string]string{"id": id}) _ = json.NewEncoder(w).Encode(map[string]string{"id": id})
@@ -310,6 +476,10 @@ func (h *Handler) createInterface(w http.ResponseWriter, r *http.Request) {
func (h *Handler) getInterface(w http.ResponseWriter, r *http.Request) { func (h *Handler) getInterface(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id") 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) data, err := h.store.Get(id)
if err != nil { if err != nil {
if errors.Is(err, storage.ErrNotFound) { 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) { func (h *Handler) updateInterface(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id") 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)) body, err := io.ReadAll(io.LimitReader(r.Body, 4<<20))
if err != nil { if err != nil {
jsonError(w, http.StatusBadRequest, "read body: "+err.Error()) jsonError(w, http.StatusBadRequest, "read body: "+err.Error())
return 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) { if errors.Is(err, storage.ErrNotFound) {
jsonError(w, http.StatusNotFound, "interface not found: "+id) jsonError(w, http.StatusNotFound, "interface not found: "+id)
} else { } else {
@@ -342,8 +516,133 @@ func (h *Handler) updateInterface(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent) 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) { func (h *Handler) deleteInterface(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id") 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 err := h.store.Delete(id); err != nil {
if errors.Is(err, storage.ErrNotFound) { if errors.Is(err, storage.ErrNotFound) {
jsonError(w, http.StatusNotFound, "interface not found: "+id) jsonError(w, http.StatusNotFound, "interface not found: "+id)
@@ -352,11 +651,18 @@ func (h *Handler) deleteInterface(w http.ResponseWriter, r *http.Request) {
} }
return return
} }
if err := h.acl.DeletePanel(id); err != nil {
h.log.Error("delete panel ACL", "id", id, "err", err)
}
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
func (h *Handler) cloneInterface(w http.ResponseWriter, r *http.Request) { func (h *Handler) cloneInterface(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id") 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) newID, err := h.store.Clone(id)
if err != nil { if err != nil {
if errors.Is(err, storage.ErrNotFound) { if errors.Is(err, storage.ErrNotFound) {
@@ -367,11 +673,242 @@ func (h *Handler) cloneInterface(w http.ResponseWriter, r *http.Request) {
} }
return 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.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated) w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(map[string]string{"id": newID}) _ = 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 ──────────────────────────────────────────────────────────────── // ── /synthetic ────────────────────────────────────────────────────────────────
func (h *Handler) listSynthetic(w http.ResponseWriter, _ *http.Request) { 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()) jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return 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 err := h.synthetic.AddSignal(def); err != nil {
if errors.Is(err, datasource.ErrNotFound) { if errors.Is(err, datasource.ErrNotFound) {
jsonError(w, http.StatusConflict, err.Error()) 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). // Ensure the name in the URL matches the body (or fill it in).
def.Name = name 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 err := h.synthetic.UpdateSignal(def); err != nil {
if errors.Is(err, datasource.ErrNotFound) { if errors.Is(err, datasource.ErrNotFound) {
jsonError(w, http.StatusNotFound, "synthetic signal not found: "+name) jsonError(w, http.StatusNotFound, "synthetic signal not found: "+name)
+9 -3
View File
@@ -12,9 +12,11 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/uopi/uopi/internal/access"
"github.com/uopi/uopi/internal/api" "github.com/uopi/uopi/internal/api"
"github.com/uopi/uopi/internal/broker" "github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/datasource/stub" "github.com/uopi/uopi/internal/datasource/stub"
"github.com/uopi/uopi/internal/panelacl"
"github.com/uopi/uopi/internal/storage" "github.com/uopi/uopi/internal/storage"
) )
@@ -38,9 +40,13 @@ func setup(t *testing.T) (*httptest.Server, func()) {
if err != nil { if err != nil {
t.Fatal("storage.New:", err) t.Fatal("storage.New:", err)
} }
acl, err := panelacl.New(dir)
if err != nil {
t.Fatal("panelacl.New:", err)
}
mux := http.NewServeMux() 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) srv := httptest.NewServer(mux)
return srv, func() { return srv, func() {
@@ -396,7 +402,7 @@ func TestStorageValidateID(t *testing.T) {
} }
// Create a real interface first to get a valid ID. // 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 { if err != nil {
t.Fatal("create:", err) t.Fatal("create:", err)
} }
@@ -416,7 +422,7 @@ func TestStorageValidateID(t *testing.T) {
if _, err := store.Get(bad); err == nil { if _, err := store.Get(bad); err == nil {
t.Errorf("Get(%q) should have failed, got nil error", bad) 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) t.Errorf("Update(%q) should have failed", bad)
} }
if err := store.Delete(bad); err == nil { if err := store.Delete(bad); err == nil {
+38
View File
@@ -12,12 +12,44 @@ import (
type Config struct { type Config struct {
Server ServerConfig `toml:"server"` Server ServerConfig `toml:"server"`
Datasource DatasourceConfig `toml:"datasource"` 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 { type ServerConfig struct {
Listen string `toml:"listen"` Listen string `toml:"listen"`
StorageDir string `toml:"storage_dir"` StorageDir string `toml:"storage_dir"`
MaxUpdateRateHz float64 `toml:"max_update_rate_hz"` // 0 = unlimited 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 { type DatasourceConfig struct {
@@ -93,6 +125,12 @@ func applyEnv(cfg *Config) {
cfg.Server.MaxUpdateRateHz = hz 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 != "" { if v := env("UOPI_EPICS_CA_ADDR_LIST"); v != "" {
cfg.Datasource.EPICS.CAAddrList = v cfg.Datasource.EPICS.CAAddrList = v
} }
+5
View File
@@ -508,6 +508,11 @@ func (e *EPICS) ListSignals(_ context.Context) ([]datasource.Metadata, error) {
// Write puts a new value onto a CA channel. // Write puts a new value onto a CA channel.
// If the signal is not currently subscribed (e.g. a button in oneshot mode), // 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. // 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 { func (e *EPICS) Write(ctx context.Context, signal string, value any) error {
e.attachCAContext() e.attachCAContext()
+85 -2
View File
@@ -39,8 +39,29 @@ type EPICS struct {
mu sync.RWMutex mu sync.RWMutex
metadata map[string]datasource.Metadata 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. // New creates a new EPICS Channel Access data source.
// //
// caAddrList is the EPICS_CA_ADDR_LIST value (space-separated addresses). // 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) return fmt.Errorf("epics: %w", err)
} }
e.client = cli 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. // Perform background sync from Channel Finder if configured.
if e.cfURL != "" && e.autoSyncFilter != "" { 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. // Write puts a new value onto the named CA channel.
// value must be one of: float64, float32, int64, int32, int, int16, string, bool. // 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 { 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 fmt.Errorf("epics: write %q: %w", signal, err)
} }
return nil 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 // // History //
// -------------------------------------------------------------------------- // // -------------------------------------------------------------------------- //
@@ -10,6 +10,16 @@ type SignalDef struct {
Inputs []InputRef `json:"inputs"` // alternative multi-input format Inputs []InputRef `json:"inputs"` // alternative multi-input format
Pipeline []NodeDef `json:"pipeline"` // ordered list of DSP nodes Pipeline []NodeDef `json:"pipeline"` // ordered list of DSP nodes
Meta MetaOverride `json:"meta"` // optional metadata overrides 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. // InputRef names one upstream signal used as input to the pipeline.
@@ -83,6 +83,9 @@ func buildNode(d NodeDef) (dsp.Node, error) {
case "derivative": case "derivative":
return &dsp.DerivativeNode{}, nil return &dsp.DerivativeNode{}, nil
case "integrate":
return &dsp.IntegrateNode{}, nil
case "clamp": case "clamp":
return &dsp.ClampNode{ return &dsp.ClampNode{
Min: floatParam(p, "min"), Min: floatParam(p, "min"),
@@ -85,6 +85,22 @@ func (s *Synthetic) ListSignals(_ context.Context) ([]datasource.Metadata, error
return out, nil 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. // GetMetadata returns metadata for a single named signal.
func (s *Synthetic) GetMetadata(_ context.Context, signal string) (datasource.Metadata, error) { func (s *Synthetic) GetMetadata(_ context.Context, signal string) (datasource.Metadata, error) {
s.mu.RLock() s.mu.RLock()
+27
View File
@@ -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 != ""
}
+28
View File
@@ -204,6 +204,34 @@ func (n *DerivativeNode) Process(inputs []float64, state map[string]any) (float6
return 0, nil 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 ─────────────────────────────────────────────────────────────────
// ClampNode clamps the output to [Min, Max]. // ClampNode clamps the output to [Min, Max].
+417
View File
@@ -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
}
+109
View File
@@ -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)
}
}
+48 -4
View File
@@ -5,12 +5,15 @@ import (
"io/fs" "io/fs"
"log/slog" "log/slog"
"net/http" "net/http"
"strings"
"time" "time"
"github.com/uopi/uopi/internal/access"
"github.com/uopi/uopi/internal/api" "github.com/uopi/uopi/internal/api"
"github.com/uopi/uopi/internal/broker" "github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/datasource/synthetic" "github.com/uopi/uopi/internal/datasource/synthetic"
"github.com/uopi/uopi/internal/metrics" "github.com/uopi/uopi/internal/metrics"
"github.com/uopi/uopi/internal/panelacl"
"github.com/uopi/uopi/internal/storage" "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. // New creates the HTTP server, registers all routes, and returns a ready-to-start Server.
// synth may be nil if the synthetic data source is not enabled. // synth may be nil if the synthetic data source is not enabled.
func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, 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() mux := http.NewServeMux()
// Health check // Health check
@@ -33,13 +36,16 @@ func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Syntheti
}) })
// WebSocket endpoint // WebSocket endpoint
mux.Handle("/ws", &wsHandler{broker: brk, log: log}) mux.Handle("/ws", &wsHandler{broker: brk, log: log, userHeader: trustedUserHeader, policy: policy})
// Prometheus-format metrics // Prometheus-format metrics
mux.HandleFunc("/metrics", metrics.Handler(brk.ActiveSubscriptions)) mux.HandleFunc("/metrics", metrics.Handler(brk.ActiveSubscriptions))
// REST API // REST API — registered on a dedicated mux so it can be wrapped with the
api.New(brk, synth, store, channelFinderURL, archiverURL, log).Register(mux, apiPrefix) // 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) // Embedded frontend — must be last (catch-all)
mux.Handle("/", http.FileServerFS(webFS)) 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. // Start listens and serves until ctx is cancelled.
func (s *Server) Start(ctx context.Context) error { func (s *Server) Start(ctx context.Context) error {
s.log.Info("listening", "addr", s.httpServer.Addr) s.log.Info("listening", "addr", s.httpServer.Addr)
+30 -1
View File
@@ -6,11 +6,13 @@ import (
"errors" "errors"
"log/slog" "log/slog"
"net/http" "net/http"
"strings"
"sync" "sync"
"time" "time"
"github.com/coder/websocket" "github.com/coder/websocket"
"github.com/uopi/uopi/internal/access"
"github.com/uopi/uopi/internal/broker" "github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/datasource" "github.com/uopi/uopi/internal/datasource"
"github.com/uopi/uopi/internal/metrics" "github.com/uopi/uopi/internal/metrics"
@@ -87,9 +89,21 @@ type histPoint struct {
type wsHandler struct { type wsHandler struct {
broker *broker.Broker broker *broker.Broker
log *slog.Logger 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) { 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{ conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
// Permissive origin check for LAN / SSH-tunnel use; tighten when auth lands. // Permissive origin check for LAN / SSH-tunnel use; tighten when auth lands.
InsecureSkipVerify: true, InsecureSkipVerify: true,
@@ -107,6 +121,8 @@ func (h *wsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
c := &wsClient{ c := &wsClient{
conn: conn, conn: conn,
broker: h.broker, broker: h.broker,
user: user,
policy: h.policy,
outCh: make(chan []byte, 512), outCh: make(chan []byte, 512),
updateCh: make(chan broker.Update, 1024), updateCh: make(chan broker.Update, 1024),
subs: make(map[broker.SignalRef]func()), subs: make(map[broker.SignalRef]func()),
@@ -136,6 +152,8 @@ type wsClient struct {
conn *websocket.Conn conn *websocket.Conn
broker *broker.Broker broker *broker.Broker
log *slog.Logger 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 outCh chan []byte // serialised outgoing messages
updateCh chan broker.Update // raw updates from the broker 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) { 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) ds, ok := c.broker.Source(msg.DS)
if !ok { if !ok {
c.log.Warn("write: unknown data source", "ds", msg.DS, "name", msg.Name) c.log.Warn("write: unknown data source", "ds", msg.DS, "name", msg.Name)
@@ -305,8 +331,11 @@ func (c *wsClient) handleWrite(ctx context.Context, msg inMsg) {
return 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() 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 { if err := ds.Write(ctx, msg.Name, value); err != nil {
c.log.Warn("write: ds.Write failed", "ds", msg.DS, "name", msg.Name, "err", err) c.log.Warn("write: ds.Write failed", "ds", msg.DS, "name", msg.Name, "err", err)
c.sendError(ctx, "WRITE_ERROR", err.Error()) c.sendError(ctx, "WRITE_ERROR", err.Error())
+235 -10
View File
@@ -7,6 +7,7 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -23,6 +24,15 @@ type InterfaceMeta struct {
Version int `json:"version"` 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/ // Store persists interface definitions as XML files under {storageDir}/interfaces/
// and workspace-level JSON blobs directly in {storageDir}. // and workspace-level JSON blobs directly in {storageDir}.
type Store struct { type Store struct {
@@ -123,6 +133,7 @@ type rootAttrs struct {
ID string `xml:"id,attr"` ID string `xml:"id,attr"`
Name string `xml:"name,attr"` Name string `xml:"name,attr"`
Version int `xml:"version,attr"` Version int `xml:"version,attr"`
Tag string `xml:"tag,attr"`
} }
func (s *Store) readMeta(id string) (InterfaceMeta, error) { 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. // Create stores new interface XML and returns the generated ID.
// The ID is derived from the name attribute; a timestamp suffix is added to // The ID is derived from the name attribute; a timestamp suffix is added to
// ensure uniqueness. // ensure uniqueness. If tag is non-empty it is stamped as the revision label.
func (s *Store) Create(xmlData []byte) (string, error) { func (s *Store) Create(xmlData []byte, tag string) (string, error) {
var root rootAttrs var root rootAttrs
if err := xml.Unmarshal(xmlData, &root); err != nil { if err := xml.Unmarshal(xmlData, &root); err != nil {
return "", fmt.Errorf("invalid XML: %w", err) 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()) 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") updatedData, err := setXMLAttribute(xmlData, "version", "1")
if err != nil { if err != nil {
return "", fmt.Errorf("initialize version attribute: %w", err) 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) return id, os.WriteFile(s.filePath(id), updatedData, 0o644)
} }
// Update replaces the XML for an existing interface, preserving the previous // Update replaces the XML for an existing interface, preserving the previous
// version as a separate file. // version as a separate file. If tag is non-empty it is stamped as the label
func (s *Store) Update(id string, xmlData []byte) error { // for the new revision.
func (s *Store) Update(id string, xmlData []byte, tag string) error {
if err := validateID(id); err != nil { if err := validateID(id); err != nil {
return ErrNotFound return ErrNotFound
} }
@@ -213,21 +236,223 @@ func (s *Store) Update(id string, xmlData []byte) error {
if err != nil { if err != nil {
return fmt.Errorf("update version attribute: %w", err) 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) 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 // setXMLAttribute is a primitive helper to update a top-level attribute in an
// XML blob without full unmarshal/marshal (which might mess up formatting). // XML blob without full unmarshal/marshal (which might mess up formatting).
func setXMLAttribute(data []byte, attr, value string) ([]byte, error) { func setXMLAttribute(data []byte, attr, value string) ([]byte, error) {
// Very basic implementation: look for the attribute in the first 512 bytes. // Very basic implementation: look for the attribute in the first 512 bytes.
// A proper implementation would use an XML encoder or a better regex. // A proper implementation would use an XML encoder or a better regex.
s := string(data) s := string(data)
tagEnd := strings.Index(s, ">")
if tagEnd == -1 { // Locate the root element's opening tag, skipping any XML declaration
// (<?xml ... ?>), 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") return nil, errors.New("malformed XML: no root tag end")
} }
rootTag := s[:tagEnd] tagEnd := tagStart + rel
rootTag := s[tagStart:tagEnd]
attrSearch := " " + attr + "=\"" attrSearch := " " + attr + "=\""
idx := strings.Index(rootTag, attrSearch) 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 return []byte(s[:insertIdx] + fmt.Sprintf(" %s=\"%s\"", attr, value) + s[insertIdx:]), nil
} }
// Attribute found, replace its value. // Attribute found, replace its value (idx is relative to rootTag).
valStart := idx + len(attrSearch) valStart := tagStart + idx + len(attrSearch)
valEnd := strings.Index(s[valStart:], "\"") valEnd := strings.Index(s[valStart:], "\"")
if valEnd == -1 { if valEnd == -1 {
return nil, errors.New("malformed XML: attribute value not closed") return nil, errors.New("malformed XML: attribute value not closed")
+11 -11
View File
@@ -35,7 +35,7 @@ func TestListEmpty(t *testing.T) {
func TestListAfterCreate(t *testing.T) { func TestListAfterCreate(t *testing.T) {
s := newStore(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) t.Fatalf("Create: %v", err)
} }
list, err := s.List() list, err := s.List()
@@ -59,7 +59,7 @@ func TestListAfterCreate(t *testing.T) {
func TestCreateAndGet(t *testing.T) { func TestCreateAndGet(t *testing.T) {
s := newStore(t) s := newStore(t)
id, err := s.Create([]byte(sampleXML)) id, err := s.Create([]byte(sampleXML), "")
if err != nil { if err != nil {
t.Fatalf("Create: %v", err) t.Fatalf("Create: %v", err)
} }
@@ -78,7 +78,7 @@ func TestCreateAndGet(t *testing.T) {
func TestCreateUsesEmbeddedID(t *testing.T) { func TestCreateUsesEmbeddedID(t *testing.T) {
s := newStore(t) s := newStore(t)
id, err := s.Create([]byte(sampleXML)) id, err := s.Create([]byte(sampleXML), "")
if err != nil { if err != nil {
t.Fatalf("Create: %v", err) t.Fatalf("Create: %v", err)
} }
@@ -90,11 +90,11 @@ func TestCreateUsesEmbeddedID(t *testing.T) {
func TestCreateDuplicateGetsUniqueID(t *testing.T) { func TestCreateDuplicateGetsUniqueID(t *testing.T) {
s := newStore(t) s := newStore(t)
id1, err := s.Create([]byte(sampleXML)) id1, err := s.Create([]byte(sampleXML), "")
if err != nil { if err != nil {
t.Fatalf("first Create: %v", err) t.Fatalf("first Create: %v", err)
} }
id2, err := s.Create([]byte(sampleXML)) id2, err := s.Create([]byte(sampleXML), "")
if err != nil { if err != nil {
t.Fatalf("second Create: %v", err) t.Fatalf("second Create: %v", err)
} }
@@ -105,7 +105,7 @@ func TestCreateDuplicateGetsUniqueID(t *testing.T) {
func TestCreateInvalidXML(t *testing.T) { func TestCreateInvalidXML(t *testing.T) {
s := newStore(t) s := newStore(t)
_, err := s.Create([]byte("not xml")) _, err := s.Create([]byte("not xml"), "")
if err == nil { if err == nil {
t.Error("expected error for invalid XML") t.Error("expected error for invalid XML")
} }
@@ -145,13 +145,13 @@ func TestGetEmptyID(t *testing.T) {
func TestUpdate(t *testing.T) { func TestUpdate(t *testing.T) {
s := newStore(t) s := newStore(t)
id, err := s.Create([]byte(sampleXML)) id, err := s.Create([]byte(sampleXML), "")
if err != nil { if err != nil {
t.Fatalf("Create: %v", err) t.Fatalf("Create: %v", err)
} }
updated := `<interface id="test-if" name="Updated" version="2"><widget/></interface>` updated := `<interface id="test-if" name="Updated" version="2"><widget/></interface>`
if err := s.Update(id, []byte(updated)); err != nil { if err := s.Update(id, []byte(updated), ""); err != nil {
t.Fatalf("Update: %v", err) t.Fatalf("Update: %v", err)
} }
@@ -166,7 +166,7 @@ func TestUpdate(t *testing.T) {
func TestUpdateNotFound(t *testing.T) { func TestUpdateNotFound(t *testing.T) {
s := newStore(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) { if !errors.Is(err, storage.ErrNotFound) {
t.Errorf("Update missing: want ErrNotFound, got %v", err) t.Errorf("Update missing: want ErrNotFound, got %v", err)
} }
@@ -178,7 +178,7 @@ func TestUpdateNotFound(t *testing.T) {
func TestDelete(t *testing.T) { func TestDelete(t *testing.T) {
s := newStore(t) s := newStore(t)
id, err := s.Create([]byte(sampleXML)) id, err := s.Create([]byte(sampleXML), "")
if err != nil { if err != nil {
t.Fatalf("Create: %v", err) t.Fatalf("Create: %v", err)
} }
@@ -205,7 +205,7 @@ func TestDeleteNotFound(t *testing.T) {
func TestClone(t *testing.T) { func TestClone(t *testing.T) {
s := newStore(t) s := newStore(t)
id, err := s.Create([]byte(sampleXML)) id, err := s.Create([]byte(sampleXML), "")
if err != nil { if err != nil {
t.Fatalf("Create: %v", err) t.Fatalf("Create: %v", err)
} }
+21
View File
@@ -2,6 +2,27 @@
listen = ":8080" listen = ":8080"
storage_dir = "./interfaces" 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] [datasource.epics]
enabled = true enabled = true
ca_addr_list = "" # overrides EPICS_CA_ADDR_LIST if set ca_addr_list = "" # overrides EPICS_CA_ADDR_LIST if set
+33 -14
View File
@@ -5,7 +5,8 @@ import ViewMode from './ViewMode';
import EditMode from './EditMode'; import EditMode from './EditMode';
import FullscreenMode from './FullscreenMode'; import FullscreenMode from './FullscreenMode';
import { applyZoom, getStoredZoom } from './ZoomControl'; 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'; type AppMode = 'view' | 'edit';
@@ -18,6 +19,8 @@ export default function App() {
const [editTarget, setEditTarget] = useState<Interface | null>(null); const [editTarget, setEditTarget] = useState<Interface | null>(null);
// Interface to show when returning to view mode after editing // Interface to show when returning to view mode after editing
const [viewTarget, setViewTarget] = useState<Interface | null>(null); const [viewTarget, setViewTarget] = useState<Interface | null>(null);
// Resolved identity + global access level for the current user.
const [me, setMe] = useState<Me>(DEFAULT_ME);
useEffect(() => { useEffect(() => {
const unsub = wsClient.status.subscribe(setWsStatus); const unsub = wsClient.status.subscribe(setWsStatus);
@@ -29,6 +32,7 @@ export default function App() {
document.documentElement.style.setProperty('--dpr', String(dpr)); document.documentElement.style.setProperty('--dpr', String(dpr));
applyZoom(getStoredZoom()); applyZoom(getStoredZoom());
if (!fsParam) wsClient.connect('/ws'); if (!fsParam) wsClient.connect('/ws');
fetchMe().then(setMe);
}, []); }, []);
if (fsParam) { if (fsParam) {
@@ -40,25 +44,40 @@ export default function App() {
setMode('edit'); setMode('edit');
} }
function exitEdit(iface: Interface) { // `iface === null` means the editor discarded an unsaved/new panel; keep
setViewTarget(iface); // showing whatever was previously open (held in viewTarget).
function exitEdit(iface: Interface | null) {
if (iface) setViewTarget(iface);
setMode('view'); setMode('view');
setEditTarget(null); setEditTarget(null);
} }
if (!canRead(me.level)) {
return ( return (
<div> <div class="app-root">
{wsStatus === 'disconnected' && ( <div class="access-denied">
<div class="connection-banner">WebSocket disconnected reconnecting</div> <h1>Access denied</h1>
)} <p>
{/* ViewMode is always mounted so PlotPanel ring-buffers are never destroyed. Your account{me.user ? ` (${me.user})` : ''} does not have permission
EditMode is conditionally rendered; it resets from props each time anyway. */} to access this application. Contact an administrator.
<div style={`display:${mode === 'view' ? 'contents' : 'none'}`}> </p>
<ViewMode onEdit={enterEdit} initialInterface={viewTarget} />
</div> </div>
{mode === 'edit' && (
<EditMode initial={editTarget} onDone={exitEdit} />
)}
</div> </div>
); );
} }
return (
<AuthContext.Provider value={me}>
<div class="app-root">
{wsStatus === 'disconnected' && (
<div class="connection-banner">WebSocket disconnected reconnecting</div>
)}
{mode === 'view' ? (
<ViewMode onEdit={enterEdit} initialInterface={viewTarget} onView={setViewTarget} />
) : (
<EditMode key={editTarget?.id || 'new'} initial={editTarget} onDone={exitEdit} />
)}
</div>
</AuthContext.Provider>
);
}
+51 -8
View File
@@ -1,5 +1,7 @@
import { h } from 'preact'; 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 type { Interface, Widget, SignalRef } from './lib/types';
import TextView from './widgets/TextView'; import TextView from './widgets/TextView';
import TextLabel from './widgets/TextLabel'; import TextLabel from './widgets/TextLabel';
@@ -15,6 +17,7 @@ import ImageWidget from './widgets/ImageWidget';
import LinkWidget from './widgets/LinkWidget'; import LinkWidget from './widgets/LinkWidget';
import ContextMenu from './ContextMenu'; import ContextMenu from './ContextMenu';
import InfoPanel from './InfoPanel'; import InfoPanel from './InfoPanel';
import SplitLayout from './SplitLayout';
const COMPONENTS: Record<string, any> = { const COMPONENTS: Record<string, any> = {
textview: TextView, textview: TextView,
@@ -42,15 +45,25 @@ interface Props {
iface: Interface | null; iface: Interface | null;
onNavigate?: (interfaceId: string) => void; onNavigate?: (interfaceId: string) => void;
timeRange?: { start: string; end: string } | null; 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<CtxState>({ const [ctxMenu, setCtxMenu] = useState<CtxState>({
visible: false, x: 0, y: 0, signal: null, visible: false, x: 0, y: 0, signal: null,
}); });
const [infoSignal, setInfoSignal] = useState<SignalRef | null>(null); const [infoSignal, setInfoSignal] = useState<SignalRef | null>(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) { function onCtxMenu(e: MouseEvent, widget: Widget) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@@ -65,10 +78,6 @@ export default function Canvas({ iface, onNavigate, timeRange, onPlot }: Props)
if (ctxMenu.signal) setInfoSignal(ctxMenu.signal); if (ctxMenu.signal) setInfoSignal(ctxMenu.signal);
} }
function handlePlot() {
if (ctxMenu.signal && onPlot) onPlot(ctxMenu.signal);
}
if (!iface) { if (!iface) {
return ( return (
<div class="canvas-container"> <div class="canvas-container">
@@ -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 (
<div class="canvas-container">
<div class="plot-panel-view">
<SplitLayout
layout={iface.layout}
renderLeaf={(wid) => {
const widget = byId.get(wid);
if (!widget) return <div class="plot-pane-empty">missing plot</div>;
return (
<PlotWidget
widget={widget}
onContextMenu={(e: MouseEvent) => onCtxMenu(e, widget)}
timeRange={timeRange}
/>
);
}}
/>
</div>
<ContextMenu
{...ctxMenu}
onClose={closeCtxMenu}
onInfo={handleInfo}
/>
{infoSignal && (
<InfoPanel signal={infoSignal} onClose={() => setInfoSignal(null)} />
)}
</div>
);
}
return ( return (
<div class="canvas-container"> <div class="canvas-container">
<div class="canvas-area" style={`width:${iface.w}px;height:${iface.h}px;`}> <div class="canvas-area" style={`width:${iface.w}px;height:${iface.h}px;`}>
@@ -110,7 +154,6 @@ export default function Canvas({ iface, onNavigate, timeRange, onPlot }: Props)
{...ctxMenu} {...ctxMenu}
onClose={closeCtxMenu} onClose={closeCtxMenu}
onInfo={handleInfo} onInfo={handleInfo}
onPlot={onPlot ? handlePlot : undefined}
/> />
{infoSignal && ( {infoSignal && (
+1 -8
View File
@@ -11,10 +11,9 @@ interface Props {
signal: SignalRef | null; signal: SignalRef | null;
onClose: () => void; onClose: () => void;
onInfo?: () => 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<SignalMeta | null>(null); const [meta, setMeta] = useState<SignalMeta | null>(null);
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
@@ -47,11 +46,6 @@ export default function ContextMenu({ visible, x, y, signal, onClose, onInfo, on
onInfo?.(); onInfo?.();
} }
function openPlot() {
onClose();
onPlot?.();
}
function exportCSV() { function exportCSV() {
// Phase 5+: export CSV data // Phase 5+: export CSV data
onClose(); onClose();
@@ -73,7 +67,6 @@ export default function ContextMenu({ visible, x, y, signal, onClose, onInfo, on
</div> </div>
<div class="ctx-divider" /> <div class="ctx-divider" />
<button class="ctx-item" onClick={openInfo}>Signal info</button> <button class="ctx-item" onClick={openInfo}>Signal info</button>
<button class="ctx-item" onClick={openPlot}>Plot</button>
<div class="ctx-divider" /> <div class="ctx-divider" />
<button class="ctx-item" onClick={copySignalName}>Copy signal name</button> <button class="ctx-item" onClick={copySignalName}>Copy signal name</button>
<button class="ctx-item" onClick={exportCSV}>Export data to CSV</button> <button class="ctx-item" onClick={exportCSV}>Export data to CSV</button>
+3 -2
View File
@@ -364,7 +364,7 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna
> >
<div class="canvas-area" style={`width:${iface.w}px;height:${iface.h}px;`}> <div class="canvas-area" style={`width:${iface.w}px;height:${iface.h}px;`}>
{iface.widgets.map(widget => { {(iface.widgets || []).map(widget => {
const Comp = COMPONENTS[widget.type]; const Comp = COMPONENTS[widget.type];
return Comp return Comp
? <Comp key={widget.id} widget={widget} /> ? <Comp key={widget.id} widget={widget} />
@@ -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); const isSelected = selectedIds.includes(widget.id);
return ( return (
<div <div
key={`ov-${widget.id}`} key={`ov-${widget.id}`}
+456 -113
View File
@@ -1,17 +1,29 @@
import { h } from 'preact'; import { h, Fragment } from 'preact';
import { useState, useCallback, useRef } from 'preact/hooks'; import { useState, useCallback, useRef, useEffect } from 'preact/hooks';
import type { Interface, Widget } from './lib/types'; import type { Interface, Widget, PlotLayout, StateVar, LogicGraph } from './lib/types';
import { serializeInterface, parseInterface } from './lib/xml'; import { serializeInterface, parseInterface } from './lib/xml';
import { initLocalState } from './lib/localstate';
import SignalTree from './SignalTree'; import SignalTree from './SignalTree';
import EditCanvas, { genWidgetId, DEFAULT_SIZES } from './EditCanvas'; import EditCanvas, { genWidgetId, DEFAULT_SIZES } from './EditCanvas';
import PlotPanelCanvas from './PlotPanelCanvas';
import LogicEditor from './LogicEditor';
import PropertiesPane from './PropertiesPane'; import PropertiesPane from './PropertiesPane';
import ContextualHelp from './ContextualHelp'; import ContextualHelp from './ContextualHelp';
import HelpModal from './HelpModal'; import HelpModal from './HelpModal';
import ZoomControl from './ZoomControl'; import ZoomControl from './ZoomControl';
import { useAuth, canWrite } from './lib/auth';
interface Props { interface Props {
initial: Interface | null; initial: Interface | null;
onDone: (iface: Interface) => void; onDone: (iface: Interface | null) => void;
}
interface VersionMeta {
version: number;
name: string;
tag?: string;
current: boolean;
savedAt: string;
} }
function blankInterface(): Interface { function blankInterface(): Interface {
@@ -69,12 +81,14 @@ function distributeWidgets(widgets: Widget[], ids: string[], axis: 'h' | 'v'): W
const STATIC_WIDGET_TYPES = [ const STATIC_WIDGET_TYPES = [
{ type: 'textlabel', label: 'Text Label' }, { type: 'textlabel', label: 'Text Label' },
{ type: 'button', label: 'Action Button' },
{ type: 'image', label: 'Image' }, { type: 'image', label: 'Image' },
{ type: 'link', label: 'Link' }, { type: 'link', label: 'Link' },
]; ];
export default function EditMode({ initial, onDone }: Props) { export default function EditMode({ initial, onDone }: Props) {
const [iface, setIface] = useState<Interface>(initial ?? blankInterface()); const [iface, setIface] = useState<Interface>(initial ?? blankInterface());
const writable = canWrite(useAuth().level);
const [selectedIds, setSelectedIds] = useState<string[]>([]); const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [dirty, setDirty] = useState(false); const [dirty, setDirty] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -86,8 +100,22 @@ export default function EditMode({ initial, onDone }: Props) {
const [helpSection, setHelpSection] = useState('edit'); const [helpSection, setHelpSection] = useState('edit');
const [leftW, setLeftW] = useState(220); const [leftW, setLeftW] = useState(220);
const [rightW, setRightW] = useState(260); 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<VersionMeta[]>([]);
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<Widget[]>([]);
const startResize = useCallback((side: 'left' | 'right') => {
return (e: MouseEvent) => { return (e: MouseEvent) => {
e.preventDefault(); e.preventDefault();
const startX = e.clientX; const startX = e.clientX;
@@ -104,74 +132,189 @@ export default function EditMode({ initial, onDone }: Props) {
window.addEventListener('mousemove', onMove); window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp); window.addEventListener('mouseup', onUp);
}; };
} }, [leftW, rightW]);
function openHelp(section = 'edit') { setHelpSection(section); setShowHelp(true); }
// Undo / redo const openHelp = useCallback((section = 'edit') => {
const undoStack = useRef<Widget[][]>([]); setHelpSection(section);
const redoStack = useRef<Widget[][]>([]); 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<Snapshot[]>([]);
const redoStack = useRef<Snapshot[]>([]);
const [historyLen, setHistoryLen] = useState(0); // drives canUndo/canRedo display const [historyLen, setHistoryLen] = useState(0); // drives canUndo/canRedo display
const widgetsRef = useRef(iface.widgets); const widgetsRef = useRef(iface.widgets || []);
widgetsRef.current = iface.widgets; widgetsRef.current = iface.widgets || [];
const layoutRef = useRef(iface.layout);
layoutRef.current = iface.layout;
function pushUndo() { const snapshot = useCallback((): Snapshot => ({
undoStack.current = [...undoStack.current.slice(-49), [...widgetsRef.current]]; widgets: [...widgetsRef.current],
layout: layoutRef.current,
}), []);
const pushUndo = useCallback(() => {
undoStack.current = [...undoStack.current.slice(-49), snapshot()];
redoStack.current = []; redoStack.current = [];
setHistoryLen(undoStack.current.length); setHistoryLen(undoStack.current.length);
} }, [snapshot]);
function undo() { const undo = useCallback(() => {
if (undoStack.current.length === 0) return; if (undoStack.current.length === 0) return;
const prev = undoStack.current[undoStack.current.length - 1]; 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); undoStack.current = undoStack.current.slice(0, -1);
setHistoryLen(undoStack.current.length); setHistoryLen(undoStack.current.length);
setIface(f => ({ ...f, widgets: prev })); setIface(f => ({ ...f, widgets: prev.widgets, layout: prev.layout }));
setDirty(true); setDirty(true);
} }, [snapshot]);
function redo() { const redo = useCallback(() => {
if (redoStack.current.length === 0) return; if (redoStack.current.length === 0) return;
const next = redoStack.current[0]; const next = redoStack.current[0];
undoStack.current = [...undoStack.current, widgetsRef.current]; undoStack.current = [...undoStack.current, snapshot()];
redoStack.current = redoStack.current.slice(1); redoStack.current = redoStack.current.slice(1);
setHistoryLen(undoStack.current.length); setHistoryLen(undoStack.current.length);
setIface(f => ({ ...f, widgets: next })); setIface(f => ({ ...f, widgets: next.widgets, layout: next.layout }));
setDirty(true); setDirty(true);
} }, [snapshot]);
const handleWidgetsChange = useCallback((widgets: Widget[]) => { const handleWidgetsChange = useCallback((widgets: Widget[]) => {
pushUndo(); pushUndo();
setIface(f => ({ ...f, widgets })); setIface(f => ({ ...f, widgets }));
setDirty(true); 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) => { const handleWidgetChange = useCallback((updated: Widget) => {
pushUndo(); 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); setDirty(true);
}, []); }, [pushUndo]);
const handleIfaceChange = useCallback((updated: Interface) => { const handleIfaceChange = useCallback((updated: Interface) => {
setIface(updated); setIface(updated);
setDirty(true); 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 ───────────────────────────────────────────────────── // ── Align / distribute ─────────────────────────────────────────────────────
function doAlign(mode: string) { const doAlign = useCallback((mode: string) => {
const aligned = alignWidgets(iface.widgets, selectedIds, mode); const aligned = alignWidgets(iface.widgets || [], selectedIds, mode);
handleWidgetsChange(aligned); handleWidgetsChange(aligned);
} }, [iface.widgets, selectedIds, handleWidgetsChange]);
function doDistribute(axis: 'h' | 'v') { const doDistribute = useCallback((axis: 'h' | 'v') => {
const distributed = distributeWidgets(iface.widgets, selectedIds, axis); const distributed = distributeWidgets(iface.widgets || [], selectedIds, axis);
handleWidgetsChange(distributed); handleWidgetsChange(distributed);
} }, [iface.widgets, selectedIds, handleWidgetsChange]);
// ── Insert static widget ─────────────────────────────────────────────────── // ── Insert static widget ───────────────────────────────────────────────────
function insertWidget(type: string) { const insertWidget = useCallback((type: string) => {
setShowInsertMenu(false); setShowInsertMenu(false);
const [defW, defH] = DEFAULT_SIZES[type] ?? [150, 60]; const [defW, defH] = DEFAULT_SIZES[type] ?? [150, 60];
const newWidget: Widget = { const newWidget: Widget = {
@@ -182,22 +325,26 @@ export default function EditMode({ initial, onDone }: Props) {
w: defW, w: defW,
h: defH, h: defH,
signals: [], 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]); setSelectedIds([newWidget.id]);
} }, [iface.widgets, handleWidgetsChange]);
// ── Save / export / import ───────────────────────────────────────────────── // ── Save / export / import ─────────────────────────────────────────────────
async function handleSave() { const handleSave = useCallback(async (saveTag = '') => {
setSaving(true); setSaving(true);
setError(null); setError(null);
try { try {
const xml = serializeInterface(iface); const xml = serializeInterface(iface);
const url = iface.id const base = iface.id
? `/api/v1/interfaces/${encodeURIComponent(iface.id)}` ? `/api/v1/interfaces/${encodeURIComponent(iface.id)}`
: '/api/v1/interfaces'; : '/api/v1/interfaces';
const url = saveTag ? `${base}?tag=${encodeURIComponent(saveTag)}` : base;
const method = iface.id ? 'PUT' : 'POST'; const method = iface.id ? 'PUT' : 'POST';
const res = await fetch(url, { const res = await fetch(url, {
method, method,
@@ -210,15 +357,141 @@ export default function EditMode({ initial, onDone }: Props) {
setIface(f => ({ ...f, id: json.id })); setIface(f => ({ ...f, id: json.id }));
} }
setDirty(false); setDirty(false);
setTag('');
window.dispatchEvent(new CustomEvent('uopi:refresh-interfaces')); window.dispatchEvent(new CustomEvent('uopi:refresh-interfaces'));
if (showHistory) loadVersions();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : String(err)); setError(err instanceof Error ? err.message : String(err));
} finally { } finally {
setSaving(false); 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 xml = serializeInterface(iface);
const blob = new Blob([xml], { type: 'application/xml' }); const blob = new Blob([xml], { type: 'application/xml' });
const url = URL.createObjectURL(blob); 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.download = `${iface.name.replace(/[^a-z0-9]/gi, '_')}.xml`;
a.click(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }, [iface]);
function handleImport() { const handleImport = useCallback(() => {
const input = document.createElement('input'); const input = document.createElement('input');
input.type = 'file'; input.type = 'file';
input.accept = '.xml,application/xml,text/xml'; input.accept = '.xml,application/xml,text/xml';
@@ -248,78 +521,30 @@ export default function EditMode({ initial, onDone }: Props) {
} }
}; };
input.click(); input.click();
} }, []);
function handleLeave() { const handleLeave = useCallback(async () => {
if (dirty && !confirm('You have unsaved changes. Leave without saving?')) return; 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); onDone(iface);
} }, [dirty, onDone, iface]);
// Clipboard
const clipboard = useRef<Widget[]>([]);
// ── 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
));
}
}
const selectedWidget = selectedIds.length === 1 const selectedWidget = selectedIds.length === 1
? iface.widgets.find(w => w.id === selectedIds[0]) ?? null ? (iface.widgets || []).find(w => w.id === selectedIds[0]) ?? null
: null; : null;
const canUndo = historyLen > 0; const canUndo = historyLen > 0;
@@ -327,7 +552,7 @@ export default function EditMode({ initial, onDone }: Props) {
const multiSelected = selectedIds.length > 1; const multiSelected = selectedIds.length > 1;
return ( return (
<div class="edit-mode-layout" onKeyDown={handleKeyDown} tabIndex={-1}> <div class="edit-mode-layout" tabIndex={-1}>
{/* ── Toolbar ── */} {/* ── Toolbar ── */}
<header class="toolbar"> <header class="toolbar">
<div class="toolbar-left"> <div class="toolbar-left">
@@ -362,7 +587,8 @@ export default function EditMode({ initial, onDone }: Props) {
<span>Grid</span> <span>Grid</span>
</label> </label>
{/* Insert static widget */} {/* Insert static widget — not applicable to split plot panels */}
{iface.kind !== 'plot' && (
<div class="toolbar-dropdown"> <div class="toolbar-dropdown">
<button class="toolbar-btn" onClick={() => setShowInsertMenu(m => !m)}>+ Insert </button> <button class="toolbar-btn" onClick={() => setShowInsertMenu(m => !m)}>+ Insert </button>
{showInsertMenu && ( {showInsertMenu && (
@@ -373,6 +599,7 @@ export default function EditMode({ initial, onDone }: Props) {
</div> </div>
)} )}
</div> </div>
)}
{/* Align/distribute — only when ≥2 selected */} {/* Align/distribute — only when ≥2 selected */}
{selectedIds.length >= 2 && ( {selectedIds.length >= 2 && (
@@ -402,7 +629,20 @@ export default function EditMode({ initial, onDone }: Props) {
<button class="icon-btn help-manual-btn" onClick={() => openHelp('edit')} title="Open user manual">📖</button> <button class="icon-btn help-manual-btn" onClick={() => openHelp('edit')} title="Open user manual">📖</button>
<button class="toolbar-btn" onClick={handleImport}>Import</button> <button class="toolbar-btn" onClick={handleImport}>Import</button>
<button class="toolbar-btn" onClick={handleExport}>Export</button> <button class="toolbar-btn" onClick={handleExport}>Export</button>
<button class="toolbar-btn toolbar-btn-primary" onClick={handleSave} disabled={saving}> <button
class={`toolbar-btn${showHistory ? ' toolbar-btn-active' : ''}`}
onClick={toggleHistory}
disabled={!iface.id}
title={iface.id ? 'Version history' : 'Save the interface first to enable history'}
>
🕘 History
</button>
<button
class="toolbar-btn toolbar-btn-primary"
onClick={() => handleSave(tag)}
disabled={saving || !writable}
title={writable ? '' : 'You have read-only access'}
>
{saving ? 'Saving…' : 'Save'} {saving ? 'Saving…' : 'Save'}
</button> </button>
<button class="toolbar-btn" onClick={handleLeave}> Close</button> <button class="toolbar-btn" onClick={handleLeave}> Close</button>
@@ -411,15 +651,58 @@ export default function EditMode({ initial, onDone }: Props) {
{/* ── Three-panel body ── */} {/* ── Three-panel body ── */}
<div class="edit-body"> <div class="edit-body">
<SignalTree width={leftW} /> {/* 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' && (
<Fragment>
<SignalTree
width={leftW}
panelId={iface.id}
statevars={iface.statevars}
onStateVarsChange={handleStateVarsChange}
/>
<div class="panel-resize-handle" onMouseDown={startResize('left')} /> <div class="panel-resize-handle" onMouseDown={startResize('left')} />
</Fragment>
)}
<div class="edit-center">
<div class="center-tabs">
<button
class={`center-tab${centerTab === 'layout' ? ' center-tab-active' : ''}`}
onClick={() => setCenterTab('layout')}
>Layout</button>
<button
class={`center-tab${centerTab === 'logic' ? ' center-tab-active' : ''}`}
onClick={() => setCenterTab('logic')}
>Logic{iface.logic && iface.logic.nodes.length > 0 ? ` (${iface.logic.nodes.length})` : ''}</button>
</div>
{centerTab === 'logic' ? (
<LogicEditor
graph={iface.logic ?? { nodes: [], wires: [] }}
onChange={handleLogicChange}
statevars={iface.statevars}
onStateVarsChange={handleStateVarsChange}
/>
) : iface.kind === 'plot' ? (
<PlotPanelCanvas
key={canvasNonce}
iface={iface}
selectedIds={selectedIds}
onSelect={setSelectedIds}
onChange={handlePlotChange}
/>
) : (
<EditCanvas <EditCanvas
key={canvasNonce}
iface={iface} iface={iface}
selectedIds={selectedIds} selectedIds={selectedIds}
onSelect={setSelectedIds} onSelect={setSelectedIds}
onChange={handleWidgetsChange} onChange={handleWidgetsChange}
snapGrid={snapGrid} snapGrid={snapGrid}
/> />
)}
</div>
{centerTab !== 'logic' && (
<Fragment>
<div class="panel-resize-handle" onMouseDown={startResize('right')} /> <div class="panel-resize-handle" onMouseDown={startResize('right')} />
<PropertiesPane <PropertiesPane
selected={selectedWidget} selected={selectedWidget}
@@ -429,6 +712,66 @@ export default function EditMode({ initial, onDone }: Props) {
onIfaceChange={handleIfaceChange} onIfaceChange={handleIfaceChange}
width={rightW} width={rightW}
/> />
</Fragment>
)}
{showHistory && (
<div class="history-pane">
<div class="history-pane-header">
<span>Version History</span>
<button class="icon-btn" onClick={() => setShowHistory(false)} title="Close"></button>
</div>
<div class="history-save-row">
<input
class="history-tag-input"
placeholder="Optional label…"
value={tag}
onInput={(e) => setTag((e.target as HTMLInputElement).value)}
/>
<button class="toolbar-btn" onClick={() => handleSave(tag)} disabled={saving || !writable}>
Save snapshot
</button>
</div>
<div class="history-list">
{historyLoading && <div class="history-empty">Loading</div>}
{!historyLoading && versions.length === 0 && (
<div class="history-empty">No saved versions yet.</div>
)}
{!historyLoading && versions.map(v => (
<div
key={v.version}
class={`history-item${v.current ? ' history-item-current' : ''}`}
>
<div class="history-item-main">
<span class="history-version">v{v.version}</span>
{v.tag && <span class="history-tag">{v.tag}</span>}
{v.current && <span class="history-current-badge">current</span>}
</div>
<div class="history-item-meta">
{new Date(v.savedAt).toLocaleString()}
</div>
<div class="history-item-actions">
{!v.current && (
<button class="history-action-btn" onClick={() => loadVersion(v.version)} title="Load this version into the editor (does not change history)">
Load
</button>
)}
{!v.current && (
<button class="history-action-btn" onClick={() => promoteVersion(v.version)} title="Make this version the current one">
Set current
</button>
)}
<button class="history-action-btn" onClick={() => forkVersion(v.version)} title="Create a new interface from this version">
Fork
</button>
<button class="history-action-btn" onClick={() => editVersionTag(v.version, v.tag ?? '')} title="Edit this version's label">
Label
</button>
</div>
</div>
))}
</div>
</div>
)}
</div> </div>
{showHelp && ( {showHelp && (
+125 -29
View File
@@ -1,32 +1,49 @@
import { h } from 'preact'; import { h } from 'preact';
import { useState, useEffect, useRef } from 'preact/hooks'; import { useState, useEffect, useRef } from 'preact/hooks';
import type { Interface } from './lib/types'; import { useAuth, canWrite } from './lib/auth';
import type { Interface, InterfaceListItem, Folder } from './lib/types';
interface ServerInterface { import ShareDialog from './ShareDialog';
id: string;
name: string;
version: number;
}
interface Props { interface Props {
onLoad: (xml: string) => void; onLoad: (xml: string) => void;
onSelect?: (id: string) => void; onSelect?: (id: string) => void;
onEdit?: (iface?: Interface) => void; onEdit?: (iface?: Interface) => void;
onNewPlot?: () => void;
onEditId?: (id: string) => void; onEditId?: (id: string) => void;
width?: number; width?: number;
collapsed: boolean;
onToggleCollapse: () => void;
} }
export default function InterfaceList({ onLoad, onSelect, onEdit, onEditId, width }: Props) { export default function InterfaceList({ onLoad, onSelect, onEdit, onNewPlot, onEditId, width, collapsed, onToggleCollapse }: Props) {
const [collapsed, setCollapsed] = useState(false); const [interfaces, setInterfaces] = useState<InterfaceListItem[]>([]);
const [interfaces, setInterfaces] = useState<ServerInterface[]>([]); const [folders, setFolders] = useState<Folder[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [share, setShare] = useState<{ id: string; name: string } | null>(null);
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const me = useAuth();
const writable = canWrite(me.level);
function refresh() { function refresh() {
setLoading(true); setLoading(true);
fetch('/api/v1/interfaces') Promise.all([
.then(r => r.ok ? r.json() : []) fetch('/api/v1/interfaces').then(r => r.ok ? r.json() : []),
.then(data => setInterfaces(data)) 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(() => {}) .catch(() => {})
.finally(() => setLoading(false)); .finally(() => setLoading(false));
} }
@@ -71,13 +88,89 @@ export default function InterfaceList({ onLoad, onSelect, onEdit, onEditId, widt
window.open(`?fs=${encodeURIComponent(id)}`, '_blank'); 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 (
<li key={item.id} class="iface-item">
<span class="iface-item-name" onClick={() => onSelect?.(item.id)} title={item.owner ? `Owner: ${item.owner}` : undefined}>
{item.name || item.id}
{item.perm === 'read' && <span class="iface-badge" title="Read-only">ro</span>}
</span>
<div class="iface-item-actions">
{item.perm === 'write' && <button class="icon-btn" title="Edit" onClick={() => onEditId?.(item.id)}></button>}
<button class="icon-btn" title="Open fullscreen in new tab" onClick={() => handleFullscreen(item.id)}></button>
{writable && <button class="icon-btn" title="Clone" onClick={() => handleClone(item.id)}></button>}
{canShare(item) && <button class="icon-btn" title="Share" onClick={() => setShare({ id: item.id, name: item.name })}></button>}
{item.perm === 'write' && <button class="icon-btn iface-delete" title="Delete" onClick={() => handleDelete(item.id, item.name)}></button>}
</div>
</li>
);
}
// 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 (
<li key={folder.id} class="iface-folder">
<div class="iface-folder-header">
<span class="iface-folder-name" onClick={() => toggle(folder.id)}>
{open ? '▾' : '▸'} {folder.name}
</span>
{folder.perm === 'write' && (
<div class="iface-item-actions">
<button class="icon-btn" title="New subfolder" onClick={() => handleNewFolder(folder.id)}></button>
<button class="icon-btn iface-delete" title="Delete folder" onClick={() => handleDeleteFolder(folder.id, folder.name)}></button>
</div>
)}
</div>
{open && (
<ul class="iface-list iface-sublist">
{children.map(renderFolder)}
{panels.map(renderPanel)}
</ul>
)}
</li>
);
}
const rootFolders = folders.filter(f => !f.parent);
const rootPanels = interfaces.filter(i => !i.folder);
return ( return (
<aside class={`panel${collapsed ? ' collapsed' : ''}`} style={!collapsed && width ? `width:${width}px;min-width:${width}px;` : undefined}> <aside class={`panel${collapsed ? ' collapsed' : ''}`} style={!collapsed && width ? `width:${width}px;min-width:${width}px;` : undefined}>
<div class="panel-header"> <div class="panel-header">
{!collapsed && <span class="panel-title">Interfaces</span>} {!collapsed && <span class="panel-title">Interfaces</span>}
<button <button
class="icon-btn" class="icon-btn"
onClick={() => setCollapsed(c => !c)} onClick={onToggleCollapse}
title={collapsed ? 'Expand panel' : 'Collapse panel'} title={collapsed ? 'Expand panel' : 'Collapse panel'}
> >
{collapsed ? '▶' : '◀'} {collapsed ? '▶' : '◀'}
@@ -85,9 +178,12 @@ export default function InterfaceList({ onLoad, onSelect, onEdit, onEditId, widt
</div> </div>
{!collapsed && ( {!collapsed && (
<div> <div class="panel-body">
{writable && (
<div class="panel-actions"> <div class="panel-actions">
<button class="panel-btn" onClick={() => onEdit?.()}>+ New</button> <button class="panel-btn" onClick={() => onEdit?.()}>+ New</button>
<button class="panel-btn" onClick={() => onNewPlot?.()}>+ Plot</button>
<button class="panel-btn" onClick={() => handleNewFolder('')}>+ Folder</button>
<button class="panel-btn" onClick={triggerImport}>Import</button> <button class="panel-btn" onClick={triggerImport}>Import</button>
<input <input
type="file" type="file"
@@ -97,32 +193,32 @@ export default function InterfaceList({ onLoad, onSelect, onEdit, onEditId, widt
onChange={handleFileChange} onChange={handleFileChange}
/> />
</div> </div>
)}
<div class="panel-list"> <div class="panel-list">
{loading ? ( {loading ? (
<p class="hint">Loading</p> <p class="hint">Loading</p>
) : interfaces.length === 0 ? ( ) : interfaces.length === 0 && folders.length === 0 ? (
<p class="hint">No interfaces yet. Create one or import XML.</p> <p class="hint">No interfaces yet. Create one or import XML.</p>
) : ( ) : (
<ul class="iface-list"> <ul class="iface-list">
{interfaces.map(iface => ( {rootFolders.map(renderFolder)}
<li key={iface.id} class="iface-item"> {rootPanels.map(renderPanel)}
<span class="iface-item-name" onClick={() => onSelect?.(iface.id)}>
{iface.name || iface.id}
</span>
<div class="iface-item-actions">
<button class="icon-btn" title="Edit" onClick={() => onEditId?.(iface.id)}></button>
<button class="icon-btn" title="Open fullscreen in new tab" onClick={() => handleFullscreen(iface.id)}></button>
<button class="icon-btn" title="Clone" onClick={() => handleClone(iface.id)}></button>
<button class="icon-btn iface-delete" title="Delete" onClick={() => handleDelete(iface.id, iface.name)}></button>
</div>
</li>
))}
</ul> </ul>
)} )}
</div> </div>
</div> </div>
)} )}
{share && (
<ShareDialog
ifaceId={share.id}
ifaceName={share.name}
folders={folders}
onClose={() => setShare(null)}
onSaved={refresh}
/>
)}
</aside> </aside>
); );
} }
+807
View File
@@ -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<string, string>;
}
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<LogicNodeKind, string> = {
'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<string | null>(null);
const [selectedWire, setSelectedWire] = useState<number | null>(null);
const [dataSources, setDataSources] = useState<string[]>([]);
const [dsSignals, setDsSignals] = useState<Record<string, string[]>>({});
const canvasRef = useRef<HTMLDivElement>(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<LogicGraph[]>([]);
const redoStack = useRef<LogicGraph[]>([]);
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<string, string>) {
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<string, string>();
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 (
<div class="flow-editor">
<div class="flow-palette">
<div class="flow-palette-toolbar">
<button class="toolbar-btn" disabled={!canUndo} onClick={undo} title="Undo (Ctrl+Z)"></button>
<button class="toolbar-btn" disabled={!canRedo} onClick={redo} title="Redo (Ctrl+Shift+Z)"></button>
</div>
<div class="flow-palette-title">Triggers</div>
{PALETTE.filter(e => category(e.kind) === 'trigger').map(paletteBtn)}
<div class="flow-palette-title">Logic</div>
{PALETTE.filter(e => category(e.kind) === 'gate' || category(e.kind) === 'flow').map(paletteBtn)}
<div class="flow-palette-title">Actions</div>
{PALETTE.filter(e => category(e.kind) === 'action').map(paletteBtn)}
<div class="flow-palette-hint hint">
Triggers start a flow. Drag a node's right port to another node's left port to connect.
In expressions, reference signals as <code>{'{ds:name}'}</code> and local vars by name.
</div>
{onStateVarsChange && (
<LocalVars statevars={statevars ?? []} onChange={onStateVarsChange} />
)}
<datalist id="flow-array-names">
{arrayNames.map(a => <option key={a} value={a} />)}
</datalist>
</div>
<div class="flow-canvas" ref={canvasRef}
onMouseDown={() => { setSelectedNode(null); setSelectedWire(null); }}
onDragOver={onCanvasDragOver}
onDrop={onCanvasDrop}>
<div class="flow-canvas-inner">
<svg class="flow-wires">
{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 (
<path key={idx}
class={`flow-wire${selectedWire === idx ? ' flow-wire-selected' : ''}`}
d={wirePathStr(p1.x, p1.y, p2.x, p2.y)}
onClick={(e) => { e.stopPropagation(); setSelectedWire(idx); setSelectedNode(null); }} />
);
})}
{pendingWire && (() => {
const a = byId.get(pendingWire.from);
if (!a) return null;
const p1 = outAnchor(a, pendingWire.port);
return <path class="flow-wire flow-wire-pending" d={wirePathStr(p1.x, p1.y, pendingWire.x, pendingWire.y)} />;
})()}
</svg>
{nodes.map(node => (
<div key={node.id}
class={`flow-node flow-node-${category(node.kind)}${selectedNode === node.id ? ' flow-node-selected' : ''}`}
style={`left:${node.x}px; top:${node.y}px; width:${NODE_W}px; min-height:${nodeHeight(node.kind)}px;`}
onMouseDown={(e) => startNodeDrag(e, node)}
onMouseUp={() => { if (pendingRef.current) finishWire(node); }}>
<div class="flow-node-header">
<span class="flow-node-title">{KIND_LABEL[node.kind]}</span>
<button class="flow-node-del" title="Delete node"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); deleteNode(node.id); }}></button>
</div>
<div class="flow-node-body hint">{nodeSummary(node)}</div>
{hasInput(node.kind) && (
<div class="flow-port flow-port-in" title="Input"
style={`top:${PORT_TOP - PORT_R}px; left:${-PORT_R}px;`}
onMouseDown={(e) => e.stopPropagation()}
onMouseUp={(e) => { e.stopPropagation(); finishWire(node); }} />
)}
{outputs(node.kind).map((port, i) => (
<Fragment key={port.id}>
{port.label && (
<span class="flow-port-label" style={`top:${PORT_TOP + i * PORT_GAP - 0.5 * REM}px;`}>{port.label}</span>
)}
<div class={`flow-port flow-port-out flow-port-${port.id}`}
style={`top:${PORT_TOP + i * PORT_GAP - PORT_R}px; right:${-PORT_R}px;`}
title={`Output: ${port.id}`}
onMouseDown={(e) => startWire(e, node, port.id)} />
</Fragment>
))}
</div>
))}
</div>
</div>
<div class="flow-inspector">
{!selected && <div class="hint">Select a node to edit it, or add one from the palette.</div>}
{selected && (
<Fragment>
<div class="wizard-section-title">{KIND_LABEL[selected.kind]}</div>
{selected.kind === 'trigger.button' && (
<div class="wizard-field">
<label>Action name</label>
<input class="prop-input" value={selected.params.name ?? ''}
placeholder="e.g. open_valve"
onInput={(e) => patchParams(selected.id, { name: (e.target as HTMLInputElement).value })} />
<p class="hint">Fires when a button widget's Action is set to this name.</p>
</div>
)}
{(selected.kind === 'trigger.threshold' || selected.kind === 'trigger.change') && (() => {
const { ds, name } = splitRef(selected.params.signal ?? '');
return (
<Fragment>
<div class="wizard-field">
<label>Data source</label>
<SearchableSelect value={ds} options={dsOptions}
onSelect={(newDs) => { loadSignals(newDs); patchParams(selected.id, { signal: `${newDs}:` }); }} />
</div>
<div class="wizard-field">
<label>Signal</label>
<SearchableSelect value={name} options={signalOptions(ds)}
onSelect={(sig) => patchParams(selected.id, { signal: `${ds}:${sig}` })}
placeholder={ds ? 'Search signals' : 'Select data source first'} />
</div>
{selected.kind === 'trigger.threshold' && (
<div class="wizard-field wizard-field-row">
<div class="wizard-field" style="flex:0 0 4.5rem;">
<label>Op</label>
<select class="prop-select" value={selected.params.op ?? '>'}
onChange={(e) => patchParams(selected.id, { op: (e.target as HTMLSelectElement).value })}>
{THRESHOLD_OPS.map(o => <option key={o} value={o}>{o}</option>)}
</select>
</div>
<div class="wizard-field" style="flex:1;">
<label>Value</label>
<input class="prop-input" value={selected.params.value ?? ''}
onInput={(e) => patchParams(selected.id, { value: (e.target as HTMLInputElement).value })} />
</div>
</div>
)}
{selected.kind === 'trigger.change' && <p class="hint">Fires whenever the signal's value changes.</p>}
</Fragment>
);
})()}
{(selected.kind === 'trigger.timer' || selected.kind === 'trigger.loop') && (
<div class="wizard-field">
<label>Interval (ms)</label>
<input class="prop-input" type="number" value={selected.params.interval ?? '1000'}
onInput={(e) => patchParams(selected.id, { interval: (e.target as HTMLInputElement).value })} />
{selected.kind === 'trigger.loop' && <p class="hint">Runs once on panel load, then on every interval.</p>}
</div>
)}
{selected.kind === 'gate.and' && (
<p class="hint">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).</p>
)}
{selected.kind === 'flow.if' && (
<ExprField label="Condition" value={selected.params.cond ?? ''}
onChange={(v) => patchParams(selected.id, { cond: v })}
onInsert={(ds, sig) => insertRef(selected.id, 'cond', ds, sig)}
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals}
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' && (
<Fragment>
<div class="wizard-field">
<label>Mode</label>
<select class="prop-select" value={selected.params.mode ?? 'count'}
onChange={(e) => patchParams(selected.id, { mode: (e.target as HTMLSelectElement).value })}>
<option value="count">Repeat N times</option>
<option value="while">While condition</option>
</select>
</div>
{(selected.params.mode ?? 'count') === 'count' ? (
<div class="wizard-field">
<label>Repeat count</label>
<input class="prop-input" type="number" value={selected.params.count ?? '3'}
onInput={(e) => patchParams(selected.id, { count: (e.target as HTMLInputElement).value })} />
</div>
) : (
<ExprField label="While condition" value={selected.params.cond ?? ''}
onChange={(v) => patchParams(selected.id, { cond: v })}
onInsert={(ds, sig) => insertRef(selected.id, 'cond', ds, sig)}
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals}
hint="Loops the 'body' port while true (capped). e.g. {stub:level} < 90" />
)}
<p class="hint">Runs the <b>body</b> port repeatedly, then continues on <b>done</b>.</p>
</Fragment>
)}
{selected.kind === 'action.write' && (() => {
const { ds, name } = splitRef(selected.params.target ?? '');
return (
<Fragment>
<div class="wizard-field">
<label>Target data source</label>
<SearchableSelect value={ds} options={dsOptions}
onSelect={(newDs) => { loadSignals(newDs); patchParams(selected.id, { target: `${newDs}:` }); }} />
</div>
<div class="wizard-field">
<label>Target signal</label>
<SearchableSelect value={name} options={signalOptions(ds)}
onSelect={(sig) => patchParams(selected.id, { target: `${ds}:${sig}` })}
placeholder={ds ? 'Search signals…' : 'Select data source first'} />
</div>
<ExprField label="Value (expression)" value={selected.params.expr ?? ''}
onChange={(v) => patchParams(selected.id, { expr: v })}
onInsert={(ds2, sig) => insertRef(selected.id, 'expr', ds2, sig)}
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals}
hint="e.g. ({stub:a} - {stub:b}) / {sys:dt} for a rate — or a constant like 42. {sys:time}=epoch seconds, {sys:dt}=seconds since the trigger last fired." />
</Fragment>
);
})()}
{selected.kind === 'action.delay' && (
<div class="wizard-field">
<label>Delay (ms)</label>
<input class="prop-input" type="number" value={selected.params.ms ?? '0'}
onInput={(e) => patchParams(selected.id, { ms: (e.target as HTMLInputElement).value })} />
</div>
)}
{selected.kind === 'action.accumulate' && (
<Fragment>
<div class="wizard-field">
<label>Array name</label>
<input class="prop-input" value={selected.params.array ?? ''} list="flow-array-names"
placeholder="e.g. data"
onInput={(e) => patchParams(selected.id, { array: (e.target as HTMLInputElement).value })} />
</div>
<ExprField label="Value (expression)" value={selected.params.expr ?? ''}
onChange={(v) => patchParams(selected.id, { expr: v })}
onInsert={(ds2, sig) => insertRef(selected.id, 'expr', ds2, sig)}
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals}
hint="Appends this value (with a timestamp) to the array. e.g. {stub:level}, or {sys:time} for the clock." />
</Fragment>
)}
{selected.kind === 'action.export' && (
<Fragment>
<div class="wizard-field">
<label>Array name</label>
<input class="prop-input" value={selected.params.array ?? ''} list="flow-array-names"
placeholder="e.g. data"
onInput={(e) => patchParams(selected.id, { array: (e.target as HTMLInputElement).value })} />
</div>
<div class="wizard-field">
<label>File name</label>
<input class="prop-input" value={selected.params.filename ?? ''}
placeholder="defaults to the array name"
onInput={(e) => patchParams(selected.id, { filename: (e.target as HTMLInputElement).value })} />
<p class="hint">Downloads the array as CSV (timestamp_ms, iso, value).</p>
</div>
</Fragment>
)}
{selected.kind === 'action.clear' && (
<div class="wizard-field">
<label>Array name</label>
<input class="prop-input" value={selected.params.array ?? ''} list="flow-array-names"
placeholder="e.g. data"
onInput={(e) => patchParams(selected.id, { array: (e.target as HTMLInputElement).value })} />
<p class="hint">Empties the named array (e.g. to start a fresh capture).</p>
</div>
)}
{selected.kind === 'action.log' && (
<Fragment>
<div class="wizard-field">
<label>Label (optional)</label>
<input class="prop-input" value={selected.params.label ?? ''}
placeholder="prefix for the console line"
onInput={(e) => patchParams(selected.id, { label: (e.target as HTMLInputElement).value })} />
</div>
<ExprField label="Value (expression)" value={selected.params.expr ?? ''}
onChange={(v) => patchParams(selected.id, { expr: v })}
onInsert={(ds2, sig) => insertRef(selected.id, 'expr', ds2, sig)}
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals}
hint="Logs this value to the browser console — handy for debugging a flow." />
</Fragment>
)}
<button class="panel-btn" style="margin-top:1rem;" onClick={() => deleteNode(selected.id)}>Delete node</button>
</Fragment>
)}
</div>
</div>
);
function paletteBtn(entry: PaletteEntry) {
return (
<button key={entry.kind} class={`flow-palette-btn flow-palette-${category(entry.kind)}`}
title={`${entry.kind} — drag onto the canvas or click to add`}
draggable
onDragStart={(e) => {
e.dataTransfer?.setData(DRAG_MIME, entry.kind);
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'copy';
}}
onClick={() => addNode(entry)}>{entry.label}</button>
);
}
}
// 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 (
<div class="wizard-field">
<label>{label}</label>
<input class={`prop-input${err ? ' prop-input-error' : ''}`} value={value}
placeholder="expression"
onInput={(e) => onChange((e.target as HTMLInputElement).value)} />
{err && <p class="wizard-error">{err}</p>}
<div class="flow-expr-insert">
<SearchableSelect value={insDs} options={dsOptions}
onSelect={(ds) => { setInsDs(ds); loadSignals(ds); }} placeholder="ds…" />
<SearchableSelect value="" options={signalOptions(insDs)}
onSelect={(sig) => onInsert(insDs, sig)}
placeholder={insDs ? '+ insert signal' : 'pick ds first'} />
</div>
<p class="hint">{hint}</p>
</div>
);
}
// 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 (
<div class="flow-localvars">
<div class="flow-palette-title">Local vars</div>
{statevars.length === 0 && <div class="hint flow-localvars-empty">None yet.</div>}
{statevars.map(v => (
<div key={v.name} class="flow-localvar-row">
<span class="flow-localvar-name" title={`${v.type ?? 'number'} = ${v.initial}`}>{v.name}</span>
<button class="flow-node-del" title="Remove"
onClick={() => onChange(statevars.filter(x => x.name !== v.name))}></button>
</div>
))}
{!open && (
<button class="flow-palette-btn flow-localvar-add" onClick={() => setOpen(true)}>+ Add variable</button>
)}
{open && (
<div class="flow-localvar-form">
<input class="prop-input" value={name} placeholder="name"
onInput={(e) => setName((e.target as HTMLInputElement).value)} />
<select class="prop-select" value={type}
onChange={(e) => setType((e.target as HTMLSelectElement).value as 'number' | 'bool' | 'string')}>
<option value="number">number</option>
<option value="bool">bool</option>
<option value="string">string</option>
</select>
<input class="prop-input" value={initial} placeholder="initial"
onInput={(e) => setInitial((e.target as HTMLInputElement).value)} />
<div class="flow-localvar-actions">
<button class="panel-btn" onClick={add}>Add</button>
<button class="panel-btn" onClick={() => { setOpen(false); setName(''); }}>Cancel</button>
</div>
</div>
)}
</div>
);
}
-424
View File
@@ -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<string, Buf>, windowSec: number): uPlot.AlignedData {
const cutoff = Date.now() / 1000 - windowSec;
const allTs = new Set<number>();
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<HTMLDivElement>(null);
const [windowSec, setWindowSec] = useState(60);
const [stats, setStats] = useState<Array<Stat | null>>([]);
const [metas, setMetas] = useState<Map<string, SignalMeta>>(new Map());
const [editingKey, setEditingKey] = useState<string | null>(null);
const bufsRef = useRef<Map<string, Buf>>(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 (
<div class="plot-panel">
<div class="plot-panel-toolbar">
<span class="plot-panel-title">Live Plot</span>
<div class="plot-window-btns">
{TIME_WINDOWS.map(tw => (
<button
key={tw.secs}
class={`toolbar-btn${windowSec === tw.secs ? ' toolbar-btn-active' : ''}`}
onClick={() => setWindowSec(tw.secs)}
>
{tw.label}
</button>
))}
</div>
</div>
<div class="plot-panel-body">
{signals.length === 0 ? (
<div class="plot-empty-hint">
Right-click any widget in the <b>HMI</b> view and choose <b>Plot</b> to add signals here.
</div>
) : (
<div class="plot-panel-content">
<div class="plot-panel-legend">
{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<PlotSignalStyle>) {
onStyleChange(s.ref, { ...s.style, ...patch });
}
return (
<div class="plot-legend-item" key={key}>
{/* Header row */}
<div class="plot-legend-hdr">
<span class="plot-legend-swatch" style={`background:${color};`} />
<span class="plot-legend-name" title={`${s.ref.ds} / ${s.ref.name}`}>
{s.ref.name}
</span>
<button
class={`plot-legend-icon-btn${isEditing ? ' active' : ''}`}
onClick={() => setEditingKey(isEditing ? null : key)}
title="Edit style"
></button>
<button
class="plot-legend-icon-btn"
onClick={() => onRemove(s.ref)}
title="Remove"
></button>
</div>
{/* Stats */}
<table class="plot-stats-tbl">
<tbody>
<tr>
<td>Last</td>
<td>{st ? `${fmtN(st.last)}${unit ? ' ' + unit : ''}` : '—'}</td>
</tr>
<tr><td>Min</td><td>{st ? fmtN(st.min) : '—'}</td></tr>
<tr><td>Max</td><td>{st ? fmtN(st.max) : '—'}</td></tr>
<tr><td>Mean</td><td>{st ? fmtN(st.mean) : '—'}</td></tr>
</tbody>
</table>
{/* Inline style editor */}
{isEditing && (
<div class="plot-style-editor">
{/* Color */}
<div class="plot-style-row">
<span class="plot-style-label">Color</span>
<input
type="color"
class="plot-style-color"
value={color}
onInput={e => update({ color: (e.target as HTMLInputElement).value })}
/>
</div>
{/* Line width */}
<div class="plot-style-row">
<span class="plot-style-label">Width</span>
<div class="plot-style-btns">
{LINE_WIDTHS.map(lw => (
<button
key={lw.value}
class={`plot-style-btn${lineWidth === lw.value ? ' active' : ''}`}
onClick={() => update({ lineWidth: lw.value })}
title={lw.value === 0 ? 'No line' : `${lw.value}px`}
>{lw.label}</button>
))}
</div>
</div>
{/* Line dash */}
<div class="plot-style-row">
<span class="plot-style-label">Line</span>
<div class="plot-style-btns">
{LINE_DASHES.map(ld => (
<button
key={ld.label}
class={`plot-style-btn${dashEq(lineDash, ld.value) ? ' active' : ''}`}
onClick={() => update({ lineDash: ld.value })}
title={ld.value.length === 0 ? 'Solid' : ld.value[0] > 4 ? 'Dashed' : 'Dotted'}
>{ld.label}</button>
))}
</div>
</div>
{/* Markers */}
<div class="plot-style-row">
<span class="plot-style-label">Markers</span>
<div class="plot-style-btns">
{MARKER_SIZES.map(ms => (
<button
key={ms.value}
class={`plot-style-btn${markerSize === ms.value ? ' active' : ''}`}
onClick={() => update({ markerSize: ms.value })}
title={ms.value === 0 ? 'None' : `Size ${ms.value}`}
>{ms.label}</button>
))}
</div>
</div>
</div>
)}
</div>
);
})}
</div>
<div class="plot-panel-chart" ref={chartRef} />
</div>
)}
</div>
</div>
);
}
+107
View File
@@ -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 (
<div
class={`plot-pane${selected ? ' plot-pane-selected' : ''}`}
onMouseDownCapture={() => onSelect([widgetId])}
onDragOver={(e: DragEvent) => { e.preventDefault(); if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; }}
onDrop={(e: DragEvent) => widget && handleDrop(e, widget)}
>
{selected && <div class="plot-pane-badge">editing</div>}
{widget
? <PlotWidget widget={widget} timeRange={null} />
: <div class="plot-pane-empty">missing plot</div>}
<div class="plot-pane-overlay" onMouseDown={(e: MouseEvent) => e.stopPropagation()}>
<button class="plot-pane-btn" title="Split left/right" onClick={() => handleSplit(widgetId, 'h')}></button>
<button class="plot-pane-btn" title="Split top/bottom" onClick={() => handleSplit(widgetId, 'v')}></button>
<button class="plot-pane-btn" title="Remove this plot" disabled={single} onClick={() => handleClose(widgetId)}></button>
</div>
{widget && widget.signals.length === 0 && (
<div class="plot-pane-hint">Drag a signal here, then configure it in the properties pane </div>
)}
</div>
);
}
return (
<div class="plot-panel-edit">
<SplitLayout layout={layout} renderLeaf={renderLeaf} onResize={handleResize} />
</div>
);
}
+37 -6
View File
@@ -1,5 +1,5 @@
import { h } from 'preact'; import { h } from 'preact';
import { useState } from 'preact/hooks'; import { useState, useEffect } from 'preact/hooks';
import type { Widget, Interface } from './lib/types'; import type { Widget, Interface } from './lib/types';
interface Props { interface Props {
@@ -21,17 +21,25 @@ function Field({ label, children }: { label: string; children: any }) {
} }
function TextInput({ value, onCommit }: { value: string; onCommit: (v: string) => void }) { 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) // Sync when external value changes (e.g. different widget selected)
if (local !== value && document.activeElement?.tagName !== 'INPUT') { useEffect(() => {
setLocal(value); setLocal(value || '');
} }, [value]);
return ( return (
<input <input
class="prop-input" class="prop-input"
value={local} value={local}
onInput={(e) => setLocal((e.target as HTMLInputElement).value)} onInput={(e) => setLocal((e.target as HTMLInputElement).value)}
onChange={(e) => onCommit((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,13 +87,17 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
<div class="props-body"> <div class="props-body">
{/* Canvas-level properties */} {/* Canvas-level properties */}
<div class="props-section"> <div class="props-section">
<div class="props-section-title">Canvas</div> <div class="props-section-title">{iface.kind === 'plot' ? 'Plot panel' : 'Canvas'}</div>
<Field label="Name"> <Field label="Name">
<TextInput <TextInput
value={iface.name} value={iface.name}
onCommit={(v) => onIfaceChange({ ...iface, name: v })} onCommit={(v) => onIfaceChange({ ...iface, name: v })}
/> />
</Field> </Field>
{/* Plot panels fill the viewport via the split layout — fixed px size
is not meaningful, so width/height are hidden. */}
{iface.kind !== 'plot' && (
<div>
<Field label="Width"> <Field label="Width">
<input <input
class="prop-input prop-input-num" class="prop-input prop-input-num"
@@ -105,6 +117,8 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
/> />
</Field> </Field>
</div> </div>
)}
</div>
{w && ( {w && (
<div class="props-section" key={w.id}> <div class="props-section" key={w.id}>
@@ -253,6 +267,23 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
<option value="true">Yes</option> <option value="true">Yes</option>
</select> </select>
</Field> </Field>
<Field label="Action">
<div class="prop-field-col">
<select
class="prop-input"
value={w.options['action'] ?? ''}
onChange={(e) => setOpt('action', (e.target as HTMLSelectElement).value)}
>
<option value="">(none)</option>
{(iface.logic?.nodes ?? [])
.filter(n => n.kind === 'trigger.button')
.map(n => (
<option key={n.id} value={n.params.name ?? ''}>{n.params.name || '(unnamed)'}</option>
))}
</select>
<span class="prop-hint">Fires a Button trigger node (define flows in the Logic tab)</span>
</div>
</Field>
</div> </div>
)} )}
+58
View File
@@ -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 (
<div class="search-select">
<div class="search-select-trigger" onClick={() => setOpen(!open)}>
{value || <span class="search-select-placeholder">{placeholder}</span>}
<span class="search-select-arrow">{open ? '▴' : '▾'}</span>
</div>
{open && (
<div class="search-select-dropdown">
<input
class="prop-input"
autoFocus
placeholder="Search…"
value={filter}
onInput={(e) => setFilter((e.target as HTMLInputElement).value)}
onClick={(e) => e.stopPropagation()}
/>
<div class="search-select-list">
{filtered.length === 0 && <div class="search-select-item hint">No matches</div>}
{filtered.map(opt => (
<div
key={opt}
class={`search-select-item${opt === value ? ' search-select-item-selected' : ''}`}
onClick={() => { onSelect(opt); setOpen(false); setFilter(''); }}
>
{opt}
</div>
))}
</div>
</div>
)}
{open && <div class="search-select-backdrop" onClick={() => setOpen(false)} />}
</div>
);
}
+181
View File
@@ -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<Grant[]>([]);
const [userGroups, setUserGroups] = useState<string[]>([]);
// 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 (
<div class="wizard-backdrop" onClick={onClose}>
<div class="wizard" onClick={e => e.stopPropagation()} style="max-width: 560px;">
<div class="wizard-header">
<span>Share {ifaceName || ifaceId}</span>
<button class="icon-btn" onClick={onClose}></button>
</div>
<div class="wizard-body">
{loading ? (
<p class="hint">Loading</p>
) : (
<div>
{owner && (
<p class="hint" style="padding: 0 0 0.75rem 0;">Owner: <strong>{owner}</strong></p>
)}
<div class="wizard-section-title">Folder</div>
<div class="wizard-field">
<select class="prop-select" value={folder} onChange={e => setFolder((e.target as HTMLSelectElement).value)}>
<option value="">(root no folder)</option>
{folders.map(f => (
<option key={f.id} value={f.id}>{f.name}</option>
))}
</select>
</div>
<div class="wizard-section-title" style="margin-top: 1rem;">Public access</div>
<div class="wizard-field">
<select class="prop-select" value={pub} onChange={e => setPub((e.target as HTMLSelectElement).value as any)}>
<option value="">Private (only owner & shares)</option>
<option value="read">Everyone can view</option>
<option value="write">Everyone can edit</option>
</select>
</div>
<div class="wizard-section-title" style="margin-top: 1rem;">Shared with</div>
{grants.length === 0 ? (
<p class="hint" style="padding: 0 0 0.5rem 0;">Not shared with anyone yet.</p>
) : (
<ul class="iface-list" style="margin-bottom: 0.5rem;">
{grants.map((g, idx) => (
<li key={`${g.kind}:${g.name}`} class="iface-item" style="cursor: default;">
<span>
<span style="font-size: 0.65rem; background: #1e293b; color: #94a3b8; padding: 0 4px; border-radius: 3px; border: 1px solid #334155; margin-right: 6px;">
{g.kind}
</span>
<span style="color: #e2e8f0;">{g.name}</span>
<span style="color: #64748b; margin-left: 6px;">({g.perm})</span>
</span>
<button class="icon-btn iface-delete" title="Remove" onClick={() => removeGrant(idx)}></button>
</li>
))}
</ul>
)}
<div class="wizard-field wizard-field-row" style="gap: 0.5rem; align-items: center;">
<select class="prop-select" style="flex: 0 0 80px;" value={newKind} onChange={e => { setNewKind((e.target as HTMLSelectElement).value as any); setNewName(''); }}>
<option value="user">User</option>
<option value="group">Group</option>
</select>
{newKind === 'group' ? (
<select class="prop-select" style="flex: 1;" value={newName} onChange={e => setNewName((e.target as HTMLSelectElement).value)}>
<option value="">Select group</option>
{userGroups.map(g => <option key={g} value={g}>{g}</option>)}
</select>
) : (
<input
class="prop-input"
style="flex: 1;"
placeholder="username"
value={newName}
onInput={e => setNewName((e.target as HTMLInputElement).value)}
onKeyDown={e => e.key === 'Enter' && addGrant()}
/>
)}
<select class="prop-select" style="flex: 0 0 80px;" value={newPerm} onChange={e => setNewPerm((e.target as HTMLSelectElement).value as any)}>
<option value="read">read</option>
<option value="write">write</option>
</select>
<button class="panel-btn" onClick={addGrant} disabled={!newName.trim()}>Add</button>
</div>
{error && <p class="wizard-error" style="margin-top: 0.75rem;">{error}</p>}
</div>
)}
</div>
<div class="wizard-footer">
<button class="toolbar-btn" onClick={onClose}>Cancel</button>
<button class="toolbar-btn toolbar-btn-primary" onClick={save} disabled={saving || loading}>
{saving ? 'Saving…' : 'Save'}
</button>
</div>
</div>
</div>
);
}
+132 -7
View File
@@ -1,6 +1,6 @@
import { h, Fragment } from 'preact'; import { h, Fragment } from 'preact';
import { useState, useEffect, useRef, useMemo } from 'preact/hooks'; import { useState, useEffect, useRef, useMemo } from 'preact/hooks';
import type { SignalRef } from './lib/types'; import type { SignalRef, StateVar } from './lib/types';
import SyntheticWizard from './SyntheticWizard'; import SyntheticWizard from './SyntheticWizard';
import SyntheticEditor from './SyntheticEditor'; import SyntheticEditor from './SyntheticEditor';
import GroupsTree from './GroupsTree'; import GroupsTree from './GroupsTree';
@@ -46,9 +46,14 @@ function saveCustom(signals: Array<{ ds: string; name: string }>) {
interface Props { interface Props {
onDragStart?: (sig: SignalRef) => void; onDragStart?: (sig: SignalRef) => void;
width?: number; 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<TreeTab>('sources'); const [treeTab, setTreeTab] = useState<TreeTab>('sources');
const [groupBy, setGroupBy] = useState<GroupBy>('datasource'); const [groupBy, setGroupBy] = useState<GroupBy>('datasource');
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
@@ -79,9 +84,14 @@ export default function SignalTree({ onDragStart, width }: Props) {
await Promise.all( await Promise.all(
dsList.map(async ({ name }) => { dsList.map(async ({ name }) => {
try { 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) { 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 }))); all.push(...sigs.map(s => ({ ...s, ds: name })));
} }
} catch (e) { console.error(`Failed to load ${name}:`, e); } } catch (e) { console.error(`Failed to load ${name}:`, e); }
@@ -90,8 +100,10 @@ export default function SignalTree({ onDragStart, width }: Props) {
setRawSignals(all); setRawSignals(all);
// Check if Channel Finder is available // Check if Channel Finder is available
const cf = await fetch('/api/v1/channel-finder?q=').catch(() => null); const cf = await fetch('/api/v1/channel-finder?q=')
setCfAvailable(cf?.status === 200); .then(r => (r.ok ? r.json() : null))
.catch(() => null);
setCfAvailable(!!cf?.available);
} catch (e) { } catch (e) {
console.error('Failed to load signals:', e); console.error('Failed to load signals:', e);
} finally { } 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 // Merge custom signals that might not be in ListSignals yet
const allSignals = useMemo(() => { const allSignals = useMemo(() => {
@@ -243,6 +289,84 @@ export default function SignalTree({ onDragStart, width }: Props) {
<GroupsTree onDragStart={onDragStart} /> <GroupsTree onDragStart={onDragStart} />
)} )}
{treeTab === 'sources' && onStateVarsChange && (
<div class="signal-group local-state-group">
<div class="signal-group-header">
<span class="signal-group-arrow" onClick={() => setLocalOpen(o => !o)}>
{localOpen ? '▾' : '▸'}
</span>
<span class="signal-group-name" title="Panel-local state variables" onClick={() => setLocalOpen(o => !o)}>
Local State
</span>
<span class="signal-group-count">{(statevars ?? []).length}</span>
<button
class="icon-btn signal-add-btn"
title="Add local state variable"
onClick={(e) => { e.stopPropagation(); setShowAddLocal(s => !s); setLocalOpen(true); }}
>+</button>
</div>
{localOpen && (
<div>
{showAddLocal && (
<div class="signal-add-row" style="flex-wrap: wrap; gap: 4px;">
<input
class="signal-add-input"
placeholder="Variable name…"
autoFocus
value={lvName}
onInput={(e) => setLvName((e.target as HTMLInputElement).value)}
onKeyDown={(e: KeyboardEvent) => {
if (e.key === 'Enter') addLocal();
if (e.key === 'Escape') setShowAddLocal(false);
}}
/>
<select
class="prop-select"
style="font-size: 0.7rem; height: 1.6rem; padding: 0 4px;"
value={lvType}
onChange={(e) => setLvType((e.target as HTMLSelectElement).value as any)}
>
<option value="number">number</option>
<option value="bool">bool</option>
<option value="string">string</option>
</select>
<input
class="signal-add-input"
style="flex: 1;"
placeholder="initial"
value={lvInitial}
onInput={(e) => setLvInitial((e.target as HTMLInputElement).value)}
onKeyDown={(e: KeyboardEvent) => { if (e.key === 'Enter') addLocal(); }}
/>
<button class="icon-btn" title="Add" onClick={addLocal}></button>
<button class="icon-btn" title="Cancel" onClick={() => setShowAddLocal(false)}></button>
</div>
)}
{(statevars ?? []).length === 0 && !showAddLocal && (
<p class="hint" style="padding: 0.25rem 0.5rem;">No local variables.</p>
)}
{(statevars ?? []).map(v => (
<div
key={v.name}
class="signal-item signal-custom"
title={`local:${v.name} (${v.type ?? 'number'}, initial ${v.initial})`}
{...makeLocalDraggable(v.name)}
>
<span class="signal-name">{v.name}</span>
<span class="signal-unit">{v.type ?? 'number'}</span>
<button
class="icon-btn signal-remove-btn"
title="Remove local variable"
onMouseDown={(e: MouseEvent) => e.stopPropagation()}
onClick={(e: MouseEvent) => { e.stopPropagation(); removeLocal(v.name); }}
></button>
</div>
))}
</div>
)}
</div>
)}
{treeTab === 'sources' && ( {treeTab === 'sources' && (
<Fragment> <Fragment>
<div class="signal-tree-search"> <div class="signal-tree-search">
@@ -368,6 +492,7 @@ export default function SignalTree({ onDragStart, width }: Props) {
{showWizard && ( {showWizard && (
<SyntheticWizard <SyntheticWizard
currentIfaceId={panelId}
onClose={() => setShowWizard(false)} onClose={() => setShowWizard(false)}
onCreated={loadAllSignals} onCreated={loadAllSignals}
/> />
+64
View File
@@ -0,0 +1,64 @@
import { h } from 'preact';
import type { VNode } from 'preact';
import type { PlotLayout } from './lib/types';
interface Props {
layout: PlotLayout;
/** Render the contents of a leaf pane for the given widget id. */
renderLeaf: (widgetId: string) => VNode;
/** When provided, dividers become draggable and report the new ratio for the
* split node addressed by `path` (a list of 0/1 = a/b choices from the root). */
onResize?: (path: number[], ratio: number) => void;
}
const MIN_RATIO = 0.1;
const MAX_RATIO = 0.9;
export default function SplitLayout({ layout, renderLeaf, onResize }: Props) {
function startDrag(e: MouseEvent, container: HTMLElement | null, dir: 'h' | 'v', path: number[]) {
if (!onResize || !container) return;
e.preventDefault();
e.stopPropagation();
function onMove(mv: MouseEvent) {
const rect = container!.getBoundingClientRect();
const r = dir === 'h'
? (mv.clientX - rect.left) / rect.width
: (mv.clientY - rect.top) / rect.height;
onResize!(path, Math.min(MAX_RATIO, Math.max(MIN_RATIO, r)));
}
function onUp() {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}
function renderNode(node: PlotLayout, path: number[]): VNode {
if (node.type === 'leaf') {
return <div class="split-leaf">{renderLeaf(node.widget)}</div>;
}
const isRow = node.dir === 'h';
let containerEl: HTMLElement | null = null;
return (
<div
class={isRow ? 'split-row' : 'split-col'}
ref={(el) => { containerEl = el as HTMLElement | null; }}
>
<div class="split-child" style={`flex:${node.ratio}`}>
{renderNode(node.a, [...path, 0])}
</div>
<div
class={`split-divider ${isRow ? 'split-divider-h' : 'split-divider-v'}${onResize ? '' : ' split-divider-static'}`}
onMouseDown={(e: MouseEvent) => startDrag(e, containerEl, node.dir, path)}
/>
<div class="split-child" style={`flex:${1 - node.ratio}`}>
{renderNode(node.b, [...path, 1])}
</div>
</div>
);
}
return <div class="split-root">{renderNode(layout, [])}</div>;
}
+1
View File
@@ -20,6 +20,7 @@ const NODE_TYPES: Array<{ type: string; label: string; params: NodeParam[] }> =
{ label: 'Order (18)', key: 'order', type: 'number', default: '1' }, { label: 'Order (18)', key: 'order', type: 'number', default: '1' },
]}, ]},
{ type: 'derivative', label: 'Derivative', params: [] }, { type: 'derivative', label: 'Derivative', params: [] },
{ type: 'integrate', label: 'Integrate', params: [] },
{ type: 'clamp', label: 'Clamp', params: [{ label: 'Min', key: 'min', type: 'number', default: '0' }, { label: 'Max', key: 'max', type: 'number', default: '100' }] }, { type: 'clamp', label: 'Clamp', params: [{ label: 'Min', key: 'min', type: 'number', default: '0' }, { label: 'Max', key: 'max', type: 'number', default: '100' }] },
{ type: 'expr', label: 'Formula', params: [{ label: 'Expression (a,b,c,d = inputs)', key: 'expr', type: 'text', default: 'a * 1.0' }] }, { type: 'expr', label: 'Formula', params: [{ label: 'Expression (a,b,c,d = inputs)', key: 'expr', type: 'text', default: 'a * 1.0' }] },
{ type: 'lua', label: 'Lua Script', params: [{ label: 'Script (a,b,c,d = inputs)', key: 'script', type: 'lua', default: 'return a' }] }, { type: 'lua', label: 'Lua Script', params: [{ label: 'Script (a,b,c,d = inputs)', key: 'script', type: 'lua', default: 'return a' }] },
+23 -58
View File
@@ -1,11 +1,14 @@
import { h } from 'preact'; import { h } from 'preact';
import { useState, useEffect, useMemo } from 'preact/hooks'; import { useState, useEffect } from 'preact/hooks';
import LuaEditor from './LuaEditor'; import LuaEditor from './LuaEditor';
import SearchableSelect from './SearchableSelect';
import type { InputRef, SignalDef } from './lib/types'; import type { InputRef, SignalDef } from './lib/types';
interface Props { interface Props {
onClose: () => void; onClose: () => void;
onCreated: () => void; onCreated: () => void;
// Interface id the wizard was opened from — used to bind panel-scoped signals.
currentIfaceId?: string;
} }
interface NodeParam { interface NodeParam {
@@ -25,6 +28,7 @@ const NODE_TYPES: Array<{ type: string; label: string; params: NodeParam[] }> =
{ label: 'Order (18)', key: 'order', type: 'number', default: '1' }, { label: 'Order (18)', key: 'order', type: 'number', default: '1' },
]}, ]},
{ type: 'derivative', label: 'Derivative', params: [] }, { type: 'derivative', label: 'Derivative', params: [] },
{ type: 'integrate', label: 'Integrate', params: [] },
{ type: 'clamp', label: 'Clamp', params: [{ label: 'Min', key: 'min', type: 'number', default: '0' }, { label: 'Max', key: 'max', type: 'number', default: '100' }] }, { type: 'clamp', label: 'Clamp', params: [{ label: 'Min', key: 'min', type: 'number', default: '0' }, { label: 'Max', key: 'max', type: 'number', default: '100' }] },
{ type: 'expr', label: 'Formula', params: [{ label: 'Expression (a,b,c,d = inputs)', key: 'expr', type: 'text', default: 'a * 1.0' }] }, { type: 'expr', label: 'Formula', params: [{ label: 'Expression (a,b,c,d = inputs)', key: 'expr', type: 'text', default: 'a * 1.0' }] },
{ type: 'lua', label: 'Lua Script', params: [{ label: 'Script (a,b,c,d = inputs)', key: 'script', type: 'lua', default: 'return a' }] }, { type: 'lua', label: 'Lua Script', params: [{ label: 'Script (a,b,c,d = inputs)', key: 'script', type: 'lua', default: 'return a' }] },
@@ -39,69 +43,16 @@ interface SignalInfo {
description?: string; description?: string;
} }
// ── Searchable Selector ──────────────────────────────────────────────────────
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 (
<div class="search-select">
<div class="search-select-trigger" onClick={() => setOpen(!open)}>
{value || <span class="hint">{placeholder}</span>}
<span class="search-select-arrow">{open ? '▴' : '▾'}</span>
</div>
{open && (
<div class="search-select-dropdown">
<input
class="prop-input"
autoFocus
placeholder="Search…"
value={filter}
onInput={(e) => setFilter((e.target as HTMLInputElement).value)}
onClick={(e) => e.stopPropagation()}
/>
<div class="search-select-list">
{filtered.length === 0 && <div class="search-select-item hint">No matches</div>}
{filtered.map(opt => (
<div
key={opt}
class={`search-select-item${opt === value ? ' search-select-item-selected' : ''}`}
onClick={() => { onSelect(opt); setOpen(false); setFilter(''); }}
>
{opt}
</div>
))}
</div>
</div>
)}
{open && <div class="search-select-backdrop" onClick={() => setOpen(false)} />}
</div>
);
}
// ── Main Wizard ────────────────────────────────────────────────────────────── // ── Main Wizard ──────────────────────────────────────────────────────────────
export default function SyntheticWizard({ onClose, onCreated }: Props) { export default function SyntheticWizard({ onClose, onCreated, currentIfaceId }: Props) {
const [name, setName] = useState(''); const [name, setName] = useState('');
const [inputs, setInputs] = useState<InputRef[]>([{ ds: 'stub', signal: 'sine_1hz' }]); const [inputs, setInputs] = useState<InputRef[]>([{ ds: 'stub', signal: 'sine_1hz' }]);
const [nodeType, setNodeType] = useState('gain'); const [nodeType, setNodeType] = useState('gain');
const [params, setParams] = useState<Record<string, string>>({}); const [params, setParams] = useState<Record<string, string>>({});
// Visibility scope. Panel scope requires an interface to bind to, so when the
// wizard is opened outside a saved panel it falls back to 'user'.
const [visibility, setVisibility] = useState<'panel' | 'user' | 'global'>(currentIfaceId ? 'panel' : 'user');
const [unit, setUnit] = useState(''); const [unit, setUnit] = useState('');
const [desc, setDesc] = useState(''); const [desc, setDesc] = useState('');
const [dispLow, setDispLow] = useState('0'); const [dispLow, setDispLow] = useState('0');
@@ -180,6 +131,8 @@ export default function SyntheticWizard({ onClose, onCreated }: Props) {
displayLow: parseFloat(dispLow) || 0, displayLow: parseFloat(dispLow) || 0,
displayHigh: parseFloat(dispHigh) || 100, displayHigh: parseFloat(dispHigh) || 100,
}, },
visibility,
...(visibility === 'panel' && currentIfaceId ? { panel: currentIfaceId } : {}),
}; };
setSaving(true); setSaving(true);
@@ -222,6 +175,18 @@ export default function SyntheticWizard({ onClose, onCreated }: Props) {
onInput={(e) => setName((e.target as HTMLInputElement).value)} /> onInput={(e) => setName((e.target as HTMLInputElement).value)} />
</div> </div>
<div class="wizard-field">
<label>Visibility</label>
<select class="prop-select" value={visibility}
onChange={(e) => setVisibility((e.target as HTMLSelectElement).value as any)}>
<option value="panel" disabled={!currentIfaceId}>
This panel only{currentIfaceId ? '' : ' (save panel first)'}
</option>
<option value="user">All my panels</option>
<option value="global">All panels (global)</option>
</select>
</div>
<div class="wizard-section-title">Input signals</div> <div class="wizard-section-title">Input signals</div>
{inputs.map((inp, idx) => ( {inputs.map((inp, idx) => (
<div key={idx} class="wizard-field wizard-field-row" style="align-items: flex-end; gap: 0.5rem; margin-bottom: 0.5rem;"> <div key={idx} class="wizard-field wizard-field-row" style="align-items: flex-end; gap: 0.5rem; margin-bottom: 0.5rem;">
+28 -55
View File
@@ -2,11 +2,11 @@ import { h } from 'preact';
import { useState, useMemo, useEffect } from 'preact/hooks'; import { useState, useMemo, useEffect } from 'preact/hooks';
import InterfaceList from './InterfaceList'; import InterfaceList from './InterfaceList';
import Canvas from './Canvas'; import Canvas from './Canvas';
import PlotPanel from './PlotPanel';
import type { PlotSignal, PlotSignalStyle } from './PlotPanel';
import { wsClient } from './lib/ws'; import { wsClient } from './lib/ws';
import { parseInterface } from './lib/xml'; import { parseInterface } from './lib/xml';
import type { Interface, SignalRef } from './lib/types'; import { newPlotPanel } from './lib/templates';
import { useAuth, canWrite } from './lib/auth';
import type { Interface } from './lib/types';
import ContextualHelp from './ContextualHelp'; import ContextualHelp from './ContextualHelp';
import HelpModal from './HelpModal'; import HelpModal from './HelpModal';
import ZoomControl from './ZoomControl'; import ZoomControl from './ZoomControl';
@@ -14,6 +14,8 @@ import ZoomControl from './ZoomControl';
interface Props { interface Props {
onEdit?: (iface?: Interface) => void; onEdit?: (iface?: Interface) => void;
initialInterface?: Interface | null; initialInterface?: Interface | null;
/** Notifies the parent which interface is currently shown (for edit return). */
onView?: (iface: Interface | null) => void;
} }
/** Format a Date as a datetime-local input value (YYYY-MM-DDTHH:MM) */ /** Format a Date as a datetime-local input value (YYYY-MM-DDTHH:MM) */
@@ -22,20 +24,17 @@ function toLocalInput(d: Date): string {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
} }
const PLOT_COLORS = ['#4a9eff', '#22c55e', '#f59e0b', '#ef4444', '#a78bfa', '#f472b6', '#34d399', '#fb923c']; export default function ViewMode({ onEdit, initialInterface, onView }: Props) {
type ViewTab = 'hmi' | 'plot';
export default function ViewMode({ onEdit, initialInterface }: Props) {
const [currentInterface, setCurrentInterface] = useState<Interface | null>(initialInterface ?? null); const [currentInterface, setCurrentInterface] = useState<Interface | null>(initialInterface ?? null);
const [parseError, setParseError] = useState<string | null>(null); const [parseError, setParseError] = useState<string | null>(null);
const [wsStatus, setWsStatus] = useState('connecting'); const [wsStatus, setWsStatus] = useState('connecting');
const [showHelp, setShowHelp] = useState(false); const [showHelp, setShowHelp] = useState(false);
const [helpSection, setHelpSection] = useState('start'); const [helpSection, setHelpSection] = useState('start');
const [showTimeNav, setShowTimeNav] = useState(false); const [showTimeNav, setShowTimeNav] = useState(false);
const [viewTab, setViewTab] = useState<ViewTab>('hmi');
const [plotSignals, setPlotSignals] = useState<PlotSignal[]>([]);
const [leftW, setLeftW] = useState(220); const [leftW, setLeftW] = useState(220);
const [listCollapsed, setListCollapsed] = useState(false);
const me = useAuth();
const writable = canWrite(me.level);
function startResize(e: MouseEvent) { function startResize(e: MouseEvent) {
e.preventDefault(); e.preventDefault();
@@ -70,6 +69,13 @@ export default function ViewMode({ onEdit, initialInterface }: Props) {
if (initialInterface) setCurrentInterface(initialInterface); if (initialInterface) setCurrentInterface(initialInterface);
}, [initialInterface]); }, [initialInterface]);
// Auto-hide the interface-selection pane whenever a panel is opened, and keep
// the parent informed of what's shown (so closing the editor can return here).
useEffect(() => {
if (currentInterface) setListCollapsed(true);
onView?.(currentInterface);
}, [currentInterface]);
function handleLoad(xml: string) { function handleLoad(xml: string) {
try { try {
setParseError(null); setParseError(null);
@@ -112,27 +118,6 @@ export default function ViewMode({ onEdit, initialInterface }: Props) {
setTimeRange(null); setTimeRange(null);
} }
function addToPlot(ref: SignalRef) {
const key = `${ref.ds}\0${ref.name}`;
const already = plotSignals.some(s => `${s.ref.ds}\0${s.ref.name}` === key);
if (!already) {
const color = PLOT_COLORS[plotSignals.length % PLOT_COLORS.length];
const style: PlotSignalStyle = { color, lineWidth: 1.5, lineDash: [], markerSize: 5 };
setPlotSignals(prev => [...prev, { ref, style }]);
}
setViewTab('plot');
}
function removeFromPlot(ref: SignalRef) {
setPlotSignals(prev => prev.filter(s => !(s.ref.ds === ref.ds && s.ref.name === ref.name)));
}
function updateSignalStyle(ref: SignalRef, style: PlotSignalStyle) {
setPlotSignals(prev => prev.map(s =>
s.ref.ds === ref.ds && s.ref.name === ref.name ? { ...s, style } : s
));
}
const isLive = timeRange === null; const isLive = timeRange === null;
return ( return (
@@ -152,7 +137,7 @@ export default function ViewMode({ onEdit, initialInterface }: Props) {
<div class="toolbar-right"> <div class="toolbar-right">
<div class={`status-chip ${wsStatus}`}> <div class={`status-chip ${wsStatus}`}>
<span class="status-dot"></span> <span class="status-dot"></span>
{wsStatus} {wsStatus === 'connected' && me.user ? `connected as ${me.user}` : wsStatus}
</div> </div>
<button <button
class={`toolbar-btn${showTimeNav ? ' toolbar-btn-active' : ''}`} class={`toolbar-btn${showTimeNav ? ' toolbar-btn-active' : ''}`}
@@ -170,6 +155,7 @@ export default function ViewMode({ onEdit, initialInterface }: Props) {
📖 📖
</button> </button>
<ZoomControl /> <ZoomControl />
{writable && (
<button <button
class="btn-edit" class="btn-edit"
onClick={() => onEdit?.(currentInterface ?? undefined)} onClick={() => onEdit?.(currentInterface ?? undefined)}
@@ -177,6 +163,12 @@ export default function ViewMode({ onEdit, initialInterface }: Props) {
> >
Edit Edit
</button> </button>
)}
{me.user && (
<span class="user-chip" title={`Signed in as ${me.user}${writable ? '' : ' (read-only)'}`}>
{me.user}{!writable && ' (read-only)'}
</span>
)}
</div> </div>
</header> </header>
@@ -219,14 +211,16 @@ export default function ViewMode({ onEdit, initialInterface }: Props) {
<InterfaceList <InterfaceList
onLoad={handleLoad} onLoad={handleLoad}
onEdit={onEdit} onEdit={onEdit}
onNewPlot={() => onEdit?.(newPlotPanel())}
onEditId={handleEditById} onEditId={handleEditById}
width={leftW} width={leftW}
collapsed={listCollapsed}
onToggleCollapse={() => setListCollapsed(c => !c)}
onSelect={async (id) => { onSelect={async (id) => {
try { try {
const res = await fetch(`/api/v1/interfaces/${encodeURIComponent(id)}`); const res = await fetch(`/api/v1/interfaces/${encodeURIComponent(id)}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`); if (!res.ok) throw new Error(`HTTP ${res.status}`);
handleLoad(await res.text()); handleLoad(await res.text());
setViewTab('hmi');
} catch (err) { } catch (err) {
setParseError(`Failed to load interface "${id}": ${err instanceof Error ? err.message : err}`); setParseError(`Failed to load interface "${id}": ${err instanceof Error ? err.message : err}`);
} }
@@ -235,34 +229,13 @@ export default function ViewMode({ onEdit, initialInterface }: Props) {
<div class="panel-resize-handle" onMouseDown={startResize} /> <div class="panel-resize-handle" onMouseDown={startResize} />
<div class="view-content-area"> <div class="view-content-area">
{/* Tab bar */} <div class="view-panel-container active">
<div class="view-tabs">
<button
class={`view-tab${viewTab === 'hmi' ? ' view-tab-active' : ''}`}
onClick={() => setViewTab('hmi')}
>
HMI
</button>
<button
class={`view-tab${viewTab === 'plot' ? ' view-tab-active' : ''}`}
onClick={() => setViewTab('plot')}
>
Plot{plotSignals.length > 0 ? ` (${plotSignals.length})` : ''}
</button>
</div>
{/* Keep both panels mounted so ring-buffer data is never lost on tab switch */}
<div style={`display:${viewTab === 'hmi' ? 'contents' : 'none'}`}>
<Canvas <Canvas
iface={currentInterface} iface={currentInterface}
onNavigate={handleNavigate} onNavigate={handleNavigate}
timeRange={timeRange} timeRange={timeRange}
onPlot={addToPlot}
/> />
</div> </div>
<div style={`display:${viewTab === 'plot' ? 'contents' : 'none'}`}>
<PlotPanel signals={plotSignals} onRemove={removeFromPlot} onStyleChange={updateSignalStyle} />
</div>
</div> </div>
</div> </div>
+43
View File
@@ -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<Me>(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<Me> {
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;
}
}
+268
View File
@@ -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<string, (a: number[]) => 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<string, Node | Error>();
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<string>();
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); }
}
+89
View File
@@ -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<number>(n).fill(0);
const im = new Array<number>(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<number>(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<number>(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 };
}
+82
View File
@@ -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<string, Writable<SignalValue>>();
const metaStores = new Map<string, Writable<SignalMeta | null>>();
const DEFAULT_VALUE: SignalValue = { value: null, quality: 'unknown', ts: null };
function valueW(name: string): Writable<SignalValue> {
let s = valueStores.get(name);
if (!s) {
s = writable<SignalValue>(DEFAULT_VALUE);
valueStores.set(name, s);
}
return s;
}
function metaW(name: string): Writable<SignalMeta | null> {
let s = metaStores.get(name);
if (!s) {
s = writable<SignalMeta | null>(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<SignalValue> {
return valueW(name);
}
export function getLocalMetaStore(name: string): Readable<SignalMeta | null> {
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() });
}
+375
View File
@@ -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<void> {
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<string, LogicNode>();
// Outgoing wires grouped by source node id.
private out = new Map<string, WireOut[]>();
// Incoming source node ids grouped by target node id (for gate evaluation).
private incoming = new Map<string, string[]>();
// Live signal value cache (key "ds\0name"), kept fresh by subscriptions.
private live = new Map<string, any>();
// Current truth of each level-style trigger (threshold), for AND-gates.
private levelState = new Map<string, boolean>();
// Per-node previous values for edge/change detection.
private prevBool = new Map<string, boolean>();
private prevVal = new Map<string, any>();
// signal key → trigger node ids that react to it (threshold / change).
private watchers = new Map<string, string[]>();
// 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<string, Array<{ t: number; v: number }>>();
// Wall-clock time (ms) each trigger last activated, for the {sys:dt} signal.
private lastFire = new Map<string, number>();
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<string, SignalRef>();
// {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<void> {
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<void> {
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();
+72
View File
@@ -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) };
}
+6
View File
@@ -1,5 +1,6 @@
import { readable, writable, type Readable } from './store'; import { readable, writable, type Readable } from './store';
import { wsClient } from './ws'; import { wsClient } from './ws';
import { getLocalValueStore, getLocalMetaStore } from './localstate';
import type { SignalRef, SignalValue, SignalMeta } from './types'; import type { SignalRef, SignalValue, SignalMeta } from './types';
// ── Default values ──────────────────────────────────────────────────────────── // ── Default values ────────────────────────────────────────────────────────────
@@ -52,6 +53,9 @@ function refKey(ref: SignalRef): string {
* The store stays alive once created (simplest correct behaviour for Phase 4). * The store stays alive once created (simplest correct behaviour for Phase 4).
*/ */
export function getSignalStore(ref: SignalRef): Readable<SignalValue> { export function getSignalStore(ref: SignalRef): Readable<SignalValue> {
// Panel-local variables are served entirely client-side (no WS subscription).
if (ref.ds === 'local') return getLocalValueStore(ref.name);
const key = refKey(ref); const key = refKey(ref);
const existing = valueStores.get(key); const existing = valueStores.get(key);
if (existing) return existing; if (existing) return existing;
@@ -72,6 +76,8 @@ export function getSignalStore(ref: SignalRef): Readable<SignalValue> {
* Metadata is sent once by the server on subscribe. * Metadata is sent once by the server on subscribe.
*/ */
export function getMetaStore(ref: SignalRef): Readable<SignalMeta | null> { export function getMetaStore(ref: SignalRef): Readable<SignalMeta | null> {
if (ref.ds === 'local') return getLocalMetaStore(ref.name);
const key = refKey(ref); const key = refKey(ref);
const existing = metaStores.get(key); const existing = metaStores.get(key);
if (existing) return existing; if (existing) return existing;
+31
View File
@@ -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' },
},
],
};
}
+174
View File
@@ -48,6 +48,107 @@ export type GroupNode =
| { kind: 'folder'; id: string; label: string; children: GroupNode[] } | { kind: 'folder'; id: string; label: string; children: GroupNode[] }
| { kind: 'signal'; ds: string; name: string }; | { 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-REDstyle 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<string, string>;
}
// 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 // A complete HMI interface definition
export interface Interface { export interface Interface {
id: string; id: string;
@@ -56,6 +157,74 @@ export interface Interface {
w: number; w: number;
h: number; h: number;
widgets: Widget[]; 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 ──────────────────────────────────────────────────────── // ── Synthetic signals ────────────────────────────────────────────────────────
@@ -83,4 +252,9 @@ export interface SignalDef {
displayHigh?: number; displayHigh?: number;
writable?: boolean; 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'
} }
+6
View File
@@ -1,4 +1,5 @@
import { writable, type Readable } from './store'; import { writable, type Readable } from './store';
import { writeLocalState } from './localstate';
import type { SignalRef, SignalValue, SignalMeta, HistoryPoint } from './types'; import type { SignalRef, SignalValue, SignalMeta, HistoryPoint } from './types';
// ── Types ──────────────────────────────────────────────────────────────────── // ── Types ────────────────────────────────────────────────────────────────────
@@ -259,6 +260,11 @@ class WsClient {
} }
write(ref: SignalRef, value: any): void { 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); 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 }); this._send({ type: 'write', ds: ref.ds, name: ref.name, value });
} }
+144 -3
View File
@@ -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. */ /** Escape a string for use as an XML attribute value. */
function xmlEsc(s: string): string { function xmlEsc(s: string): string {
@@ -9,14 +9,67 @@ function xmlEsc(s: string): string {
.replace(/>/g, '&gt;'); .replace(/>/g, '&gt;');
} }
/** Serialize a panel-logic flow graph to indented XML lines. */
function serializeLogic(graph: LogicGraph, lines: string[]): void {
lines.push(` <logic>`);
for (const node of graph.nodes) {
lines.push(
` <node id="${xmlEsc(node.id)}" kind="${xmlEsc(node.kind)}" ` +
`x="${node.x}" y="${node.y}">`
);
for (const [key, value] of Object.entries(node.params)) {
lines.push(` <param key="${xmlEsc(key)}" value="${xmlEsc(value)}"/>`);
}
lines.push(` </node>`);
}
for (const wire of graph.wires) {
const portAttr = wire.fromPort && wire.fromPort !== 'out'
? ` port="${xmlEsc(wire.fromPort)}"` : '';
lines.push(` <wire from="${xmlEsc(wire.from)}"${portAttr} to="${xmlEsc(wire.to)}"/>`);
}
lines.push(` </logic>`);
}
/** Serialize a PlotLayout subtree to indented XML lines. */
function serializeLayout(node: PlotLayout, indent: string, lines: string[]): void {
if (node.type === 'leaf') {
lines.push(`${indent}<leaf widget="${xmlEsc(node.widget)}"/>`);
return;
}
lines.push(`${indent}<split dir="${node.dir}" ratio="${node.ratio}">`);
serializeLayout(node.a, indent + ' ', lines);
serializeLayout(node.b, indent + ' ', lines);
lines.push(`${indent}</split>`);
}
/** Serialize an Interface object to an XML string. */ /** Serialize an Interface object to an XML string. */
export function serializeInterface(iface: Interface): string { export function serializeInterface(iface: Interface): string {
const lines: string[] = []; const lines: string[] = [];
lines.push(`<?xml version="1.0" encoding="UTF-8"?>`); lines.push(`<?xml version="1.0" encoding="UTF-8"?>`);
const kindAttr = iface.kind === 'plot' ? ` kind="plot"` : '';
lines.push( lines.push(
`<interface id="${xmlEsc(iface.id)}" name="${xmlEsc(iface.name)}" ` + `<interface id="${xmlEsc(iface.id)}" name="${xmlEsc(iface.name)}" ` +
`version="${iface.version}" width="${iface.w}" height="${iface.h}">` `version="${iface.version}" width="${iface.w}" height="${iface.h}"${kindAttr}>`
); );
if (iface.kind === 'plot' && iface.layout) {
lines.push(` <layout>`);
serializeLayout(iface.layout, ' ', lines);
lines.push(` </layout>`);
}
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(` <statevar ${attrs.join(' ')}/>`);
}
if (iface.logic && (iface.logic.nodes.length > 0 || iface.logic.wires.length > 0)) {
serializeLogic(iface.logic, lines);
}
for (const w of iface.widgets) { for (const w of iface.widgets) {
lines.push( lines.push(
` <widget id="${xmlEsc(w.id)}" type="${xmlEsc(w.type)}" ` + ` <widget id="${xmlEsc(w.id)}" type="${xmlEsc(w.type)}" ` +
@@ -46,6 +99,56 @@ export function serializeInterface(iface: Interface): string {
* </widget> * </widget>
* </interface> * </interface>
*/ */
/** Parse a <split>/<leaf> 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 <logic> 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<string, string> = {};
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 { export function parseInterface(xml: string): Interface {
const parser = new DOMParser(); const parser = new DOMParser();
const doc = parser.parseFromString(xml, 'application/xml'); 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 version = parseInt(root.getAttribute('version') ?? '1', 10);
const w = parseInt(root.getAttribute('width') ?? '1200', 10); const w = parseInt(root.getAttribute('width') ?? '1200', 10);
const h = parseInt(root.getAttribute('height') ?? '800', 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[] = []; const widgets: Widget[] = [];
@@ -100,5 +235,11 @@ export function parseInterface(xml: string): Interface {
widgets.push({ id, type, x, y, w, h, signals, options }); 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 } : {}),
};
} }
+677 -249
View File
@@ -164,6 +164,42 @@ body {
background: #374151; 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 { .content {
display: flex; display: flex;
flex: 1; flex: 1;
@@ -257,8 +293,17 @@ body {
color: #e2e8f0; color: #e2e8f0;
} }
.panel-body {
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
min-height: 0;
}
.panel-actions { .panel-actions {
display: flex; display: flex;
flex-wrap: wrap;
gap: 0.4rem; gap: 0.4rem;
padding: 0.5rem 0.6rem; padding: 0.5rem 0.6rem;
border-bottom: 1px solid #2d3748; border-bottom: 1px solid #2d3748;
@@ -834,6 +879,15 @@ body {
background: #1e40af; 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 ─────────────────────────────────────────────────────── */
.button-widget { .button-widget {
@@ -868,6 +922,19 @@ body {
transform: scale(0.97); 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 ───────────────────────────────────────────────────────── */ /* ── ImageWidget ───────────────────────────────────────────────────────── */
.image-widget { .image-widget {
@@ -1001,6 +1068,7 @@ body {
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
font-family: system-ui, sans-serif; font-family: system-ui, sans-serif;
white-space: nowrap;
} }
.toolbar-btn:hover { background: #374151; } .toolbar-btn:hover { background: #374151; }
@@ -1042,6 +1110,384 @@ body {
min-height: 0; 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 ────────────────────────────────────────────────────────── */
.edit-canvas-container { .edit-canvas-container {
@@ -1792,6 +2238,47 @@ body {
.iface-delete { color: #ef4444 !important; } .iface-delete { color: #ef4444 !important; }
.iface-delete:hover { background: rgba(239, 68, 68, 0.15) !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 ───────────────────────────────────────────────────── */ /* ── Phase 7 additions ───────────────────────────────────────────────────── */
/* Rubber-band selection rectangle */ /* Rubber-band selection rectangle */
@@ -2395,39 +2882,6 @@ kbd {
overflow: hidden; 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 ─────────────────────────────────────────────────────────── */ /* ── InfoPanel ─────────────────────────────────────────────────────────── */
.info-panel { .info-panel {
@@ -2527,220 +2981,6 @@ kbd {
line-height: 1.4; 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 ─────────────────────────────────────────────────────────── */
.zoom-control { .zoom-control {
@@ -2800,6 +3040,15 @@ kbd {
margin-left: 0.25rem; 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 { .search-select-dropdown {
position: absolute; position: absolute;
top: 100%; top: 100%;
@@ -2893,11 +3142,190 @@ kbd {
} }
.panel-btn.icon-only { .panel-btn.icon-only {
padding: 0; padding: 0 0.35rem;
width: 1.6rem; min-width: 1.6rem;
width: auto;
height: 1.6rem; height: 1.6rem;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 0.9rem; 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; }
+31 -4
View File
@@ -1,8 +1,10 @@
import { h } from 'preact'; import { h } from 'preact';
import { useState, useEffect } from 'preact/hooks'; import { useState, useEffect } from 'preact/hooks';
import { wsClient } from '../lib/ws'; import { wsClient } from '../lib/ws';
import { getSignalStore } from '../lib/stores'; import { getSignalStore, getMetaStore } from '../lib/stores';
import type { Widget } from '../lib/types'; 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; } 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 mode = widget.options['mode'] ?? 'oneshot';
const resetTimeSec = parseFloat(widget.options['resetTime'] ?? '1'); const resetTimeSec = parseFloat(widget.options['resetTime'] ?? '1');
const confirmOpt = widget.options['confirm'] === 'true'; 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<any>(null); const [signalVal, setSignalVal] = useState<any>(null);
const [meta, setMeta] = useState<SignalMeta | null>(null);
// Subscribe to signal only for persistent mode (to track whether it is active) // Subscribe to signal only for persistent mode (to track whether it is active)
useEffect(() => { useEffect(() => {
@@ -24,6 +30,20 @@ export default function Button({ widget, onContextMenu }: Props) {
return getSignalStore(sigRef).subscribe(sv => setSignalVal(sv.value)); return getSignalStore(sigRef).subscribe(sv => setSignalVal(sv.value));
}, [sigRef?.ds, sigRef?.name, mode]); }, [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 parsedSend = isNaN(parseFloat(sendValue)) ? sendValue : parseFloat(sendValue);
const parsedReset = isNaN(parseFloat(resetValue)) ? resetValue : parseFloat(resetValue); const parsedReset = isNaN(parseFloat(resetValue)) ? resetValue : parseFloat(resetValue);
@@ -38,6 +58,7 @@ export default function Button({ widget, onContextMenu }: Props) {
} }
function execute() { function execute() {
if (sigRef) {
if (mode === 'setReset') { if (mode === 'setReset') {
doWrite(parsedSend); doWrite(parsedSend);
setTimeout(() => doWrite(parsedReset), Math.max(0, resetTimeSec) * 1000); setTimeout(() => doWrite(parsedReset), Math.max(0, resetTimeSec) * 1000);
@@ -47,9 +68,13 @@ export default function Button({ widget, onContextMenu }: Props) {
doWrite(parsedSend); doWrite(parsedSend);
} }
} }
if (action) logicEngine.runAction(action);
}
function handleClick() { 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 (confirmOpt) {
if (window.confirm(`Send ${label}?`)) execute(); if (window.confirm(`Send ${label}?`)) execute();
} else { } 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;`} style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
onContextMenu={onContextMenu} onContextMenu={onContextMenu}
> >
<button class={`btn${isPressed ? ' btn-pressed' : ''}`} onClick={handleClick}> <button class={`btn${isPressed ? ' btn-pressed' : ''}`} onClick={handleClick}
disabled={!writeAllowed}
title={writeAllowed ? '' : 'You do not have permission to write this signal'}>
{label} {label}
{mode === 'setReset' && ( {mode === 'setReset' && (
<span class="btn-mode-hint">{resetTimeSec}s</span> <span class="btn-mode-hint">{resetTimeSec}s</span>
+68 -25
View File
@@ -5,6 +5,7 @@ import * as echarts from 'echarts';
import { getSignalStore } from '../lib/stores'; import { getSignalStore } from '../lib/stores';
import { wsClient } from '../lib/ws'; import { wsClient } from '../lib/ws';
import { formatValue } from '../lib/format'; import { formatValue } from '../lib/format';
import { fftMag, resampleUniform } from '../lib/fft';
import type { Widget, SignalRef } from '../lib/types'; import type { Widget, SignalRef } from '../lib/types';
interface Props { interface Props {
@@ -81,7 +82,6 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
const showLegend = legendPos !== 'none'; const showLegend = legendPos !== 'none';
const buffers: SeriesBuffer[] = signals.map(() => ({ timestamps: [], values: [] })); const buffers: SeriesBuffer[] = signals.map(() => ({ timestamps: [], values: [] }));
const histValues: number[][] = signals.map(() => []);
const unsubs: (() => void)[] = []; const unsubs: (() => void)[] = [];
const fmt = widget.options['format'] ?? ''; const fmt = widget.options['format'] ?? '';
@@ -180,12 +180,14 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
switch (plotType) { switch (plotType) {
case 'histogram': { case 'histogram': {
const { labels, counts } = buildHistogram(histValues); // Distribution over all buffered samples (ring-buffer bounded).
const { labels, counts } = buildHistogram(buffers.map(b => b.values));
return { return {
backgroundColor: ECHARTS_DARK.backgroundColor, backgroundColor: ECHARTS_DARK.backgroundColor,
legend: legendOpt, legend: legendOpt,
xAxis: { type: 'category', data: labels, axisLine, axisLabel, splitLine }, grid: { left: 56, right: 16, top: showLegend ? 28 : 12, bottom: 36 },
yAxis: { type: 'value', axisLine, axisLabel, splitLine }, xAxis: { type: 'category', name: 'Value', nameLocation: 'middle', nameGap: 24, data: labels, axisLine, axisLabel, splitLine },
yAxis: { type: 'value', name: 'Count', axisLine, axisLabel, splitLine },
series: counts.map((d, i) => ({ series: counts.map((d, i) => ({
name: signals[i]?.name ?? `S${i}`, name: signals[i]?.name ?? `S${i}`,
type: 'bar', type: 'bar',
@@ -198,6 +200,7 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
return { return {
backgroundColor: ECHARTS_DARK.backgroundColor, backgroundColor: ECHARTS_DARK.backgroundColor,
legend: legendOpt, legend: legendOpt,
grid: { left: 56, right: 16, top: showLegend ? 28 : 12, bottom: 36 },
xAxis: { type: 'category', data: signals.map(s => s.name), axisLine, axisLabel }, xAxis: { type: 'category', data: signals.map(s => s.name), axisLine, axisLabel },
yAxis: { type: 'value', axisLine, axisLabel, splitLine }, yAxis: { type: 'value', axisLine, axisLabel, splitLine },
series: [{ series: [{
@@ -210,32 +213,62 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
}; };
} }
case 'fft': { case 'fft': {
// Single-sided magnitude spectrum per signal, computed from the
// windowed samples resampled onto a uniform grid.
const N = 256;
return { return {
backgroundColor: ECHARTS_DARK.backgroundColor, backgroundColor: ECHARTS_DARK.backgroundColor,
legend: legendOpt, legend: legendOpt,
xAxis: { type: 'category', name: 'Freq Index', axisLine, axisLabel }, grid: { left: 60, right: 16, top: showLegend ? 28 : 12, bottom: 40 },
yAxis: { type: 'value', axisLine, axisLabel, splitLine }, xAxis: { type: 'value', name: 'Hz', nameLocation: 'middle', nameGap: 24, min: 0, axisLine, axisLabel, splitLine },
series: buffers.map((b, i) => ({ yAxis: { type: 'value', name: 'Mag', axisLine, axisLabel, splitLine },
series: buffers.map((b, i) => {
const { ts, vals } = windowedSlice(b, timeWindow);
let data: [number, number][] = [];
if (vals.length >= 4) {
const { sampled, dt } = resampleUniform(ts, vals, N);
const mag = fftMag(sampled);
const fs = dt > 0 ? 1 / dt : 1; // sample rate (Hz)
data = mag.map((m, k) => [(k * fs) / N, m]);
}
return {
name: signals[i]?.name ?? `S${i}`, name: signals[i]?.name ?? `S${i}`,
type: 'line', type: 'line',
data: b.values.length ? b.values[b.values.length - 1] : [], data,
lineStyle: { color: signals[i]?.color ?? COLORS[i % COLORS.length] }, lineStyle: { color: signals[i]?.color ?? COLORS[i % COLORS.length] },
showSymbol: false, showSymbol: false,
})), };
}),
}; };
} }
case 'logic': { case 'logic': {
// Logic-analyser style: each signal is a 0/1 step trace stacked on its
// own lane, with a relative-time x-axis (seconds before now).
const nowSec = Date.now() / 1000;
const lane = 1.4;
return { return {
backgroundColor: ECHARTS_DARK.backgroundColor, backgroundColor: ECHARTS_DARK.backgroundColor,
legend: legendOpt, legend: legendOpt,
xAxis: { type: 'value', min: 'dataMin', max: 'dataMax', axisLine, axisLabel }, grid: { left: 90, right: 16, top: showLegend ? 28 : 12, bottom: 36 },
yAxis: { type: 'value', min: -0.1, max: 1.1, axisLine, axisLabel, splitLine }, xAxis: {
type: 'value', min: 'dataMin', max: 0, axisLine, splitLine,
axisLabel: { color: '#64748b', formatter: (v: number) => `${v.toFixed(0)}s` },
},
yAxis: {
type: 'value', min: 0, max: Math.max(lane, signals.length * lane),
interval: lane, axisLine, splitLine,
axisLabel: {
color: '#64748b',
formatter: (v: number) => signals[Math.round(v / lane)]?.name ?? '',
},
},
series: buffers.map((b, i) => { series: buffers.map((b, i) => {
const { ts, vals } = windowedSlice(b, timeWindow); const { ts, vals } = windowedSlice(b, timeWindow);
const base = i * lane;
return { return {
name: signals[i]?.name ?? `S${i}`, name: signals[i]?.name ?? `S${i}`,
type: 'line', step: 'end', type: 'line', step: 'end',
data: ts.map((t, j) => [t, vals[j] > 0 ? 1 : 0]), data: ts.map((t, j) => [t - nowSec, base + (vals[j] > 0 ? 1 : 0)]),
lineStyle: { color: signals[i]?.color ?? COLORS[i % COLORS.length] }, lineStyle: { color: signals[i]?.color ?? COLORS[i % COLORS.length] },
showSymbol: false, showSymbol: false,
}; };
@@ -243,22 +276,33 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
}; };
} }
case 'waterfall': { case 'waterfall': {
const ROWS = 32; // Spectrogram of the first signal: split the windowed samples into
// ROWS time frames and FFT each frame (freq on x, time on y).
const ROWS = 24, FRAME = 32;
const b = buffers[0]; const b = buffers[0];
const step = Math.max(1, Math.floor(b.values.length / ROWS));
const flatData: [number, number, number][] = []; const flatData: [number, number, number][] = [];
for (let ri = 0; ri < ROWS; ri++) { let maxMag = 0;
const idx = b.values.length - 1 - (ROWS - 1 - ri) * step; if (b && b.values.length >= FRAME) {
if (idx < 0) continue; const { sampled } = resampleUniform(b.timestamps, b.values, ROWS * FRAME);
const row = b.values[idx]; for (let r = 0; r < ROWS; r++) {
const arr = Array.isArray(row) ? row : [row]; const mag = fftMag(sampled.slice(r * FRAME, (r + 1) * FRAME));
arr.forEach((val, ci) => flatData.push([ci, ri, val])); for (let k = 0; k < mag.length; k++) {
flatData.push([k, r, mag[k]]);
if (mag[k] > maxMag) maxMag = mag[k];
} }
}
}
const bins = FRAME >> 1;
return { return {
backgroundColor: ECHARTS_DARK.backgroundColor, backgroundColor: ECHARTS_DARK.backgroundColor,
visualMap: { min: 0, max: 1, show: false, inRange: { color: ['#0f1117', '#4a9eff'] } }, grid: { left: 56, right: 70, top: 12, bottom: 36 },
xAxis: { type: 'value', axisLabel }, visualMap: {
yAxis: { type: 'value', axisLabel }, min: 0, max: maxMag || 1, calculable: true, right: 8, top: 'center',
dimension: 2, textStyle: { color: '#94a3b8' },
inRange: { color: ['#0f1117', '#1e3a8a', '#4a9eff', '#22c55e', '#f59e0b', '#ef4444'] },
},
xAxis: { type: 'category', name: 'Freq bin', nameLocation: 'middle', nameGap: 22, data: Array.from({ length: bins }, (_, k) => String(k)), axisLine, axisLabel },
yAxis: { type: 'category', name: 'Time', data: Array.from({ length: ROWS }, (_, r) => String(r - ROWS + 1)), axisLine, axisLabel },
series: [{ type: 'heatmap', data: flatData }], series: [{ type: 'heatmap', data: flatData }],
}; };
} }
@@ -346,7 +390,6 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
const v = typeof sv.value === 'number' ? sv.value : parseFloat(String(sv.value)); const v = typeof sv.value === 'number' ? sv.value : parseFloat(String(sv.value));
if (!isNaN(v)) { if (!isNaN(v)) {
pushSample(buffers[i], ts, v); pushSample(buffers[i], ts, v);
if (plotType === 'histogram') histValues[i].push(v);
} }
if (echart) echart.setOption(echartsOption(), { notMerge: true }); if (echart) echart.setOption(echartsOption(), { notMerge: true });
} }
@@ -372,7 +415,7 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
if (uplot) uplot.destroy(); if (uplot) uplot.destroy();
if (echart) echart.dispose(); if (echart) echart.dispose();
}; };
}, [widget.id, timeRangeKey, plotType, signalsKey, widget.options['timeWindow'] ?? '60', widget.options['format'] ?? '', widget.options['yMin'] ?? '', widget.options['yMax'] ?? '']); }, [widget.id, timeRangeKey, plotType, signalsKey, widget.options['timeWindow'] ?? '60', widget.options['format'] ?? '', widget.options['yMin'] ?? '', widget.options['yMax'] ?? '', widget.options['legend'] ?? 'bottom']);
return ( return (
<div <div
+18 -4
View File
@@ -2,6 +2,7 @@ import { h, Fragment } from 'preact';
import { useState, useEffect } from 'preact/hooks'; import { useState, useEffect } from 'preact/hooks';
import { getSignalStore, getMetaStore } from '../lib/stores'; import { getSignalStore, getMetaStore } from '../lib/stores';
import { wsClient } from '../lib/ws'; import { wsClient } from '../lib/ws';
import { useAuth, canWrite } from '../lib/auth';
import type { Widget, SignalValue, SignalMeta } from '../lib/types'; import type { Widget, SignalValue, SignalMeta } from '../lib/types';
const DEFAULT_VALUE: SignalValue = { value: null, quality: 'unknown', ts: null }; const DEFAULT_VALUE: SignalValue = { value: null, quality: 'unknown', ts: null };
@@ -19,6 +20,7 @@ interface Props { widget: Widget; onContextMenu?: (e: MouseEvent) => void; }
export default function SetValue({ widget, onContextMenu }: Props) { export default function SetValue({ widget, onContextMenu }: Props) {
const sigRef = widget.signals[0]; const sigRef = widget.signals[0];
const me = useAuth();
const [sv, setSv] = useState<SignalValue>(DEFAULT_VALUE); const [sv, setSv] = useState<SignalValue>(DEFAULT_VALUE);
const [meta, setMeta] = useState<SignalMeta | null>(null); const [meta, setMeta] = useState<SignalMeta | null>(null);
const [inputValue, setInputValue] = useState(''); const [inputValue, setInputValue] = useState('');
@@ -38,6 +40,14 @@ export default function SetValue({ widget, onContextMenu }: Props) {
const isEnum = !!(meta?.enumStrings && meta.enumStrings.length > 0); const isEnum = !!(meta?.enumStrings && meta.enumStrings.length > 0);
const quality = sv.quality; const quality = sv.quality;
// The control is read-only when the current user cannot write this signal:
// either their global access is read-only, or the signal's metadata reports
// it is not writable. Panel-local vars are written client-side (no backend
// meta) so they stay editable. meta.writable === false only once known, so
// the control isn't prematurely disabled while metadata is still loading.
const isLocal = sigRef?.ds === 'local';
const writeAllowed = isLocal || (canWrite(me.level) && meta?.writable !== false);
function displayValue(): string { function displayValue(): string {
const v = sv.value; const v = sv.value;
if (v === null || v === undefined) return '---'; if (v === null || v === undefined) return '---';
@@ -52,7 +62,7 @@ export default function SetValue({ widget, onContextMenu }: Props) {
} }
function doWrite(raw: string) { function doWrite(raw: string) {
if (!sigRef || !raw.trim()) return; if (!sigRef || !writeAllowed || !raw.trim()) return;
const val = isNumeric ? parseFloat(raw) : raw; const val = isNumeric ? parseFloat(raw) : raw;
wsClient.write(sigRef, val); wsClient.write(sigRef, val);
setInputValue(''); setInputValue('');
@@ -84,7 +94,7 @@ export default function SetValue({ widget, onContextMenu }: Props) {
const [enumSelected, setEnumSelected] = useState(currentEnumIdx >= 0 ? String(currentEnumIdx) : '0'); const [enumSelected, setEnumSelected] = useState(currentEnumIdx >= 0 ? String(currentEnumIdx) : '0');
function handleEnumSet() { function handleEnumSet() {
if (!sigRef) return; if (!sigRef || !writeAllowed) return;
const doEnumWrite = () => { wsClient.write(sigRef, parseFloat(enumSelected)); }; const doEnumWrite = () => { wsClient.write(sigRef, parseFloat(enumSelected)); };
if (confirm) { if (confirm) {
const display = meta?.enumStrings?.[parseInt(enumSelected, 10)] ?? enumSelected; const display = meta?.enumStrings?.[parseInt(enumSelected, 10)] ?? enumSelected;
@@ -107,13 +117,15 @@ export default function SetValue({ widget, onContextMenu }: Props) {
<select <select
class="sv-input sv-select" class="sv-input sv-select"
value={enumSelected} value={enumSelected}
disabled={!writeAllowed}
onChange={(e: Event) => setEnumSelected((e.target as HTMLSelectElement).value)} onChange={(e: Event) => setEnumSelected((e.target as HTMLSelectElement).value)}
> >
{meta!.enumStrings!.map((s, i) => ( {meta!.enumStrings!.map((s, i) => (
<option key={i} value={String(i)}>{s}</option> <option key={i} value={String(i)}>{s}</option>
))} ))}
</select> </select>
<button class="sv-btn" onClick={handleEnumSet}>Set</button> <button class="sv-btn" onClick={handleEnumSet} disabled={!writeAllowed}
title={writeAllowed ? '' : 'You do not have permission to write this signal'}>Set</button>
</> </>
) : ( ) : (
<> <>
@@ -121,13 +133,15 @@ export default function SetValue({ widget, onContextMenu }: Props) {
class="sv-input" class="sv-input"
type={isNumeric ? 'number' : 'text'} type={isNumeric ? 'number' : 'text'}
value={inputValue} value={inputValue}
disabled={!writeAllowed}
onInput={(e: Event) => setInputValue((e.target as HTMLInputElement).value)} onInput={(e: Event) => setInputValue((e.target as HTMLInputElement).value)}
onKeyDown={handleKeydown} onKeyDown={handleKeydown}
placeholder="value" placeholder="value"
/> />
<span class="sv-current">{displayValue()}</span> <span class="sv-current">{displayValue()}</span>
{unit && <span class="sv-unit">{unit}</span>} {unit && <span class="sv-unit">{unit}</span>}
<button class="sv-btn" onClick={handleSet}>Set</button> <button class="sv-btn" onClick={handleSet} disabled={!writeAllowed}
title={writeAllowed ? '' : 'You do not have permission to write this signal'}>Set</button>
</> </>
)} )}
</div> </div>
+10
View File
@@ -0,0 +1,10 @@
{
"panels": {},
"folders": {
"fld-68b0bb9c23c9fd3b": {
"id": "fld-68b0bb9c23c9fd3b",
"name": "Test",
"owner": ""
}
}
}
+1 -1
View File
@@ -128,7 +128,7 @@ fi
# ── Start uopi ──────────────────────────────────────────────────────────────── # ── Start uopi ────────────────────────────────────────────────────────────────
echo "" echo ""
echo "Starting uopi (http://localhost:8080)..." echo "Starting uopi (http://localhost:8088)..."
echo " UOPI_CA_DEBUG=$UOPI_CA_DEBUG" echo " UOPI_CA_DEBUG=$UOPI_CA_DEBUG"
echo " Config: $WORKSPACE/uopi.toml" echo " Config: $WORKSPACE/uopi.toml"
echo " Log will appear below. Press Ctrl-C to stop." echo " Log will appear below. Press Ctrl-C to stop."