Major changes: logic add to panel, local variables, panel histor, users management...
This commit is contained in:
+22
-1
@@ -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)
|
||||||
|
|||||||
@@ -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())
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 != ""
|
||||||
|
}
|
||||||
@@ -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].
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+30
-11
@@ -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">
|
||||||
|
<div class="access-denied">
|
||||||
|
<h1>Access denied</h1>
|
||||||
|
<p>
|
||||||
|
Your account{me.user ? ` (${me.user})` : ''} does not have permission
|
||||||
|
to access this application. Contact an administrator.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={me}>
|
||||||
|
<div class="app-root">
|
||||||
{wsStatus === 'disconnected' && (
|
{wsStatus === 'disconnected' && (
|
||||||
<div class="connection-banner">WebSocket disconnected — reconnecting…</div>
|
<div class="connection-banner">WebSocket disconnected — reconnecting…</div>
|
||||||
)}
|
)}
|
||||||
{/* ViewMode is always mounted so PlotPanel ring-buffers are never destroyed.
|
{mode === 'view' ? (
|
||||||
EditMode is conditionally rendered; it resets from props each time anyway. */}
|
<ViewMode onEdit={enterEdit} initialInterface={viewTarget} onView={setViewTarget} />
|
||||||
<div style={`display:${mode === 'view' ? 'contents' : 'none'}`}>
|
) : (
|
||||||
<ViewMode onEdit={enterEdit} initialInterface={viewTarget} />
|
<EditMode key={editTarget?.id || 'new'} initial={editTarget} onDone={exitEdit} />
|
||||||
</div>
|
|
||||||
{mode === 'edit' && (
|
|
||||||
<EditMode initial={editTarget} onDone={exitEdit} />
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</AuthContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+51
-8
@@ -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 && (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ const NODE_TYPES: Array<{ type: string; label: string; params: NodeParam[] }> =
|
|||||||
{ label: 'Order (1–8)', key: 'order', type: 'number', default: '1' },
|
{ label: 'Order (1–8)', 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
@@ -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 (1–8)', key: 'order', type: 'number', default: '1' },
|
{ label: 'Order (1–8)', 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
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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); }
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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() });
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
@@ -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) };
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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-RED–style flow graph) ──────────────────────────────────
|
||||||
|
|
||||||
|
// The kind of a logic node.
|
||||||
|
//
|
||||||
|
// Triggers are flow entry points:
|
||||||
|
// trigger.button — fired when a button widget's Action names this node
|
||||||
|
// (params: name).
|
||||||
|
// trigger.threshold — fired on the rising edge of (signal `op` value)
|
||||||
|
// (params: signal, op, value).
|
||||||
|
// trigger.timer — fired every `interval` ms while the panel is in view
|
||||||
|
// (params: interval).
|
||||||
|
// trigger.loop — fired once on panel load, then every `interval` ms
|
||||||
|
// (params: interval).
|
||||||
|
// trigger.change — fired whenever a signal's value changes
|
||||||
|
// (params: signal).
|
||||||
|
//
|
||||||
|
// Gates combine/guard flow:
|
||||||
|
// gate.and — passes to its output only when ALL incoming triggers
|
||||||
|
// are currently satisfied (no params).
|
||||||
|
//
|
||||||
|
// Control flow (use labelled output ports on the wires):
|
||||||
|
// flow.if — evaluate `cond`; continue on the 'then' or 'else' port
|
||||||
|
// (params: cond).
|
||||||
|
// flow.loop — repeat the 'body' port then continue on 'done'
|
||||||
|
// (params: mode 'count'|'while', count, cond).
|
||||||
|
//
|
||||||
|
// Actions:
|
||||||
|
// action.write — evaluate `expr` and write the result to `target`
|
||||||
|
// ("ds:name" or a bare local var) (params: target, expr).
|
||||||
|
// action.delay — pause `ms` before continuing downstream (params: ms).
|
||||||
|
// action.accumulate — evaluate `expr` and append the value (with a timestamp)
|
||||||
|
// to the named in-memory data array (params: array, expr).
|
||||||
|
// action.export — download the named array as a CSV file (params: array,
|
||||||
|
// filename).
|
||||||
|
// action.clear — empty the named array (params: array).
|
||||||
|
// action.log — evaluate `expr` and log it to the browser console for
|
||||||
|
// debugging a flow (params: expr, label).
|
||||||
|
//
|
||||||
|
// Expressions (cond/expr) may reference signals inline as {ds:name} and panel-
|
||||||
|
// local state variables as bare identifiers. See lib/expr.ts.
|
||||||
|
export type LogicNodeKind =
|
||||||
|
| 'trigger.button'
|
||||||
|
| 'trigger.threshold'
|
||||||
|
| 'trigger.timer'
|
||||||
|
| 'trigger.loop'
|
||||||
|
| 'trigger.change'
|
||||||
|
| 'gate.and'
|
||||||
|
| 'flow.if'
|
||||||
|
| 'flow.loop'
|
||||||
|
| 'action.write'
|
||||||
|
| 'action.delay'
|
||||||
|
| 'action.accumulate'
|
||||||
|
| 'action.export'
|
||||||
|
| 'action.clear'
|
||||||
|
| 'action.log';
|
||||||
|
|
||||||
|
// A block on the flow canvas. `params` holds kind-specific fields as strings.
|
||||||
|
export interface LogicNode {
|
||||||
|
id: string;
|
||||||
|
kind: LogicNodeKind;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
params: Record<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'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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, '>');
|
.replace(/>/g, '>');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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
@@ -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; }
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"panels": {},
|
||||||
|
"folders": {
|
||||||
|
"fld-68b0bb9c23c9fd3b": {
|
||||||
|
"id": "fld-68b0bb9c23c9fd3b",
|
||||||
|
"name": "Test",
|
||||||
|
"owner": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -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."
|
||||||
|
|||||||
Reference in New Issue
Block a user