Compare commits

...

6 Commits

Author SHA1 Message Date
Martino Ferrari 3fc7c1b546 initial config manager 2026-06-20 17:40:03 +02:00
Martino Ferrari 206d5b541d Expand TODO widgets item with concrete HMI widget examples
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-20 17:10:52 +02:00
Martino Ferrari f7f297c3df Add synthetic array (waveform) DSP support + UX improvements
Adds full array/waveform support through the synthetic DSP engine: a
dsp.Sample value model (scalar or []float64), array ops (index, slice,
sum, mean, min, max, length, fft) with an in-tree radix-2 FFT, and static
type propagation (OpOutputType) that the editor mirrors to colour wires by
data type and flag invalid wirings. Stateful filters and lua stay
scalar-only. Adds a waveform plot mode (x-vs-index trace).

Also: errored-node hover reasons, S/N add-signal/add-node HUD shortcuts in
the synthetic editor, and view-mode widgets that blend with the canvas
background (chrome kept in edit mode).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-20 17:06:55 +02:00
Martino Ferrari 446de7f1ee Improving all side of app 2026-06-20 14:28:28 +02:00
Martino Ferrari 901b87d407 Implementing more advanced feature: audit and more 2026-06-19 14:17:46 +02:00
Martino Ferrari 8f6dbcba49 Working on audit 2026-06-19 09:44:57 +02:00
81 changed files with 7962 additions and 967 deletions
+41
View File
@@ -0,0 +1,41 @@
# TODO
- [ ] **MAJOR** Implement configuration manager:
- configuration is a set of signals (that can be organized in group and subgroup) that are used to configure a system or a sub system
- with configuration manager user should be able to:
- Define and manage configuration sets: delete (keep server side copy of deleted config), create (specifying default to parameters, mandatory, optional etc), edit (with versioning git style) and compare set
- user can fork an existing config instance to create a new one
- user can compare two instances: show unified diff or side by side diff
- etc...
- Create, delete (keep server cache), edit (git style versioning) and compare configuration instances: meaning set actual value to a configuration set
- user can fork an existing config instance to create a new one
- user can compare two instances: show unified diff or side by side diff
- etc...
- user can apply and save configuration instances
- in logic editor and control loop add nodes to read/write/create/apply config instances
- [ ] Improve UX:
- [x] Synthetic editor:
- [x] color code the node link by type
- [x] hover on a block in error should show the reason
- [x] add proper array functionality
- [x] add shortcut to add Signals (S) and nodes (N) with HUD
- [ ] Panels:
- [x] in view mode the widgets should have no border/bg but blend with background
- [ ] add widgets such as toggle switch, table and other industrial hmi widgets
- [ ] Implement git style versioning for: synthetic variable, panels, control logic:
- possibility to fork any version
- click to view the version
- possibility to view graphical diff between versions (side by side or unified diff)
- simple slick versioning pane:
- vertical tree like
- each version represented by a circle
- active (the one currently view/edited) version has circle bigger then rest
- selected (the one that will be executed/showed by user) version has circle full, not active only border
- unsaved / new version appear with connection line dashed
- [ ] Implement admin pane: create / manage groups, set users permits, manage auditors etc
- [ ] Implement new datasources:
- [ ] Finalize alarm service
- [ ] modbus tcp
- [ ] scpi tcp
- [ ] udp? other?
- [ ] **MAJOR** Implement proper distributed server side nodes to balance load and have redundancy (if a node is not available anymore all its clients migrate seamelessly to another)
+58 -3
View File
@@ -9,14 +9,19 @@ import (
"log/slog"
"os"
"os/signal"
"os/user"
"path/filepath"
"syscall"
"github.com/uopi/uopi/internal/access"
"github.com/uopi/uopi/internal/audit"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/config"
"github.com/uopi/uopi/internal/confmgr"
"github.com/uopi/uopi/internal/controllogic"
"github.com/uopi/uopi/internal/datasource/epics"
"github.com/uopi/uopi/internal/datasource/pva"
"github.com/uopi/uopi/internal/datasource/servervar"
"github.com/uopi/uopi/internal/datasource/stub"
"github.com/uopi/uopi/internal/datasource/synthetic"
"github.com/uopi/uopi/internal/panelacl"
@@ -41,6 +46,16 @@ func main() {
os.Exit(1)
}
// In an unproxied/local deployment (no trusted_user_header) without an explicit
// default_user, attribute actions to the OS user running the process so the UI
// and audit log show a real identity instead of anonymous.
if cfg.Server.TrustedUserHeader == "" && cfg.Server.DefaultUser == "" {
if u, err := user.Current(); err == nil && u.Username != "" {
cfg.Server.DefaultUser = u.Username
log.Info("no default_user configured; defaulting to OS user", "user", u.Username)
}
}
if err := os.MkdirAll(cfg.Server.StorageDir, 0o755); err != nil {
log.Error("failed to create storage dir", "dir", cfg.Server.StorageDir, "err", err)
os.Exit(1)
@@ -58,6 +73,12 @@ func main() {
os.Exit(1)
}
cfgStore, err := confmgr.New(cfg.Server.StorageDir)
if err != nil {
log.Error("failed to open configuration store", "err", err)
os.Exit(1)
}
webFS, err := fs.Sub(web.FS, "dist")
if err != nil {
log.Error("failed to sub web dist", "err", err)
@@ -122,6 +143,15 @@ func main() {
}
}
// Server variables: a small persistent key/value source ("srv") that the
// control-logic engine writes (e.g. sequence state) and panels can read.
srvVars, err := servervar.New(cfg.Server.StorageDir)
if err != nil {
log.Error("server variables init", "err", err)
os.Exit(1)
}
brk.Register(srvVars)
// Build the global access policy from config: every user is trusted with
// full write access unless downgraded by the blacklist; groups are named
// sets of users referenced by per-panel sharing.
@@ -133,7 +163,26 @@ func main() {
for _, g := range cfg.Groups {
groups[g.Name] = g.Members
}
policy := access.New(cfg.Server.DefaultUser, blacklist, groups, cfg.Server.LogicEditors)
policy := access.New(cfg.Server.DefaultUser, blacklist, groups, cfg.Server.LogicEditors, cfg.Audit.Readers)
// Audit log: when enabled, every system-affecting action (user/automated
// signal writes, interface and control-logic mutations) is recorded to SQLite.
// When disabled a no-op recorder is used so call sites need no guards.
recorder := audit.Nop()
if cfg.Audit.Enabled {
dbPath := cfg.Audit.DBPath
if dbPath == "" {
dbPath = filepath.Join(cfg.Server.StorageDir, "audit.db")
}
r, err := audit.NewSQLite(dbPath, log)
if err != nil {
log.Error("failed to open audit log", "path", dbPath, "err", err)
os.Exit(1)
}
defer r.Close()
recorder = r
log.Info("audit log enabled", "path", dbPath)
}
// Server-side control logic: flow graphs that run continuously under the root
// context, independent of any panel. The store is loaded from disk and the
@@ -143,10 +192,16 @@ func main() {
log.Error("failed to open control logic store", "err", err)
os.Exit(1)
}
ctrlEngine := controllogic.NewEngine(ctx, brk, ctrlStore, log)
ctrlEngine := controllogic.NewEngine(ctx, brk, ctrlStore, recorder, log)
// Dialog hub: control-logic action.dialog nodes push notifications/inputs to
// connected clients (filtered by user/group) and route input responses back
// to server variables. Installed before Reload so running graphs can emit.
dialogs := server.NewDialogHub(brk, policy, recorder, log)
ctrlEngine.SetNotifier(dialogs)
ctrlEngine.Reload()
srv := server.New(cfg.Server.Listen, webFS, brk, synthDS, store, policy, aclStore, ctrlStore, ctrlEngine, cfg.Datasource.EPICS.ChannelFinderURL, cfg.Datasource.EPICS.ArchiveURL, cfg.Server.TrustedUserHeader, log)
srv := server.New(cfg.Server.Listen, webFS, brk, synthDS, store, cfgStore, policy, aclStore, ctrlStore, ctrlEngine, dialogs, recorder, cfg.Datasource.EPICS.ChannelFinderURL, cfg.Datasource.EPICS.ArchiveURL, cfg.Server.TrustedUserHeader, log)
if err := srv.Start(ctx); err != nil {
fmt.Fprintf(os.Stderr, "server error: %v\n", err)
+1
View File
@@ -192,6 +192,7 @@ When multiple widgets are selected:
| Histogram | numeric scalar(s) |
| Bar chart | numeric scalar(s) |
| Logic analyser | boolean / integer (bitset) |
| Waveform | 1-D numeric array (latest sample, x-vs-index) |
### 4.5 Widget Properties (Properties Pane)
+26 -11
View File
@@ -41,7 +41,7 @@
| ---------------- | --------------------------------------------------------------- |
| `preact` 10 | Virtual DOM UI framework |
| `uPlot` | Extremely fast time-series/line plot (canvas-based, < 40 kB) |
| `Apache ECharts` | FFT, waterfall, histogram, bar, logic analyser plots |
| `Apache ECharts` | FFT, waterfall, histogram, bar, logic analyser, waveform plots |
| `uplot.css` | uPlot default stylesheet |
**Intentionally excluded:** React, Vue, Svelte, Konva, WebGPU, jQuery, npm at runtime.
@@ -234,15 +234,30 @@ endpoints and for any change to a panel's `<logic>` block.
**Built-in node types:**
| Node type | Parameters | Description |
| ------------ | ----------------------------------- | ------------------------------------------------ |
| `source` | `ds`, `name` | Reads a signal from any data source |
| `gain` | `factor` | Multiplies by a constant |
| `offset` | `value` | Adds a constant |
| `moving_avg` | `window` (samples) | Rolling mean |
| `lowpass` | `freq` (Hz), `order` (18) | Cascaded IIR Butterworth-style low-pass filter |
| `formula` | `expr` | Inline math expression (variables: `a`, `b`, …) |
| `lua` | `script` | Arbitrary Lua 5.1 code with persistent state |
| Node type | Parameters | In→Out | Description |
| ---------------- | --------------------------- | -------------- | ----------------------------------------------- |
| `source` | `ds`, `name` | — | Reads a signal from any data source |
| `gain` | `gain` | elementwise | Multiplies by a constant |
| `offset` | `offset` | elementwise | Adds a constant |
| `add`/`subtract` | — | elementwise | Sum of inputs / `a b` |
| `multiply`/`divide` | — | elementwise | Product of inputs / `a ÷ b` |
| `clamp` | `min`, `max` | elementwise | Constrains to a range |
| `threshold` | `threshold`, `high`, `low` | elementwise | Comparator output |
| `moving_average` | `window` (samples) | scalar-only | Rolling mean |
| `rms` | `window` (samples) | scalar-only | Rolling RMS |
| `derivative` | — | scalar-only | Time derivative (per-sample `dt`) |
| `integrate` | — | scalar-only | Trapezoidal integral |
| `lowpass` | `freq` (Hz), `order` (18) | scalar-only | Cascaded IIR Butterworth-style low-pass filter |
| `expr` | `expr`, `vars` | elementwise | Inline math expression (named inputs) |
| `lua` | `script`, `vars` | scalar-only | Arbitrary Lua 5.1 code with persistent state |
| `index` | `i` | array→scalar | Element `i` of a waveform (bounds-checked) |
| `slice` | `start`, `end` | array→array | Sub-range of a waveform (clamped) |
| `sum`/`mean` | — | array→scalar | Σ / average of a waveform |
| `min`/`max` | — | array→scalar | Reduction of a waveform |
| `length` | — | array→scalar | Element count of a waveform |
| `fft` | — | array→array | Magnitude spectrum (zero-padded to next pow-2) |
**Scalar vs waveform values:** A value flowing through the graph is a `dsp.Sample` — either a scalar `float64` or a `[]float64` waveform (the array-aware counterpart of EPICS `TypeFloat64Array`). *Elementwise* ops broadcast over arrays (scalar inputs act as constants; array inputs must share a length). *Reduction*/*producer* ops (`index`/`slice`/`sum`/…/`fft`) operate natively on waveforms. *Scalar-only* ops (stateful filters + `lua`) reject array inputs, since their per-evaluation state cannot be split across array lanes. `OpOutputType` (`internal/dsp/types.go`) propagates types statically at compile time to reject invalid wirings and to report the synthetic's metadata type; the editor mirrors these rules in `web/src/lib/synthTypes.ts` to colour wires by data type (scalar vs array) and flag type-incompatible links. Runtime `Sample` typing is authoritative.
**Low-pass filter implementation:** Cascaded first-order IIR sections. Each stage computes `y = y_prev + α·(x y_prev)` where `α = dt / (RC + dt)` and `RC = 1/(2π·fc)`. `dt` is computed per sample from source timestamps so the filter is correct for event-driven (non-uniform) data.
@@ -353,7 +368,7 @@ The edit canvas is a free-form HTML div with absolutely positioned widget compon
View mode renders widgets as absolutely positioned Preact components on a scrollable canvas div:
- Each widget subscribes to its signal store(s) in a `useEffect` and re-renders only when values change.
- uPlot (time series) and ECharts (histogram, bar, FFT, waterfall, logic analyser) manage their own canvas elements inside their widget component.
- uPlot (time series) and ECharts (histogram, bar, FFT, waterfall, logic analyser, waveform) manage their own canvas elements inside their widget component. The `waveform` plot renders a waveform (array) signal's latest `[]float64` as an x-vs-index trace, replacing the trace on each update.
- Plot widgets maintain a rolling ring buffer of 200,000 samples per signal for smooth long-window display.
- Step-hold interpolation: when multiple signals at different update rates share a plot, the most recent value is carried forward to fill the shared time axis correctly.
+13 -1
View File
@@ -9,4 +9,16 @@ require (
github.com/yuin/gopher-lua v1.1.2
)
require golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/sys v0.36.0 // indirect
modernc.org/libc v1.66.10 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.42.2 // indirect
)
+23
View File
@@ -2,9 +2,32 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/evanw/esbuild v0.28.0 h1:V96ghtc5p5JnNUQIUsc5H3kr+AcFcMqOJll2ZmJW6Lo=
github.com/evanw/esbuild v0.28.0/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA=
github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.42.2 h1:7hkZUNJvJFN2PgfUdjni9Kbvd4ef4mNLOu0B9FGxM74=
modernc.org/sqlite v1.42.2/go.mod h1:+VkC6v3pLOAE0A0uVucQEcbVW0I5nHCeDaBf+DpsQT8=
+32 -1
View File
@@ -61,18 +61,20 @@ type Policy struct {
userGroups map[string][]string // user → groups they belong to
groupNames []string // all configured group names (sorted)
logicEditors map[string]bool // users + group names allowed to edit logic
auditReaders map[string]bool // users + group names allowed to view the audit log
}
// New builds a Policy. blacklist maps a username to a config level string;
// groups maps a group name to its member usernames. logicEditors optionally
// restricts who may edit panel/control logic (usernames or group names); empty
// means no restriction.
func New(defaultUser string, blacklist map[string]string, groups map[string][]string, logicEditors []string) *Policy {
func New(defaultUser string, blacklist map[string]string, groups map[string][]string, logicEditors, auditReaders []string) *Policy {
p := &Policy{
defaultUser: strings.TrimSpace(defaultUser),
blacklist: make(map[string]Level),
userGroups: make(map[string][]string),
logicEditors: make(map[string]bool),
auditReaders: make(map[string]bool),
}
for _, e := range logicEditors {
e = strings.TrimSpace(e)
@@ -80,6 +82,12 @@ func New(defaultUser string, blacklist map[string]string, groups map[string][]st
p.logicEditors[e] = true
}
}
for _, e := range auditReaders {
e = strings.TrimSpace(e)
if e != "" {
p.auditReaders[e] = true
}
}
for user, lvl := range blacklist {
u := strings.TrimSpace(user)
if u == "" {
@@ -165,6 +173,29 @@ func (p *Policy) CanEditLogic(user string) bool {
return false
}
// CanViewAudit reports whether a user may view the audit log. When no reader
// allowlist is configured everyone with read access qualifies; otherwise the
// user (or one of their groups) must be listed. Anonymous/trusted-LAN callers
// (user=="") are always permitted.
func (p *Policy) CanViewAudit(user string) bool {
user = strings.TrimSpace(user)
if user == "" {
return true
}
if len(p.auditReaders) == 0 {
return true
}
if p.auditReaders[user] {
return true
}
for _, g := range p.userGroups[user] {
if p.auditReaders[g] {
return true
}
}
return false
}
// GroupsOf returns a copy of the groups a user belongs to.
func (p *Policy) GroupsOf(user string) []string {
src := p.userGroups[strings.TrimSpace(user)]
+2 -2
View File
@@ -6,7 +6,7 @@ func TestCanEditLogic(t *testing.T) {
groups := map[string][]string{"ops": {"carol"}}
// No allowlist configured: everyone with write access may edit logic.
open := New("", nil, groups, nil)
open := New("", nil, groups, nil, nil)
for _, u := range []string{"", "alice", "carol"} {
if !open.CanEditLogic(u) {
t.Errorf("unrestricted: CanEditLogic(%q) = false, want true", u)
@@ -17,7 +17,7 @@ func TestCanEditLogic(t *testing.T) {
}
// Allowlist by username and by group name.
p := New("", nil, groups, []string{"alice", "ops"})
p := New("", nil, groups, []string{"alice", "ops"}, nil)
if !p.LogicRestricted() {
t.Error("LogicRestricted() = false with allowlist set")
}
+127 -5
View File
@@ -8,13 +8,17 @@ import (
"errors"
"io"
"log/slog"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/uopi/uopi/internal/access"
"github.com/uopi/uopi/internal/audit"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/confmgr"
"github.com/uopi/uopi/internal/controllogic"
"github.com/uopi/uopi/internal/datasource"
"github.com/uopi/uopi/internal/datasource/synthetic"
@@ -27,25 +31,33 @@ type Handler struct {
broker *broker.Broker
synthetic *synthetic.Synthetic // nil if not enabled
store *storage.Store
cfg *confmgr.Store
policy *access.Policy
acl *panelacl.Store
ctrlLogic *controllogic.Store
ctrlEngine *controllogic.Engine
channelFinderURL string // empty if not configured
archiverURL string // empty if not configured
audit audit.Recorder // never nil; audit.Nop when disabled
channelFinderURL string // empty if not configured
archiverURL string // empty if not configured
log *slog.Logger
}
// New creates an API Handler. synth may be nil if the synthetic DS is disabled.
func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, policy *access.Policy, acl *panelacl.Store, ctrlLogic *controllogic.Store, ctrlEngine *controllogic.Engine, channelFinderURL, archiverURL string, log *slog.Logger) *Handler {
// rec records system-affecting mutations; pass audit.Nop() to disable auditing.
func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, cfg *confmgr.Store, policy *access.Policy, acl *panelacl.Store, ctrlLogic *controllogic.Store, ctrlEngine *controllogic.Engine, rec audit.Recorder, channelFinderURL, archiverURL string, log *slog.Logger) *Handler {
if rec == nil {
rec = audit.Nop()
}
return &Handler{
broker: b,
synthetic: synth,
store: store,
cfg: cfg,
policy: policy,
acl: acl,
ctrlLogic: ctrlLogic,
ctrlEngine: ctrlEngine,
audit: rec,
channelFinderURL: channelFinderURL,
archiverURL: archiverURL,
log: log,
@@ -56,6 +68,7 @@ func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, pol
// Requires Go 1.22+ for method+path routing.
func (h *Handler) Register(mux *http.ServeMux, prefix string) {
mux.HandleFunc("GET "+prefix+"/me", h.getMe)
mux.HandleFunc("GET "+prefix+"/audit", h.getAudit)
mux.HandleFunc("GET "+prefix+"/datasources", h.listDataSources)
mux.HandleFunc("GET "+prefix+"/signals", h.listSignals)
mux.HandleFunc("GET "+prefix+"/signals/search", h.searchSignals)
@@ -99,6 +112,30 @@ func (h *Handler) Register(mux *http.ServeMux, prefix string) {
mux.HandleFunc("GET "+prefix+"/controllogic/{id}", h.getControlLogic)
mux.HandleFunc("PUT "+prefix+"/controllogic/{id}", h.updateControlLogic)
mux.HandleFunc("DELETE "+prefix+"/controllogic/{id}", h.deleteControlLogic)
// Configuration manager — config sets (schemas) and instances (values),
// both versioned git-style. Mutations are write-gated by the access
// middleware; "apply" writes each value to its target signal.
mux.HandleFunc("GET "+prefix+"/config/sets", h.listConfigSets)
mux.HandleFunc("POST "+prefix+"/config/sets", h.createConfigSet)
mux.HandleFunc("GET "+prefix+"/config/sets/{id}", h.getConfigSet)
mux.HandleFunc("PUT "+prefix+"/config/sets/{id}", h.updateConfigSet)
mux.HandleFunc("DELETE "+prefix+"/config/sets/{id}", h.deleteConfigSet)
mux.HandleFunc("GET "+prefix+"/config/sets/{id}/versions", h.listConfigSetVersions)
mux.HandleFunc("GET "+prefix+"/config/sets/{id}/versions/{version}", h.getConfigSetVersion)
mux.HandleFunc("POST "+prefix+"/config/sets/{id}/versions/{version}/promote", h.promoteConfigSet)
mux.HandleFunc("POST "+prefix+"/config/sets/{id}/versions/{version}/fork", h.forkConfigSet)
mux.HandleFunc("GET "+prefix+"/config/sets/diff", h.diffConfigSets)
mux.HandleFunc("GET "+prefix+"/config/instances", h.listConfigInstances)
mux.HandleFunc("POST "+prefix+"/config/instances", h.createConfigInstance)
mux.HandleFunc("GET "+prefix+"/config/instances/{id}", h.getConfigInstance)
mux.HandleFunc("PUT "+prefix+"/config/instances/{id}", h.updateConfigInstance)
mux.HandleFunc("DELETE "+prefix+"/config/instances/{id}", h.deleteConfigInstance)
mux.HandleFunc("GET "+prefix+"/config/instances/{id}/versions", h.listConfigInstanceVersions)
mux.HandleFunc("GET "+prefix+"/config/instances/{id}/versions/{version}", h.getConfigInstanceVersion)
mux.HandleFunc("POST "+prefix+"/config/instances/{id}/versions/{version}/promote", h.promoteConfigInstance)
mux.HandleFunc("POST "+prefix+"/config/instances/{id}/versions/{version}/fork", h.forkConfigInstance)
mux.HandleFunc("POST "+prefix+"/config/instances/{id}/apply", h.applyConfigInstance)
mux.HandleFunc("GET "+prefix+"/config/instances/diff", h.diffConfigInstances)
}
// ── /me ─────────────────────────────────────────────────────────────────────
@@ -118,15 +155,93 @@ func (h *Handler) getMe(w http.ResponseWriter, r *http.Request) {
"level": h.policy.Level(user).String(),
"groups": groups,
"canEditLogic": h.policy.CanEditLogic(user),
"canViewAudit": h.policy.CanViewAudit(user),
})
}
// ── /audit ────────────────────────────────────────────────────────────────────
// getAudit returns audit-log entries matching the query filters. Access is
// restricted to users permitted by CanViewAudit (the audit-readers allowlist).
// Filters: start, end (RFC3339), user, action, ds, signal, limit.
func (h *Handler) getAudit(w http.ResponseWriter, r *http.Request) {
if !h.policy.CanViewAudit(caller(r)) {
jsonError(w, http.StatusForbidden, "you are not permitted to view the audit log")
return
}
q := r.URL.Query()
f := audit.Filter{
Actor: q.Get("user"),
Action: q.Get("action"),
DS: q.Get("ds"),
Signal: q.Get("signal"),
}
if v := q.Get("start"); v != "" {
t, err := time.Parse(time.RFC3339, v)
if err != nil {
jsonError(w, http.StatusBadRequest, "invalid start time (want RFC3339): "+v)
return
}
f.Start = t
}
if v := q.Get("end"); v != "" {
t, err := time.Parse(time.RFC3339, v)
if err != nil {
jsonError(w, http.StatusBadRequest, "invalid end time (want RFC3339): "+v)
return
}
f.End = t
}
if v := q.Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
f.Limit = n
}
}
events, err := h.audit.Query(f)
if err != nil {
h.log.Error("audit query", "err", err)
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
jsonOK(w, events)
}
// ── access helpers ────────────────────────────────────────────────────────────
// caller returns the resolved end-user identity for the request (set by the
// access middleware).
func caller(r *http.Request) string { return access.UserFrom(r.Context()) }
// clientIP returns the best-effort client address for audit attribution,
// preferring the proxy-set X-Forwarded-For / X-Real-IP headers.
func clientIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if i := strings.IndexByte(xff, ','); i >= 0 {
return strings.TrimSpace(xff[:i])
}
return strings.TrimSpace(xff)
}
if xr := r.Header.Get("X-Real-IP"); xr != "" {
return strings.TrimSpace(xr)
}
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
return host
}
return r.RemoteAddr
}
// recordMutation appends a successful configuration change to the audit log.
func (h *Handler) recordMutation(r *http.Request, action, detail string) {
h.audit.Record(audit.Event{
Actor: caller(r),
ActorType: audit.ActorUser,
Action: action,
Detail: detail,
IP: clientIP(r),
Outcome: audit.OutcomeOK,
})
}
// capByGlobal lowers a per-panel/folder permission to what the user's global
// access level allows: read-only users can never exceed read, no-access users
// get nothing.
@@ -490,6 +605,7 @@ func (h *Handler) createInterface(w http.ResponseWriter, r *http.Request) {
h.log.Error("record panel ownership", "id", id, "err", err)
}
}
h.recordMutation(r, "interface.create", id)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(map[string]string{"id": id})
@@ -580,6 +696,7 @@ func (h *Handler) updateInterface(w http.ResponseWriter, r *http.Request) {
}
return
}
h.recordMutation(r, "interface.update", id)
w.WriteHeader(http.StatusNoContent)
}
@@ -721,6 +838,7 @@ func (h *Handler) deleteInterface(w http.ResponseWriter, r *http.Request) {
if err := h.acl.DeletePanel(id); err != nil {
h.log.Error("delete panel ACL", "id", id, "err", err)
}
h.recordMutation(r, "interface.delete", id)
w.WriteHeader(http.StatusNoContent)
}
@@ -1123,6 +1241,7 @@ func (h *Handler) createControlLogic(w http.ResponseWriter, r *http.Request) {
return
}
h.ctrlEngine.Reload()
h.recordMutation(r, "controllogic.create", g.ID+" "+g.Name)
w.WriteHeader(http.StatusCreated)
jsonOK(w, g)
}
@@ -1152,6 +1271,7 @@ func (h *Handler) updateControlLogic(w http.ResponseWriter, r *http.Request) {
return
}
h.ctrlEngine.Reload()
h.recordMutation(r, "controllogic.update", g.ID+" "+g.Name)
jsonOK(w, g)
}
@@ -1164,11 +1284,13 @@ func (h *Handler) deleteControlLogic(w http.ResponseWriter, r *http.Request) {
jsonError(w, http.StatusForbidden, "you are not permitted to edit logic")
return
}
if err := h.ctrlLogic.Delete(r.PathValue("id")); err != nil {
jsonError(w, http.StatusNotFound, "control logic graph not found: "+r.PathValue("id"))
id := r.PathValue("id")
if err := h.ctrlLogic.Delete(id); err != nil {
jsonError(w, http.StatusNotFound, "control logic graph not found: "+id)
return
}
h.ctrlEngine.Reload()
h.recordMutation(r, "controllogic.delete", id)
w.WriteHeader(http.StatusNoContent)
}
+18 -5
View File
@@ -14,7 +14,9 @@ import (
"github.com/uopi/uopi/internal/access"
"github.com/uopi/uopi/internal/api"
"github.com/uopi/uopi/internal/audit"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/confmgr"
"github.com/uopi/uopi/internal/controllogic"
"github.com/uopi/uopi/internal/datasource/stub"
"github.com/uopi/uopi/internal/panelacl"
@@ -50,10 +52,15 @@ func setup(t *testing.T) (*httptest.Server, func()) {
if err != nil {
t.Fatal("controllogic.NewStore:", err)
}
clEngine := controllogic.NewEngine(ctx, brk, clStore, log)
clEngine := controllogic.NewEngine(ctx, brk, clStore, audit.Nop(), log)
cfgStore, err := confmgr.New(dir)
if err != nil {
t.Fatal("confmgr.New:", err)
}
mux := http.NewServeMux()
api.New(brk, nil, store, access.New("", nil, nil, nil), acl, clStore, clEngine, "", "", log).Register(mux, "/api/v1")
api.New(brk, nil, store, cfgStore, access.New("", nil, nil, nil, nil), acl, clStore, clEngine, audit.Nop(), "", "", log).Register(mux, "/api/v1")
srv := httptest.NewServer(mux)
return srv, func() {
@@ -214,7 +221,9 @@ func TestSearchSignals(t *testing.T) {
resp := get(t, srv, "/api/v1/signals/search?q=sine")
assertStatus(t, resp, http.StatusOK)
var signals []struct{ Name string `json:"name"` }
var signals []struct {
Name string `json:"name"`
}
readJSON(t, resp, &signals)
for _, s := range signals {
@@ -259,7 +268,9 @@ func TestInterfaceCRUD(t *testing.T) {
// Create
resp = postRaw(t, srv, "/api/v1/interfaces", "application/xml", []byte(sampleXML))
assertStatus(t, resp, http.StatusCreated)
var created struct{ ID string `json:"id"` }
var created struct {
ID string `json:"id"`
}
readJSON(t, resp, &created)
if created.ID == "" {
t.Fatal("expected non-empty ID from create")
@@ -303,7 +314,9 @@ func TestInterfaceCRUD(t *testing.T) {
t.Fatal("clone:", err)
}
assertStatus(t, resp, http.StatusCreated)
var cloned struct{ ID string `json:"id"` }
var cloned struct {
ID string `json:"id"`
}
readJSON(t, resp, &cloned)
if cloned.ID == created.ID {
t.Error("clone produced same ID as original")
+446
View File
@@ -0,0 +1,446 @@
package api
import (
"context"
"encoding/json"
"errors"
"net/http"
"strconv"
"time"
"github.com/uopi/uopi/internal/confmgr"
)
// configEnabled guards every config-manager handler; the store is always
// constructed today, but the nil check keeps the handlers safe if it is ever
// made optional.
func (h *Handler) configEnabled(w http.ResponseWriter) bool {
if h.cfg == nil {
jsonError(w, http.StatusServiceUnavailable, "configuration manager not enabled")
return false
}
return true
}
func pathVersion(r *http.Request) (int, error) {
return strconv.Atoi(r.PathValue("version"))
}
func configStatus(err error) int {
if errors.Is(err, confmgr.ErrNotFound) {
return http.StatusNotFound
}
return http.StatusBadRequest
}
// ── config sets ─────────────────────────────────────────────────────────────
func (h *Handler) listConfigSets(w http.ResponseWriter, _ *http.Request) {
if !h.configEnabled(w) {
return
}
sets, err := h.cfg.List(confmgr.KindSet)
if err != nil {
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
jsonOK(w, sets)
}
func (h *Handler) getConfigSet(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
set, err := h.cfg.GetSet(r.PathValue("id"))
if err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
jsonOK(w, set)
}
func (h *Handler) createConfigSet(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
var set confmgr.ConfigSet
if err := json.NewDecoder(r.Body).Decode(&set); err != nil {
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
set.ID = ""
set.Owner = caller(r)
out, err := h.cfg.CreateSet(set, r.URL.Query().Get("tag"))
if err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
h.recordMutation(r, "config.set.create", out.ID+" "+out.Name)
w.WriteHeader(http.StatusCreated)
jsonOK(w, out)
}
func (h *Handler) updateConfigSet(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
id := r.PathValue("id")
var set confmgr.ConfigSet
if err := json.NewDecoder(r.Body).Decode(&set); err != nil {
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
out, err := h.cfg.UpdateSet(id, set, r.URL.Query().Get("tag"))
if err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
h.recordMutation(r, "config.set.update", out.ID+" v"+strconv.Itoa(out.Version))
jsonOK(w, out)
}
func (h *Handler) deleteConfigSet(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
id := r.PathValue("id")
if err := h.cfg.Delete(confmgr.KindSet, id); err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
h.recordMutation(r, "config.set.delete", id)
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) listConfigSetVersions(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
versions, err := h.cfg.Versions(confmgr.KindSet, r.PathValue("id"))
if err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
jsonOK(w, versions)
}
func (h *Handler) getConfigSetVersion(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
version, err := pathVersion(r)
if err != nil {
jsonError(w, http.StatusBadRequest, "invalid version")
return
}
set, err := h.cfg.GetSetVersion(r.PathValue("id"), version)
if err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
jsonOK(w, set)
}
func (h *Handler) promoteConfigSet(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
version, err := pathVersion(r)
if err != nil {
jsonError(w, http.StatusBadRequest, "invalid version")
return
}
id := r.PathValue("id")
if err := h.cfg.Promote(confmgr.KindSet, id, version); err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
h.recordMutation(r, "config.set.promote", id+" v"+strconv.Itoa(version))
set, err := h.cfg.GetSet(id)
if err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
jsonOK(w, set)
}
func (h *Handler) forkConfigSet(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
version, err := pathVersion(r)
if err != nil {
jsonError(w, http.StatusBadRequest, "invalid version")
return
}
id := r.PathValue("id")
newID, err := h.cfg.Fork(confmgr.KindSet, id, version)
if err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
h.recordMutation(r, "config.set.fork", id+" v"+strconv.Itoa(version)+" -> "+newID)
set, err := h.cfg.GetSet(newID)
if err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
w.WriteHeader(http.StatusCreated)
jsonOK(w, set)
}
// diffConfigSets compares two set revisions. Query params: a, b (ids);
// optional av, bv (versions, default current).
func (h *Handler) diffConfigSets(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
q := r.URL.Query()
left, err := h.resolveSet(q.Get("a"), q.Get("av"))
if err != nil {
jsonError(w, configStatus(err), "left set: "+err.Error())
return
}
right, err := h.resolveSet(q.Get("b"), q.Get("bv"))
if err != nil {
jsonError(w, configStatus(err), "right set: "+err.Error())
return
}
jsonOK(w, confmgr.DiffSets(left, right))
}
func (h *Handler) resolveSet(id, version string) (confmgr.ConfigSet, error) {
if id == "" {
return confmgr.ConfigSet{}, errors.New("missing set id")
}
if version == "" {
return h.cfg.GetSet(id)
}
v, err := strconv.Atoi(version)
if err != nil {
return confmgr.ConfigSet{}, errors.New("invalid version")
}
return h.cfg.GetSetVersion(id, v)
}
// ── config instances ────────────────────────────────────────────────────────
func (h *Handler) listConfigInstances(w http.ResponseWriter, _ *http.Request) {
if !h.configEnabled(w) {
return
}
insts, err := h.cfg.List(confmgr.KindInstance)
if err != nil {
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
jsonOK(w, insts)
}
func (h *Handler) getConfigInstance(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
inst, err := h.cfg.GetInstance(r.PathValue("id"))
if err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
jsonOK(w, inst)
}
func (h *Handler) createConfigInstance(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
var inst confmgr.ConfigInstance
if err := json.NewDecoder(r.Body).Decode(&inst); err != nil {
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
inst.ID = ""
inst.Owner = caller(r)
out, err := h.cfg.CreateInstance(inst, r.URL.Query().Get("tag"))
if err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
h.recordMutation(r, "config.instance.create", out.ID+" "+out.Name)
w.WriteHeader(http.StatusCreated)
jsonOK(w, out)
}
func (h *Handler) updateConfigInstance(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
id := r.PathValue("id")
var inst confmgr.ConfigInstance
if err := json.NewDecoder(r.Body).Decode(&inst); err != nil {
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
out, err := h.cfg.UpdateInstance(id, inst, r.URL.Query().Get("tag"))
if err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
h.recordMutation(r, "config.instance.update", out.ID+" v"+strconv.Itoa(out.Version))
jsonOK(w, out)
}
func (h *Handler) deleteConfigInstance(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
id := r.PathValue("id")
if err := h.cfg.Delete(confmgr.KindInstance, id); err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
h.recordMutation(r, "config.instance.delete", id)
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) listConfigInstanceVersions(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
versions, err := h.cfg.Versions(confmgr.KindInstance, r.PathValue("id"))
if err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
jsonOK(w, versions)
}
func (h *Handler) getConfigInstanceVersion(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
version, err := pathVersion(r)
if err != nil {
jsonError(w, http.StatusBadRequest, "invalid version")
return
}
inst, err := h.cfg.GetInstanceVersion(r.PathValue("id"), version)
if err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
jsonOK(w, inst)
}
func (h *Handler) promoteConfigInstance(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
version, err := pathVersion(r)
if err != nil {
jsonError(w, http.StatusBadRequest, "invalid version")
return
}
id := r.PathValue("id")
if err := h.cfg.Promote(confmgr.KindInstance, id, version); err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
h.recordMutation(r, "config.instance.promote", id+" v"+strconv.Itoa(version))
inst, err := h.cfg.GetInstance(id)
if err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
jsonOK(w, inst)
}
func (h *Handler) forkConfigInstance(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
version, err := pathVersion(r)
if err != nil {
jsonError(w, http.StatusBadRequest, "invalid version")
return
}
id := r.PathValue("id")
newID, err := h.cfg.Fork(confmgr.KindInstance, id, version)
if err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
h.recordMutation(r, "config.instance.fork", id+" v"+strconv.Itoa(version)+" -> "+newID)
inst, err := h.cfg.GetInstance(newID)
if err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
w.WriteHeader(http.StatusCreated)
jsonOK(w, inst)
}
// applyConfigInstance writes every resolvable parameter value of an instance to
// its target signal via the broker. Per-parameter outcomes are returned so a
// partial apply is reported faithfully.
func (h *Handler) applyConfigInstance(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
id := r.PathValue("id")
inst, err := h.cfg.GetInstance(id)
if err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
set, err := h.cfg.SetForInstance(inst)
if err != nil {
jsonError(w, configStatus(err), "load set: "+err.Error())
return
}
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
write := func(ds, signal string, value any) error {
src, ok := h.broker.Source(ds)
if !ok {
return errors.New("unknown data source: " + ds)
}
return src.Write(ctx, signal, value)
}
res := confmgr.Apply(set, inst, write)
h.recordMutation(r, "config.instance.apply", id+" applied="+strconv.Itoa(res.Applied)+" failed="+strconv.Itoa(res.Failed))
jsonOK(w, res)
}
// diffConfigInstances compares two instance revisions. Query params: a, b
// (ids); optional av, bv (versions, default current).
func (h *Handler) diffConfigInstances(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
q := r.URL.Query()
left, err := h.resolveInstance(q.Get("a"), q.Get("av"))
if err != nil {
jsonError(w, configStatus(err), "left instance: "+err.Error())
return
}
right, err := h.resolveInstance(q.Get("b"), q.Get("bv"))
if err != nil {
jsonError(w, configStatus(err), "right instance: "+err.Error())
return
}
jsonOK(w, confmgr.DiffInstances(left, right))
}
func (h *Handler) resolveInstance(id, version string) (confmgr.ConfigInstance, error) {
if id == "" {
return confmgr.ConfigInstance{}, errors.New("missing instance id")
}
if version == "" {
return h.cfg.GetInstance(id)
}
v, err := strconv.Atoi(version)
if err != nil {
return confmgr.ConfigInstance{}, errors.New("invalid version")
}
return h.cfg.GetInstanceVersion(id, v)
}
+107
View File
@@ -0,0 +1,107 @@
package api_test
import (
"net/http"
"testing"
)
// TestConfigSetCRUD exercises create → get → version → apply over HTTP, using
// the stub data source's writable "setpoint" signal as the apply target.
func TestConfigManagerEndToEnd(t *testing.T) {
srv, teardown := setup(t)
defer teardown()
// Create a config set targeting the stub's writable setpoint.
setBody := map[string]any{
"name": "Stub PSU",
"parameters": []map[string]any{
{"key": "sp", "ds": "stub", "signal": "setpoint", "type": "float64", "default": 42.0, "mandatory": true, "min": 0.0, "max": 100.0},
},
}
resp := postJSON(t, srv, "/api/v1/config/sets", setBody)
assertStatus(t, resp, http.StatusCreated)
var set struct {
ID string `json:"id"`
Version int `json:"version"`
}
readJSON(t, resp, &set)
if set.ID == "" || set.Version != 1 {
t.Fatalf("unexpected created set: %+v", set)
}
// List sets includes it.
resp = get(t, srv, "/api/v1/config/sets")
assertStatus(t, resp, http.StatusOK)
var sets []map[string]any
readJSON(t, resp, &sets)
if len(sets) != 1 {
t.Fatalf("want 1 set, got %d", len(sets))
}
// Create an instance assigning a concrete value.
instBody := map[string]any{
"name": "nominal",
"setId": set.ID,
"values": map[string]any{"sp": 73.0},
}
resp = postJSON(t, srv, "/api/v1/config/instances", instBody)
assertStatus(t, resp, http.StatusCreated)
var inst struct {
ID string `json:"id"`
}
readJSON(t, resp, &inst)
if inst.ID == "" {
t.Fatal("instance missing id")
}
// Apply writes the value to the target signal.
resp = postJSON(t, srv, "/api/v1/config/instances/"+inst.ID+"/apply", nil)
assertStatus(t, resp, http.StatusOK)
var res struct {
Applied int `json:"applied"`
Failed int `json:"failed"`
Entries []struct {
Signal string `json:"signal"`
OK bool `json:"ok"`
} `json:"entries"`
}
readJSON(t, resp, &res)
if res.Applied != 1 || res.Failed != 0 {
t.Fatalf("apply summary: applied=%d failed=%d", res.Applied, res.Failed)
}
if len(res.Entries) != 1 || res.Entries[0].Signal != "setpoint" || !res.Entries[0].OK {
t.Errorf("unexpected apply entries: %+v", res.Entries)
}
// Delete the set soft-deletes it.
resp = deleteReq(t, srv, "/api/v1/config/sets/"+set.ID)
assertStatus(t, resp, http.StatusNoContent)
resp = get(t, srv, "/api/v1/config/sets/"+set.ID)
assertStatus(t, resp, http.StatusNotFound)
}
// TestConfigInstanceRejectsInvalidValue verifies server-side validation against
// the bound set (value above the parameter's max).
func TestConfigInstanceRejectsInvalidValue(t *testing.T) {
srv, teardown := setup(t)
defer teardown()
resp := postJSON(t, srv, "/api/v1/config/sets", map[string]any{
"name": "S",
"parameters": []map[string]any{
{"key": "sp", "ds": "stub", "signal": "setpoint", "type": "float64", "max": 10.0},
},
})
assertStatus(t, resp, http.StatusCreated)
var set struct {
ID string `json:"id"`
}
readJSON(t, resp, &set)
resp = postJSON(t, srv, "/api/v1/config/instances", map[string]any{
"name": "bad",
"setId": set.ID,
"values": map[string]any{"sp": 999.0},
})
assertStatus(t, resp, http.StatusBadRequest)
}
+65
View File
@@ -0,0 +1,65 @@
// Package audit records system-affecting actions (signal writes by users or the
// control-logic engine, interface and control-logic mutations) to an append-only
// SQLite log that audit staff can query later. It is enabled via [audit] in the
// config; when disabled a no-op Recorder is used so call sites need no guards.
package audit
import "time"
// Actor types distinguish human-initiated actions from automated ones.
const (
ActorUser = "user" // action attributed to an authenticated end-user
ActorSystem = "system" // action performed by the control-logic engine
)
// Outcomes record whether the action succeeded.
const (
OutcomeOK = "ok"
OutcomeError = "error"
)
// Event is a single audit record. Optional fields are omitted from the database
// when empty. Time defaults to the current time when zero.
type Event struct {
Time time.Time `json:"time"`
Actor string `json:"actor"` // username, or graph name for system actions
ActorType string `json:"actorType"` // ActorUser | ActorSystem
Action string `json:"action"` // e.g. "signal.write", "interface.update"
DS string `json:"ds,omitempty"` // data source (signal writes)
Signal string `json:"signal,omitempty"` // signal / target name
Value string `json:"value,omitempty"` // serialised written value
Detail string `json:"detail,omitempty"` // free-form context (id, graph, trigger)
IP string `json:"ip,omitempty"` // client address (user actions)
Outcome string `json:"outcome"` // OutcomeOK | OutcomeError
Error string `json:"error,omitempty"` // message when Outcome==OutcomeError
}
// Filter selects a subset of events for Query. Zero-valued fields are ignored.
type Filter struct {
Start time.Time
End time.Time
Actor string
Action string
DS string
Signal string
Limit int // <=0 means a default cap is applied
}
// Recorder appends events and answers queries. Implementations must be safe for
// concurrent use and Record must never block the caller for long.
type Recorder interface {
Record(Event)
Query(Filter) ([]Event, error)
Close() error
}
// nopRecorder is used when auditing is disabled. It discards every event.
type nopRecorder struct{}
func (nopRecorder) Record(Event) {}
func (nopRecorder) Query(Filter) ([]Event, error) { return []Event{}, nil }
func (nopRecorder) Close() error { return nil }
// Nop returns a Recorder that discards everything. Call sites can hold a Recorder
// unconditionally and call Record without checking whether auditing is enabled.
func Nop() Recorder { return nopRecorder{} }
+183
View File
@@ -0,0 +1,183 @@
package audit
import (
"database/sql"
"fmt"
"log/slog"
"strings"
"sync"
"time"
_ "modernc.org/sqlite" // pure-Go SQLite driver (no CGo, keeps the single static binary)
)
// defaultQueryLimit caps how many rows Query returns when the filter does not
// specify a smaller limit, protecting the API and UI from unbounded result sets.
const defaultQueryLimit = 1000
// writeBuffer is the depth of the async insert queue. When full, Record falls
// back to a synchronous insert so events are never silently dropped.
const writeBuffer = 4096
// sqliteRecorder appends events to a SQLite database. Inserts are normally
// handled by a background goroutine so Record does not block the calling write
// path; the queue has a synchronous fallback to guarantee durability under load.
type sqliteRecorder struct {
db *sql.DB
log *slog.Logger
ch chan Event
wg sync.WaitGroup
closed chan struct{}
once sync.Once
}
// NewSQLite opens (creating if needed) the audit database at path and starts the
// background writer. The returned Recorder must be Closed on shutdown.
func NewSQLite(path string, log *slog.Logger) (Recorder, error) {
// WAL + a busy timeout let the async writer and synchronous query/fallback
// paths share the file without "database is locked" errors.
dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)", path)
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("open audit db: %w", err)
}
if _, err := db.Exec(schema); err != nil {
_ = db.Close()
return nil, fmt.Errorf("init audit schema: %w", err)
}
r := &sqliteRecorder{
db: db,
log: log,
ch: make(chan Event, writeBuffer),
closed: make(chan struct{}),
}
r.wg.Add(1)
go r.writeLoop()
return r, nil
}
const schema = `
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts_ns INTEGER NOT NULL,
actor TEXT NOT NULL,
actor_type TEXT NOT NULL,
action TEXT NOT NULL,
ds TEXT NOT NULL DEFAULT '',
signal TEXT NOT NULL DEFAULT '',
value TEXT NOT NULL DEFAULT '',
detail TEXT NOT NULL DEFAULT '',
ip TEXT NOT NULL DEFAULT '',
outcome TEXT NOT NULL DEFAULT 'ok',
error TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit_log(ts_ns);
CREATE INDEX IF NOT EXISTS idx_audit_actor ON audit_log(actor);
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log(action);
`
func (r *sqliteRecorder) Record(e Event) {
if e.Time.IsZero() {
e.Time = time.Now()
}
if e.Outcome == "" {
e.Outcome = OutcomeOK
}
select {
case <-r.closed:
// Recorder is shutting down; best-effort synchronous insert.
r.insert(e)
case r.ch <- e:
default:
// Queue full: write synchronously rather than drop an audit record.
r.insert(e)
}
}
func (r *sqliteRecorder) writeLoop() {
defer r.wg.Done()
for e := range r.ch {
r.insert(e)
}
}
func (r *sqliteRecorder) insert(e Event) {
_, err := r.db.Exec(
`INSERT INTO audit_log (ts_ns, actor, actor_type, action, ds, signal, value, detail, ip, outcome, error)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
e.Time.UnixNano(), e.Actor, e.ActorType, e.Action,
e.DS, e.Signal, e.Value, e.Detail, e.IP, e.Outcome, e.Error,
)
if err != nil {
r.log.Error("audit: insert failed", "action", e.Action, "err", err)
}
}
func (r *sqliteRecorder) Query(f Filter) ([]Event, error) {
var where []string
var args []any
if !f.Start.IsZero() {
where = append(where, "ts_ns >= ?")
args = append(args, f.Start.UnixNano())
}
if !f.End.IsZero() {
where = append(where, "ts_ns <= ?")
args = append(args, f.End.UnixNano())
}
if f.Actor != "" {
where = append(where, "actor = ?")
args = append(args, f.Actor)
}
if f.Action != "" {
where = append(where, "action = ?")
args = append(args, f.Action)
}
if f.DS != "" {
where = append(where, "ds = ?")
args = append(args, f.DS)
}
if f.Signal != "" {
where = append(where, "signal LIKE ?")
args = append(args, "%"+f.Signal+"%")
}
limit := f.Limit
if limit <= 0 || limit > defaultQueryLimit {
limit = defaultQueryLimit
}
q := "SELECT ts_ns, actor, actor_type, action, ds, signal, value, detail, ip, outcome, error FROM audit_log"
if len(where) > 0 {
q += " WHERE " + strings.Join(where, " AND ")
}
q += " ORDER BY ts_ns DESC LIMIT ?"
args = append(args, limit)
rows, err := r.db.Query(q, args...)
if err != nil {
return nil, fmt.Errorf("query audit log: %w", err)
}
defer rows.Close()
out := []Event{}
for rows.Next() {
var e Event
var tsNs int64
if err := rows.Scan(&tsNs, &e.Actor, &e.ActorType, &e.Action,
&e.DS, &e.Signal, &e.Value, &e.Detail, &e.IP, &e.Outcome, &e.Error); err != nil {
return nil, fmt.Errorf("scan audit row: %w", err)
}
e.Time = time.Unix(0, tsNs)
out = append(out, e)
}
return out, rows.Err()
}
func (r *sqliteRecorder) Close() error {
r.once.Do(func() {
close(r.closed)
close(r.ch)
})
r.wg.Wait()
return r.db.Close()
}
+68
View File
@@ -0,0 +1,68 @@
package audit
import (
"log/slog"
"path/filepath"
"testing"
"time"
)
func TestSQLiteRoundTrip(t *testing.T) {
path := filepath.Join(t.TempDir(), "audit.db")
rec, err := NewSQLite(path, slog.Default())
if err != nil {
t.Fatal("NewSQLite:", err)
}
defer rec.Close()
base := time.Now()
rec.Record(Event{Time: base, Actor: "alice", ActorType: ActorUser, Action: "signal.write", DS: "epics", Signal: "PV:A", Value: "1.5"})
rec.Record(Event{Time: base.Add(time.Second), Actor: "flow1", ActorType: ActorSystem, Action: "signal.write", DS: "epics", Signal: "PV:B", Value: "0"})
rec.Record(Event{Time: base.Add(2 * time.Second), Actor: "bob", ActorType: ActorUser, Action: "interface.update", Detail: "panel-1", Outcome: OutcomeError, Error: "denied"})
// Flush the async writer.
if err := rec.Close(); err != nil {
t.Fatal("Close:", err)
}
rec, err = NewSQLite(path, slog.Default())
if err != nil {
t.Fatal("reopen:", err)
}
defer rec.Close()
all, err := rec.Query(Filter{})
if err != nil {
t.Fatal("Query:", err)
}
if len(all) != 3 {
t.Fatalf("got %d events, want 3", len(all))
}
// Newest first.
if all[0].Actor != "bob" {
t.Errorf("first actor = %q, want bob", all[0].Actor)
}
byActor, err := rec.Query(Filter{Actor: "alice"})
if err != nil {
t.Fatal("Query actor:", err)
}
if len(byActor) != 1 || byActor[0].Signal != "PV:A" {
t.Errorf("actor filter = %+v, want one PV:A event", byActor)
}
byAction, err := rec.Query(Filter{Action: "signal.write"})
if err != nil {
t.Fatal("Query action:", err)
}
if len(byAction) != 2 {
t.Errorf("action filter returned %d, want 2", len(byAction))
}
since, err := rec.Query(Filter{Start: base.Add(1500 * time.Millisecond)})
if err != nil {
t.Fatal("Query start:", err)
}
if len(since) != 1 || since[0].Actor != "bob" {
t.Errorf("time filter = %+v, want one bob event", since)
}
}
+26 -4
View File
@@ -12,10 +12,23 @@ import (
type Config struct {
Server ServerConfig `toml:"server"`
Datasource DatasourceConfig `toml:"datasource"`
Audit AuditConfig `toml:"audit"`
// Groups are named sets of users, referenced by panel sharing rules.
Groups []GroupDef `toml:"groups"`
}
// AuditConfig controls the audit trail. When enabled, every user and automated
// action that could affect the controlled system (signal writes, control-logic
// changes) is recorded to a SQLite database for later review by audit staff.
type AuditConfig struct {
Enabled bool `toml:"enabled"`
DBPath string `toml:"db_path"` // SQLite file; default {storage_dir}/audit.db
// Readers lists users or group names allowed to view the audit log. Empty
// leaves viewing unrestricted (any caller with read access). Anonymous /
// trusted-LAN callers are always permitted.
Readers []string `toml:"readers"`
}
// GroupDef is a named set of users defined as [[groups]] in the config file.
type GroupDef struct {
Name string `toml:"name"`
@@ -71,10 +84,10 @@ type StubConfig struct {
}
type EPICSConfig struct {
Enabled bool `toml:"enabled"`
CAAddrList string `toml:"ca_addr_list"`
ArchiveURL string `toml:"archive_url"`
ChannelFinderURL string `toml:"channel_finder_url"`
Enabled bool `toml:"enabled"`
CAAddrList string `toml:"ca_addr_list"`
ArchiveURL string `toml:"archive_url"`
ChannelFinderURL string `toml:"channel_finder_url"`
AutoSyncFilter string `toml:"auto_sync_filter"`
AutoSyncFromArchiver bool `toml:"auto_sync_from_archiver"`
PVNames []string `toml:"pv_names"`
@@ -141,6 +154,15 @@ func applyEnv(cfg *Config) {
if v := env("UOPI_SERVER_LOGIC_EDITORS"); v != "" {
cfg.Server.LogicEditors = strings.Fields(v)
}
if v := env("UOPI_AUDIT_ENABLED"); v != "" {
cfg.Audit.Enabled = (v == "true" || v == "YES")
}
if v := env("UOPI_AUDIT_DB_PATH"); v != "" {
cfg.Audit.DBPath = v
}
if v := env("UOPI_AUDIT_READERS"); v != "" {
cfg.Audit.Readers = strings.Fields(v)
}
if v := env("UOPI_EPICS_CA_ADDR_LIST"); v != "" {
cfg.Datasource.EPICS.CAAddrList = v
}
+55
View File
@@ -0,0 +1,55 @@
package confmgr
// WriteFunc writes a resolved value to a target signal. The API layer supplies
// a closure backed by the broker/datasource so confmgr stays decoupled from the
// transport.
type WriteFunc func(ds, signal string, value any) error
// ApplyEntry records the outcome of writing one parameter.
type ApplyEntry struct {
Key string `json:"key"`
DS string `json:"ds"`
Signal string `json:"signal"`
Value any `json:"value,omitempty"`
OK bool `json:"ok"`
Skipped bool `json:"skipped,omitempty"`
Error string `json:"error,omitempty"`
}
// ApplyResult summarises an apply run.
type ApplyResult struct {
InstanceID string `json:"instanceId"`
SetID string `json:"setId"`
Entries []ApplyEntry `json:"entries"`
Applied int `json:"applied"`
Failed int `json:"failed"`
Skipped int `json:"skipped"`
}
// Apply writes every resolvable parameter value of an instance to its target
// signal via write. Optional parameters with no value and no default are
// skipped. Individual write failures are recorded per-entry rather than
// aborting the whole apply, so a partial apply is reported faithfully.
func Apply(set ConfigSet, inst ConfigInstance, write WriteFunc) ApplyResult {
res := ApplyResult{InstanceID: inst.ID, SetID: set.ID, Entries: make([]ApplyEntry, 0, len(set.Parameters))}
for _, p := range set.Parameters {
e := ApplyEntry{Key: p.Key, DS: p.DS, Signal: p.Signal}
v, ok := inst.Resolve(p)
if !ok {
e.Skipped = true
res.Skipped++
res.Entries = append(res.Entries, e)
continue
}
e.Value = v
if err := write(p.DS, p.Signal, v); err != nil {
e.Error = err.Error()
res.Failed++
} else {
e.OK = true
res.Applied++
}
res.Entries = append(res.Entries, e)
}
return res
}
+77
View File
@@ -0,0 +1,77 @@
package confmgr
import (
"errors"
"testing"
)
func TestApplyWritesValues(t *testing.T) {
set := ConfigSet{
ID: "set1",
Name: "s",
Parameters: []Parameter{
{Key: "v", DS: "epics", Signal: "PSU:V", Type: TypeFloat, Default: 12.0},
{Key: "en", DS: "epics", Signal: "PSU:EN", Type: TypeBool},
{Key: "opt", DS: "epics", Signal: "PSU:OPT", Type: TypeFloat}, // no value, no default → skipped
},
}
inst := ConfigInstance{ID: "i1", SetID: "set1", Values: map[string]any{"v": 24.0, "en": true}}
type write struct {
ds, sig string
val any
}
var writes []write
res := Apply(set, inst, func(ds, signal string, value any) error {
writes = append(writes, write{ds, signal, value})
return nil
})
if res.Applied != 2 || res.Skipped != 1 || res.Failed != 0 {
t.Fatalf("summary: applied=%d skipped=%d failed=%d", res.Applied, res.Skipped, res.Failed)
}
if len(writes) != 2 {
t.Fatalf("want 2 writes, got %d", len(writes))
}
if writes[0].sig != "PSU:V" || writes[0].val != 24.0 {
t.Errorf("first write: %+v", writes[0])
}
}
func TestApplyUsesDefaultWhenNoValue(t *testing.T) {
set := ConfigSet{
ID: "set1", Name: "s",
Parameters: []Parameter{{Key: "v", DS: "d", Signal: "S", Type: TypeFloat, Default: 7.0}},
}
inst := ConfigInstance{ID: "i", SetID: "set1", Values: map[string]any{}}
var got any
res := Apply(set, inst, func(_, _ string, value any) error { got = value; return nil })
if res.Applied != 1 || got != 7.0 {
t.Errorf("want default 7.0 applied, got %v (applied=%d)", got, res.Applied)
}
}
func TestApplyRecordsFailures(t *testing.T) {
set := ConfigSet{
ID: "set1", Name: "s",
Parameters: []Parameter{
{Key: "a", DS: "d", Signal: "A", Type: TypeFloat, Default: 1.0},
{Key: "b", DS: "d", Signal: "B", Type: TypeFloat, Default: 2.0},
},
}
inst := ConfigInstance{ID: "i", SetID: "set1", Values: map[string]any{}}
res := Apply(set, inst, func(_, signal string, _ any) error {
if signal == "B" {
return errors.New("write rejected")
}
return nil
})
if res.Applied != 1 || res.Failed != 1 {
t.Fatalf("want applied=1 failed=1, got applied=%d failed=%d", res.Applied, res.Failed)
}
for _, e := range res.Entries {
if e.Key == "b" && (e.OK || e.Error == "") {
t.Errorf("entry b should record failure: %+v", e)
}
}
}
+140
View File
@@ -0,0 +1,140 @@
package confmgr
import (
"fmt"
"sort"
)
// ChangeStatus classifies a single diff entry.
type ChangeStatus string
const (
StatusAdded ChangeStatus = "added"
StatusRemoved ChangeStatus = "removed"
StatusChanged ChangeStatus = "changed"
StatusUnchanged ChangeStatus = "unchanged"
)
// SetDiffEntry is one parameter-level difference between two config sets.
type SetDiffEntry struct {
Key string `json:"key"`
Status ChangeStatus `json:"status"`
Left *Parameter `json:"left,omitempty"`
Right *Parameter `json:"right,omitempty"`
}
// InstanceDiffEntry is one value-level difference between two config instances.
type InstanceDiffEntry struct {
Key string `json:"key"`
Status ChangeStatus `json:"status"`
Left any `json:"left,omitempty"`
Right any `json:"right,omitempty"`
}
// DiffSets compares two config sets parameter-by-parameter (keyed by Key),
// returning entries sorted by key. Both sides are included so the frontend can
// render either unified or side-by-side.
func DiffSets(left, right ConfigSet) []SetDiffEntry {
keys := map[string]bool{}
li := map[string]Parameter{}
ri := map[string]Parameter{}
for _, p := range left.Parameters {
li[p.Key] = p
keys[p.Key] = true
}
for _, p := range right.Parameters {
ri[p.Key] = p
keys[p.Key] = true
}
out := make([]SetDiffEntry, 0, len(keys))
for k := range keys {
l, lok := li[k]
r, rok := ri[k]
e := SetDiffEntry{Key: k}
switch {
case lok && !rok:
e.Status = StatusRemoved
lp := l
e.Left = &lp
case !lok && rok:
e.Status = StatusAdded
rp := r
e.Right = &rp
default:
lp, rp := l, r
e.Left, e.Right = &lp, &rp
if paramEqual(l, r) {
e.Status = StatusUnchanged
} else {
e.Status = StatusChanged
}
}
out = append(out, e)
}
sort.Slice(out, func(i, j int) bool { return out[i].Key < out[j].Key })
return out
}
// DiffInstances compares two config instances value-by-value (keyed by
// parameter key), returning entries sorted by key.
func DiffInstances(left, right ConfigInstance) []InstanceDiffEntry {
keys := map[string]bool{}
for k := range left.Values {
keys[k] = true
}
for k := range right.Values {
keys[k] = true
}
out := make([]InstanceDiffEntry, 0, len(keys))
for k := range keys {
lv, lok := left.Values[k]
rv, rok := right.Values[k]
e := InstanceDiffEntry{Key: k, Left: lv, Right: rv}
switch {
case lok && !rok:
e.Status = StatusRemoved
case !lok && rok:
e.Status = StatusAdded
case valueEqual(lv, rv):
e.Status = StatusUnchanged
default:
e.Status = StatusChanged
}
out = append(out, e)
}
sort.Slice(out, func(i, j int) bool { return out[i].Key < out[j].Key })
return out
}
func paramEqual(a, b Parameter) bool {
if a.Label != b.Label || a.Group != b.Group || a.Subgroup != b.Subgroup ||
a.DS != b.DS || a.Signal != b.Signal || a.Type != b.Type ||
a.Mandatory != b.Mandatory || a.Unit != b.Unit || a.Description != b.Description {
return false
}
if !valueEqual(a.Default, b.Default) || !floatPtrEqual(a.Min, b.Min) || !floatPtrEqual(a.Max, b.Max) {
return false
}
if len(a.EnumValues) != len(b.EnumValues) {
return false
}
for i := range a.EnumValues {
if a.EnumValues[i] != b.EnumValues[i] {
return false
}
}
return true
}
func floatPtrEqual(a, b *float64) bool {
if a == nil || b == nil {
return a == b
}
return *a == *b
}
// valueEqual compares two JSON-decoded scalar values for diff purposes by
// rendering them to a canonical string.
func valueEqual(a, b any) bool {
return fmt.Sprintf("%v", a) == fmt.Sprintf("%v", b)
}
+55
View File
@@ -0,0 +1,55 @@
package confmgr
import "testing"
func TestDiffSets(t *testing.T) {
left := ConfigSet{Parameters: []Parameter{
{Key: "a", DS: "d", Signal: "A", Type: TypeFloat, Default: 1.0},
{Key: "b", DS: "d", Signal: "B", Type: TypeFloat},
}}
right := ConfigSet{Parameters: []Parameter{
{Key: "a", DS: "d", Signal: "A", Type: TypeFloat, Default: 2.0}, // changed default
{Key: "c", DS: "d", Signal: "C", Type: TypeFloat}, // added
}}
diff := DiffSets(left, right)
got := map[string]ChangeStatus{}
for _, e := range diff {
got[e.Key] = e.Status
}
if got["a"] != StatusChanged {
t.Errorf("a: want changed, got %s", got["a"])
}
if got["b"] != StatusRemoved {
t.Errorf("b: want removed, got %s", got["b"])
}
if got["c"] != StatusAdded {
t.Errorf("c: want added, got %s", got["c"])
}
}
func TestDiffSetsUnchanged(t *testing.T) {
p := Parameter{Key: "a", DS: "d", Signal: "A", Type: TypeFloat, Default: 1.0}
diff := DiffSets(ConfigSet{Parameters: []Parameter{p}}, ConfigSet{Parameters: []Parameter{p}})
if len(diff) != 1 || diff[0].Status != StatusUnchanged {
t.Errorf("want single unchanged entry, got %+v", diff)
}
}
func TestDiffInstances(t *testing.T) {
left := ConfigInstance{Values: map[string]any{"a": 1.0, "b": "x"}}
right := ConfigInstance{Values: map[string]any{"a": 2.0, "c": true}}
diff := DiffInstances(left, right)
got := map[string]ChangeStatus{}
for _, e := range diff {
got[e.Key] = e.Status
}
if got["a"] != StatusChanged {
t.Errorf("a: want changed, got %s", got["a"])
}
if got["b"] != StatusRemoved {
t.Errorf("b: want removed, got %s", got["b"])
}
if got["c"] != StatusAdded {
t.Errorf("c: want added, got %s", got["c"])
}
}
+225
View File
@@ -0,0 +1,225 @@
// Package confmgr implements the configuration manager: versioned, git-style
// configuration Sets (schemas of parameters bound to target signals) and
// configuration Instances (concrete values for a set), persisted as versioned
// JSON files. Applying an instance writes each value to its target signal.
package confmgr
import (
"fmt"
"math"
"slices"
"strconv"
"strings"
)
// ParamType enumerates the value kinds a parameter may hold.
type ParamType string
const (
TypeFloat ParamType = "float64"
TypeInt ParamType = "int64"
TypeBool ParamType = "bool"
TypeString ParamType = "string"
TypeEnum ParamType = "enum"
)
func (t ParamType) valid() bool {
switch t {
case TypeFloat, TypeInt, TypeBool, TypeString, TypeEnum:
return true
}
return false
}
// Parameter is one entry in a configuration set's schema. It names a target
// signal (DS + Signal), a value type, an optional default, and validation
// metadata. Parameters may be grouped for presentation via Group/Subgroup.
type Parameter struct {
Key string `json:"key"` // unique within the set
Label string `json:"label,omitempty"` // human-friendly name
Group string `json:"group,omitempty"` // top-level grouping
Subgroup string `json:"subgroup,omitempty"`
DS string `json:"ds"` // target data source
Signal string `json:"signal"` // target signal name
Type ParamType `json:"type"` // value kind
Default any `json:"default,omitempty"`
Mandatory bool `json:"mandatory,omitempty"`
Min *float64 `json:"min,omitempty"` // numeric lower bound
Max *float64 `json:"max,omitempty"` // numeric upper bound
EnumValues []string `json:"enumValues,omitempty"` // allowed values for enum
Unit string `json:"unit,omitempty"`
Description string `json:"description,omitempty"`
}
// ConfigSet is the schema half of the two-tier model: an ordered list of
// parameters bound to target signals. It is versioned git-style.
type ConfigSet struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Version int `json:"version"`
Tag string `json:"tag,omitempty"`
Owner string `json:"owner,omitempty"`
Parameters []Parameter `json:"parameters"`
}
// ConfigInstance is the value half: concrete values for a set's parameters,
// keyed by parameter key. SetID pins the schema it belongs to; SetVersion pins
// the schema revision (0 means "track current"). It is versioned git-style.
type ConfigInstance struct {
ID string `json:"id"`
Name string `json:"name"`
SetID string `json:"setId"`
SetVersion int `json:"setVersion,omitempty"` // 0 = current set version
Version int `json:"version"`
Tag string `json:"tag,omitempty"`
Owner string `json:"owner,omitempty"`
Values map[string]any `json:"values"`
}
// Validate checks structural invariants of a config set: non-empty name,
// unique non-empty parameter keys, valid types, a target signal per parameter,
// and well-formed enum/default metadata.
func (s ConfigSet) Validate() error {
if strings.TrimSpace(s.Name) == "" {
return fmt.Errorf("config set name must not be empty")
}
seen := make(map[string]bool, len(s.Parameters))
for i, p := range s.Parameters {
if strings.TrimSpace(p.Key) == "" {
return fmt.Errorf("parameter %d: key must not be empty", i)
}
if seen[p.Key] {
return fmt.Errorf("duplicate parameter key %q", p.Key)
}
seen[p.Key] = true
if !p.Type.valid() {
return fmt.Errorf("parameter %q: invalid type %q", p.Key, p.Type)
}
if strings.TrimSpace(p.DS) == "" || strings.TrimSpace(p.Signal) == "" {
return fmt.Errorf("parameter %q: target ds and signal are required", p.Key)
}
if p.Type == TypeEnum && len(p.EnumValues) == 0 {
return fmt.Errorf("parameter %q: enum type requires enumValues", p.Key)
}
if p.Min != nil && p.Max != nil && *p.Min > *p.Max {
return fmt.Errorf("parameter %q: min %v greater than max %v", p.Key, *p.Min, *p.Max)
}
if p.Default != nil {
if err := p.checkValue(p.Default); err != nil {
return fmt.Errorf("parameter %q default: %w", p.Key, err)
}
}
}
return nil
}
// param returns the parameter with the given key, or false.
func (s ConfigSet) param(key string) (Parameter, bool) {
for _, p := range s.Parameters {
if p.Key == key {
return p, true
}
}
return Parameter{}, false
}
// ValidateAgainst checks an instance against its set: every value references a
// known parameter and is type/range valid; every mandatory parameter without a
// default has a value.
func (inst ConfigInstance) ValidateAgainst(set ConfigSet) error {
for key, v := range inst.Values {
p, ok := set.param(key)
if !ok {
return fmt.Errorf("value for unknown parameter %q", key)
}
if err := p.checkValue(v); err != nil {
return fmt.Errorf("parameter %q: %w", key, err)
}
}
for _, p := range set.Parameters {
if !p.Mandatory {
continue
}
if _, ok := inst.Values[p.Key]; ok {
continue
}
if p.Default == nil {
return fmt.Errorf("mandatory parameter %q has no value", p.Key)
}
}
return nil
}
// Resolve returns the effective value for a parameter: the instance value when
// present, otherwise the parameter default. The second result is false when no
// value is available (optional parameter, no default).
func (inst ConfigInstance) Resolve(p Parameter) (any, bool) {
if v, ok := inst.Values[p.Key]; ok {
return v, true
}
if p.Default != nil {
return p.Default, true
}
return nil, false
}
// checkValue verifies a value matches the parameter's type and constraints.
func (p Parameter) checkValue(v any) error {
switch p.Type {
case TypeFloat, TypeInt:
f, err := toFloat(v)
if err != nil {
return err
}
if p.Type == TypeInt && f != math.Trunc(f) {
return fmt.Errorf("value %v is not an integer", f)
}
if p.Min != nil && f < *p.Min {
return fmt.Errorf("value %v below minimum %v", f, *p.Min)
}
if p.Max != nil && f > *p.Max {
return fmt.Errorf("value %v above maximum %v", f, *p.Max)
}
case TypeBool:
if _, ok := v.(bool); !ok {
return fmt.Errorf("value %v is not a bool", v)
}
case TypeString:
if _, ok := v.(string); !ok {
return fmt.Errorf("value %v is not a string", v)
}
case TypeEnum:
s, ok := v.(string)
if !ok {
return fmt.Errorf("enum value %v is not a string", v)
}
if !slices.Contains(p.EnumValues, s) {
return fmt.Errorf("value %q is not an allowed enum value", s)
}
}
return nil
}
// toFloat coerces a JSON-decoded numeric value to float64. JSON unmarshalling
// yields float64 for numbers, but values may also arrive as int or string.
func toFloat(v any) (float64, error) {
switch n := v.(type) {
case float64:
return n, nil
case float32:
return float64(n), nil
case int:
return float64(n), nil
case int64:
return float64(n), nil
case string:
f, err := strconv.ParseFloat(n, 64)
if err != nil {
return 0, fmt.Errorf("value %q is not numeric", n)
}
return f, nil
default:
return 0, fmt.Errorf("value %v is not numeric", v)
}
}
+550
View File
@@ -0,0 +1,550 @@
package confmgr
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"unicode"
)
// ErrNotFound is returned when the requested set or instance does not exist.
var ErrNotFound = errors.New("config object not found")
// Kind selects which collection a store operation targets.
type Kind int
const (
KindSet Kind = iota
KindInstance
)
func (k Kind) sub() string {
if k == KindInstance {
return "instances"
}
return "sets"
}
// Meta is the lightweight listing representation.
type Meta struct {
ID string `json:"id"`
Name string `json:"name"`
Version int `json:"version"`
}
// VersionMeta describes a single persisted revision.
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 configuration sets and instances as versioned JSON files
// under {storageDir}/configs/{sets,instances}/. Each object lives in
// {id}.json with prior revisions preserved as {id}.v{N}.json backups, exactly
// mirroring the panel storage versioning scheme.
type Store struct {
rootDir string
dirs map[Kind]string
}
// New opens (and creates, if needed) the configs storage directories.
func New(storageDir string) (*Store, error) {
s := &Store{rootDir: storageDir, dirs: map[Kind]string{}}
for _, k := range []Kind{KindSet, KindInstance} {
dir := filepath.Join(storageDir, "configs", k.sub())
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, fmt.Errorf("create %s dir: %w", k.sub(), err)
}
s.dirs[k] = dir
}
return s, nil
}
// header parses the metadata fields shared by sets and instances.
type header struct {
ID string `json:"id"`
Name string `json:"name"`
Version int `json:"version"`
Tag string `json:"tag"`
}
func validateID(id string) error {
if id == "" {
return fmt.Errorf("config ID must not be empty")
}
for _, r := range id {
if !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '-' && r != '_' {
return fmt.Errorf("invalid character %q in config ID", r)
}
}
return nil
}
func (s *Store) filePath(k Kind, id string) string {
return filepath.Join(s.dirs[k], id+".json")
}
func (s *Store) backupPath(k Kind, id string, version int) string {
return filepath.Join(s.dirs[k], fmt.Sprintf("%s.v%d.json", id, version))
}
// isVersioned reports whether name matches <id>.v<number>.json.
func isVersioned(name string) bool {
parts := strings.Split(name, ".")
if len(parts) != 3 || parts[2] != "json" {
return false
}
if !strings.HasPrefix(parts[1], "v") {
return false
}
_, err := strconv.Atoi(parts[1][1:])
return err == nil
}
func readHeader(data []byte) (header, error) {
var h header
if err := json.Unmarshal(data, &h); err != nil {
return header{}, err
}
return h, nil
}
// List returns metadata for every stored object of the given kind.
func (s *Store) List(k Kind) ([]Meta, error) {
entries, err := os.ReadDir(s.dirs[k])
if err != nil {
return nil, err
}
out := []Meta{}
for _, e := range entries {
name := e.Name()
if e.IsDir() || !strings.HasSuffix(name, ".json") || isVersioned(name) {
continue
}
id := strings.TrimSuffix(name, ".json")
data, err := os.ReadFile(s.filePath(k, id))
if err != nil {
continue
}
h, err := readHeader(data)
if err != nil {
continue
}
out = append(out, Meta{ID: id, Name: h.Name, Version: h.Version})
}
return out, nil
}
// get returns the raw JSON bytes for the current revision.
func (s *Store) get(k Kind, id string) ([]byte, error) {
if err := validateID(id); err != nil {
return nil, fmt.Errorf("%w: %v", ErrNotFound, err)
}
data, err := os.ReadFile(s.filePath(k, id))
if errors.Is(err, os.ErrNotExist) {
return nil, ErrNotFound
}
return data, err
}
// create writes a new object, deriving a unique ID from name when obj has none,
// stamping version=1 and the given tag. It returns the raw stored bytes.
func (s *Store) create(k Kind, obj map[string]any, tag string) (string, []byte, error) {
id, _ := obj["id"].(string)
if id == "" {
id = slugify(name(obj))
}
if err := validateID(id); err != nil {
return "", nil, err
}
if _, err := os.Stat(s.filePath(k, id)); err == nil {
id = id + "-" + strconv.FormatInt(time.Now().UnixMilli(), 10)
}
obj["id"] = id
obj["version"] = 1
if tag != "" {
obj["tag"] = tag
}
data, err := marshal(obj)
if err != nil {
return "", nil, err
}
return id, data, os.WriteFile(s.filePath(k, id), data, 0o644)
}
// update replaces the current revision, preserving the prior one as a backup
// and incrementing the version. It returns the raw stored bytes.
func (s *Store) update(k Kind, id string, obj map[string]any, tag string) ([]byte, error) {
if err := validateID(id); err != nil {
return nil, ErrNotFound
}
oldData, err := os.ReadFile(s.filePath(k, id))
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, ErrNotFound
}
return nil, err
}
oldHdr, err := readHeader(oldData)
if err != nil {
return nil, fmt.Errorf("parse existing JSON: %w", err)
}
if oldHdr.Version < 1 {
oldHdr.Version = 1
}
if err := os.WriteFile(s.backupPath(k, id, oldHdr.Version), oldData, 0o644); err != nil {
return nil, fmt.Errorf("create backup: %w", err)
}
obj["id"] = id
obj["version"] = oldHdr.Version + 1
if tag != "" {
obj["tag"] = tag
}
data, err := marshal(obj)
if err != nil {
return nil, err
}
return data, os.WriteFile(s.filePath(k, id), data, 0o644)
}
// Versions returns metadata for every persisted revision, newest-first.
func (s *Store) Versions(k Kind, id string) ([]VersionMeta, error) {
if err := validateID(id); err != nil {
return nil, ErrNotFound
}
curInfo, err := os.Stat(s.filePath(k, id))
if errors.Is(err, os.ErrNotExist) {
return nil, ErrNotFound
} else if err != nil {
return nil, err
}
curData, err := os.ReadFile(s.filePath(k, id))
if err != nil {
return nil, err
}
curHdr, err := readHeader(curData)
if err != nil {
return nil, err
}
out := []VersionMeta{{
Version: curHdr.Version,
Name: curHdr.Name,
Tag: curHdr.Tag,
Current: true,
SavedAt: curInfo.ModTime(),
}}
entries, err := os.ReadDir(s.dirs[k])
if err != nil {
return nil, err
}
prefix := id + ".v"
for _, e := range entries {
name := e.Name()
if e.IsDir() || !strings.HasPrefix(name, prefix) || !isVersioned(name) {
continue
}
info, err := e.Info()
if err != nil {
continue
}
data, err := os.ReadFile(filepath.Join(s.dirs[k], name))
if err != nil {
continue
}
h, err := readHeader(data)
if err != nil {
continue
}
out = append(out, VersionMeta{
Version: h.Version,
Name: h.Name,
Tag: h.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 JSON bytes for a specific revision.
func (s *Store) getVersion(k Kind, id string, version int) ([]byte, error) {
if err := validateID(id); err != nil {
return nil, ErrNotFound
}
curData, err := os.ReadFile(s.filePath(k, id))
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, ErrNotFound
}
return nil, err
}
if h, err := readHeader(curData); err == nil && h.Version == version {
return curData, nil
}
data, err := os.ReadFile(s.backupPath(k, id, version))
if errors.Is(err, os.ErrNotExist) {
return nil, ErrNotFound
}
return data, err
}
// Promote re-saves a past revision as a new revision on top of history.
func (s *Store) Promote(k Kind, id string, version int) error {
data, err := s.getVersion(k, id, version)
if err != nil {
return err
}
obj, err := unmarshal(data)
if err != nil {
return err
}
_, err = s.update(k, id, obj, fmt.Sprintf("restored from v%d", version))
return err
}
// Fork creates a brand-new object from a revision, with a fresh ID and version 1.
func (s *Store) Fork(k Kind, id string, version int) (string, error) {
data, err := s.getVersion(k, id, version)
if err != nil {
return "", err
}
obj, err := unmarshal(data)
if err != nil {
return "", err
}
newID := id + "-fork-" + strconv.FormatInt(time.Now().UnixMilli(), 10)
obj["id"] = newID
obj["version"] = 1
delete(obj, "tag")
out, err := marshal(obj)
if err != nil {
return "", err
}
return newID, os.WriteFile(s.filePath(k, newID), out, 0o644)
}
// Delete moves an object and all its backups to a timestamped trash folder so
// it can be recovered. Nothing is destroyed.
func (s *Store) Delete(k Kind, id string) error {
if err := validateID(id); err != nil {
return ErrNotFound
}
if _, err := os.Stat(s.filePath(k, id)); err != nil {
if errors.Is(err, os.ErrNotExist) {
return ErrNotFound
}
return err
}
trashDir := filepath.Join(s.rootDir, "trash", "configs", k.sub(), fmt.Sprintf("%s.%d", id, time.Now().UnixMilli()))
if err := os.MkdirAll(trashDir, 0o755); err != nil {
return fmt.Errorf("create trash dir: %w", err)
}
entries, err := os.ReadDir(s.dirs[k])
if err != nil {
return err
}
for _, e := range entries {
name := e.Name()
if e.IsDir() {
continue
}
if name == id+".json" || (strings.HasPrefix(name, id+".v") && isVersioned(name)) {
if err := os.Rename(filepath.Join(s.dirs[k], name), filepath.Join(trashDir, name)); err != nil {
return fmt.Errorf("move %s to trash: %w", name, err)
}
}
}
return nil
}
// ── typed wrappers ──────────────────────────────────────────────────────────
// GetSet returns the current revision of a config set.
func (s *Store) GetSet(id string) (ConfigSet, error) {
data, err := s.get(KindSet, id)
if err != nil {
return ConfigSet{}, err
}
var set ConfigSet
return set, json.Unmarshal(data, &set)
}
// GetSetVersion returns a specific revision of a config set.
func (s *Store) GetSetVersion(id string, version int) (ConfigSet, error) {
data, err := s.getVersion(KindSet, id, version)
if err != nil {
return ConfigSet{}, err
}
var set ConfigSet
return set, json.Unmarshal(data, &set)
}
// CreateSet validates and stores a new config set, returning it with its
// assigned ID and version.
func (s *Store) CreateSet(set ConfigSet, tag string) (ConfigSet, error) {
if err := set.Validate(); err != nil {
return ConfigSet{}, err
}
obj, err := toMap(set)
if err != nil {
return ConfigSet{}, err
}
_, data, err := s.create(KindSet, obj, tag)
if err != nil {
return ConfigSet{}, err
}
var out ConfigSet
return out, json.Unmarshal(data, &out)
}
// UpdateSet validates and stores a new revision of an existing config set.
func (s *Store) UpdateSet(id string, set ConfigSet, tag string) (ConfigSet, error) {
if err := set.Validate(); err != nil {
return ConfigSet{}, err
}
obj, err := toMap(set)
if err != nil {
return ConfigSet{}, err
}
data, err := s.update(KindSet, id, obj, tag)
if err != nil {
return ConfigSet{}, err
}
var out ConfigSet
return out, json.Unmarshal(data, &out)
}
// GetInstance returns the current revision of a config instance.
func (s *Store) GetInstance(id string) (ConfigInstance, error) {
data, err := s.get(KindInstance, id)
if err != nil {
return ConfigInstance{}, err
}
var inst ConfigInstance
return inst, json.Unmarshal(data, &inst)
}
// GetInstanceVersion returns a specific revision of a config instance.
func (s *Store) GetInstanceVersion(id string, version int) (ConfigInstance, error) {
data, err := s.getVersion(KindInstance, id, version)
if err != nil {
return ConfigInstance{}, err
}
var inst ConfigInstance
return inst, json.Unmarshal(data, &inst)
}
// setForInstance loads the schema an instance is bound to, honouring a pinned
// SetVersion when set.
func (s *Store) setForInstance(inst ConfigInstance) (ConfigSet, error) {
if inst.SetVersion > 0 {
return s.GetSetVersion(inst.SetID, inst.SetVersion)
}
return s.GetSet(inst.SetID)
}
// CreateInstance validates an instance against its set and stores it.
func (s *Store) CreateInstance(inst ConfigInstance, tag string) (ConfigInstance, error) {
set, err := s.setForInstance(inst)
if err != nil {
return ConfigInstance{}, fmt.Errorf("load set %q: %w", inst.SetID, err)
}
if err := inst.ValidateAgainst(set); err != nil {
return ConfigInstance{}, err
}
obj, err := toMap(inst)
if err != nil {
return ConfigInstance{}, err
}
_, data, err := s.create(KindInstance, obj, tag)
if err != nil {
return ConfigInstance{}, err
}
var out ConfigInstance
return out, json.Unmarshal(data, &out)
}
// UpdateInstance validates and stores a new revision of an existing instance.
func (s *Store) UpdateInstance(id string, inst ConfigInstance, tag string) (ConfigInstance, error) {
set, err := s.setForInstance(inst)
if err != nil {
return ConfigInstance{}, fmt.Errorf("load set %q: %w", inst.SetID, err)
}
if err := inst.ValidateAgainst(set); err != nil {
return ConfigInstance{}, err
}
obj, err := toMap(inst)
if err != nil {
return ConfigInstance{}, err
}
data, err := s.update(KindInstance, id, obj, tag)
if err != nil {
return ConfigInstance{}, err
}
var out ConfigInstance
return out, json.Unmarshal(data, &out)
}
// SetForInstance is the exported loader used by apply/diff callers.
func (s *Store) SetForInstance(inst ConfigInstance) (ConfigSet, error) {
return s.setForInstance(inst)
}
// ── JSON helpers ────────────────────────────────────────────────────────────
func name(obj map[string]any) string {
n, _ := obj["name"].(string)
return n
}
func marshal(obj map[string]any) ([]byte, error) {
return json.MarshalIndent(obj, "", " ")
}
func unmarshal(data []byte) (map[string]any, error) {
var obj map[string]any
if err := json.Unmarshal(data, &obj); err != nil {
return nil, err
}
return obj, nil
}
// toMap round-trips a typed value through JSON into a generic map so the store
// can stamp id/version/tag without knowing the concrete type.
func toMap(v any) (map[string]any, error) {
data, err := json.Marshal(v)
if err != nil {
return nil, err
}
return unmarshal(data)
}
// slugify converts a human-readable name into a URL-safe identifier.
func slugify(s string) string {
var b strings.Builder
for _, r := range strings.ToLower(s) {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
b.WriteRune(r)
default:
if b.Len() > 0 && b.String()[b.Len()-1] != '-' {
b.WriteByte('-')
}
}
}
result := strings.Trim(b.String(), "-")
if result == "" {
return "config"
}
return result
}
+200
View File
@@ -0,0 +1,200 @@
package confmgr
import (
"testing"
)
func fptr(f float64) *float64 { return &f }
func sampleSet() ConfigSet {
return ConfigSet{
Name: "PSU",
Description: "power supply config",
Parameters: []Parameter{
{Key: "voltage", DS: "epics", Signal: "PSU:V", Type: TypeFloat, Default: 12.0, Mandatory: true, Min: fptr(0), Max: fptr(48)},
{Key: "enabled", DS: "epics", Signal: "PSU:EN", Type: TypeBool, Default: false},
},
}
}
func TestCreateAndGetSet(t *testing.T) {
s, err := New(t.TempDir())
if err != nil {
t.Fatal(err)
}
out, err := s.CreateSet(sampleSet(), "initial")
if err != nil {
t.Fatal(err)
}
if out.ID == "" {
t.Fatal("expected generated ID")
}
if out.Version != 1 {
t.Errorf("version: want 1, got %d", out.Version)
}
got, err := s.GetSet(out.ID)
if err != nil {
t.Fatal(err)
}
if got.Name != "PSU" || len(got.Parameters) != 2 {
t.Errorf("round-trip mismatch: %+v", got)
}
}
func TestSetVersioning(t *testing.T) {
s, _ := New(t.TempDir())
created, _ := s.CreateSet(sampleSet(), "")
id := created.ID
// Update -> v2.
upd := sampleSet()
upd.Description = "edited"
v2, err := s.UpdateSet(id, upd, "edit")
if err != nil {
t.Fatal(err)
}
if v2.Version != 2 {
t.Fatalf("version after update: want 2, got %d", v2.Version)
}
versions, err := s.Versions(KindSet, id)
if err != nil {
t.Fatal(err)
}
if len(versions) != 2 {
t.Fatalf("want 2 versions, got %d", len(versions))
}
if !versions[0].Current || versions[0].Version != 2 {
t.Errorf("newest should be current v2: %+v", versions[0])
}
// Old version still retrievable.
old, err := s.GetSetVersion(id, 1)
if err != nil {
t.Fatal(err)
}
if old.Description != "power supply config" {
t.Errorf("v1 description changed: %q", old.Description)
}
// Promote v1 -> becomes v3 with original content.
if err := s.Promote(KindSet, id, 1); err != nil {
t.Fatal(err)
}
cur, _ := s.GetSet(id)
if cur.Version != 3 || cur.Description != "power supply config" {
t.Errorf("after promote: want v3 original desc, got v%d %q", cur.Version, cur.Description)
}
}
func TestForkSet(t *testing.T) {
s, _ := New(t.TempDir())
created, _ := s.CreateSet(sampleSet(), "")
_, _ = s.UpdateSet(created.ID, sampleSet(), "")
newID, err := s.Fork(KindSet, created.ID, 1)
if err != nil {
t.Fatal(err)
}
forked, err := s.GetSet(newID)
if err != nil {
t.Fatal(err)
}
if forked.ID == created.ID {
t.Error("fork should have a new ID")
}
if forked.Version != 1 {
t.Errorf("fork version: want 1, got %d", forked.Version)
}
if forked.Tag != "" {
t.Errorf("fork tag should be cleared, got %q", forked.Tag)
}
}
func TestDeleteSetSoftDeletes(t *testing.T) {
s, _ := New(t.TempDir())
created, _ := s.CreateSet(sampleSet(), "")
if err := s.Delete(KindSet, created.ID); err != nil {
t.Fatal(err)
}
if _, err := s.GetSet(created.ID); err == nil {
t.Error("expected set to be gone after delete")
}
// Deleting again is a not-found.
if err := s.Delete(KindSet, created.ID); err != ErrNotFound {
t.Errorf("want ErrNotFound, got %v", err)
}
}
func TestSetValidation(t *testing.T) {
s, _ := New(t.TempDir())
bad := ConfigSet{Name: "", Parameters: nil}
if _, err := s.CreateSet(bad, ""); err == nil {
t.Error("expected empty-name validation error")
}
dup := ConfigSet{Name: "x", Parameters: []Parameter{
{Key: "a", DS: "d", Signal: "s", Type: TypeFloat},
{Key: "a", DS: "d", Signal: "s2", Type: TypeFloat},
}}
if _, err := s.CreateSet(dup, ""); err == nil {
t.Error("expected duplicate-key validation error")
}
defaultOOR := ConfigSet{Name: "x", Parameters: []Parameter{
{Key: "a", DS: "d", Signal: "s", Type: TypeFloat, Default: 100.0, Max: fptr(10)},
}}
if _, err := s.CreateSet(defaultOOR, ""); err == nil {
t.Error("expected out-of-range default validation error")
}
}
func TestInstanceLifecycle(t *testing.T) {
s, _ := New(t.TempDir())
set, _ := s.CreateSet(sampleSet(), "")
inst := ConfigInstance{
Name: "nominal",
SetID: set.ID,
Values: map[string]any{"voltage": 24.0, "enabled": true},
}
out, err := s.CreateInstance(inst, "")
if err != nil {
t.Fatal(err)
}
if out.Version != 1 {
t.Errorf("instance version: want 1, got %d", out.Version)
}
// Update value -> v2.
out.Values["voltage"] = 36.0
v2, err := s.UpdateInstance(out.ID, out, "bump")
if err != nil {
t.Fatal(err)
}
if v2.Version != 2 {
t.Errorf("instance version after update: want 2, got %d", v2.Version)
}
}
func TestInstanceValidation(t *testing.T) {
s, _ := New(t.TempDir())
set, _ := s.CreateSet(sampleSet(), "")
// voltage is mandatory with a default, so omitting it is OK; but an
// out-of-range value must fail.
bad := ConfigInstance{Name: "x", SetID: set.ID, Values: map[string]any{"voltage": 999.0}}
if _, err := s.CreateInstance(bad, ""); err == nil {
t.Error("expected out-of-range value error")
}
// Unknown parameter key must fail.
unknown := ConfigInstance{Name: "x", SetID: set.ID, Values: map[string]any{"nope": 1.0}}
if _, err := s.CreateInstance(unknown, ""); err == nil {
t.Error("expected unknown-parameter error")
}
// Unknown set must fail.
missing := ConfigInstance{Name: "x", SetID: "does-not-exist", Values: map[string]any{}}
if _, err := s.CreateInstance(missing, ""); err == nil {
t.Error("expected missing-set error")
}
}
+75 -10
View File
@@ -8,8 +8,10 @@ import (
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/uopi/uopi/internal/audit"
"github.com/uopi/uopi/internal/broker"
)
@@ -25,23 +27,46 @@ const (
type Engine struct {
broker *broker.Broker
store *Store
audit audit.Recorder
log *slog.Logger
root context.Context
mu sync.Mutex
cancel context.CancelFunc // cancels the current generation
wg *sync.WaitGroup // tracks the current generation's goroutines
mu sync.Mutex
cancel context.CancelFunc // cancels the current generation
wg *sync.WaitGroup // tracks the current generation's goroutines
// notifier delivers action.dialog requests to connected clients. Stored in
// an atomic so flow goroutines can read it without taking e.mu (which Reload
// holds while it waits for those same goroutines to drain).
notifier atomic.Value // notifierBox
// Shared live signal cache for the current generation (key "ds\0name").
liveMu sync.RWMutex
live map[string]float64
}
// NewEngine creates an engine bound to root. Call Reload to start it.
func NewEngine(root context.Context, brk *broker.Broker, store *Store, log *slog.Logger) *Engine {
// notifierBox wraps a Notifier so atomic.Value always sees one concrete type.
type notifierBox struct{ n Notifier }
// dialogSeq generates unique action.dialog ids across all graphs.
var dialogSeq uint64
// SetNotifier installs the sink for action.dialog requests. Safe to call once
// at startup before or after Reload; it is read lock-free by running flows.
func (e *Engine) SetNotifier(n Notifier) {
e.notifier.Store(notifierBox{n: n})
}
// NewEngine creates an engine bound to root. Call Reload to start it. rec records
// the writes performed by flows; pass audit.Nop() to disable auditing.
func NewEngine(root context.Context, brk *broker.Broker, store *Store, rec audit.Recorder, log *slog.Logger) *Engine {
if rec == nil {
rec = audit.Nop()
}
return &Engine{
broker: brk,
store: store,
audit: rec,
log: log,
root: root,
live: map[string]float64{},
@@ -227,9 +252,45 @@ func (e *Engine) write(cg *compiledGraph, target string, val float64) {
e.log.Warn("control logic: write to unknown data source", "ds", ds, "signal", name)
return
}
ev := audit.Event{
Actor: cg.name,
ActorType: audit.ActorSystem,
Action: "signal.write",
DS: ds,
Signal: name,
Value: strconv.FormatFloat(val, 'g', -1, 64),
Detail: "control logic: " + cg.name,
Outcome: audit.OutcomeOK,
}
if err := src.Write(e.root, name, val); err != nil {
e.log.Warn("control logic: write failed", "ds", ds, "signal", name, "err", err)
ev.Outcome = audit.OutcomeError
ev.Error = err.Error()
}
e.audit.Record(ev)
}
// emitDialog delivers an action.dialog node's request to the installed Notifier
// (the WebSocket dialog hub). It is lock-free so it never deadlocks against a
// concurrent Reload that is waiting for this flow goroutine to finish.
func (e *Engine) emitDialog(n Node) {
box, _ := e.notifier.Load().(notifierBox)
if box.n == nil {
return
}
kind := strings.TrimSpace(n.param("kind"))
if kind != "error" && kind != "input" {
kind = "info"
}
box.n.Notify(Dialog{
ID: strconv.FormatUint(atomic.AddUint64(&dialogSeq, 1), 10),
Kind: kind,
Title: n.param("title"),
Message: n.param("message"),
Target: strings.TrimSpace(n.param("target")),
Users: splitCSV(n.param("users")),
Groups: splitCSV(n.param("groups")),
})
}
// ── compiled graph ─────────────────────────────────────────────────────────────
@@ -245,11 +306,11 @@ type compiledGraph struct {
genCtx context.Context
wg *sync.WaitGroup
name string
byId map[string]Node
out map[string][]wireOut
inc map[string][]string // incoming source ids per node (for gates)
refs map[string]RefLite // unique signals to subscribe (excl. sys/local)
name string
byId map[string]Node
out map[string][]wireOut
inc map[string][]string // incoming source ids per node (for gates)
refs map[string]RefLite // unique signals to subscribe (excl. sys/local)
watchers map[string][]string // signal key → trigger node ids
luaNodes map[string]*luaRuntime
@@ -638,6 +699,10 @@ func (cg *compiledGraph) run(nodeID string, ctx *runCtx) {
cg.runLua(node.ID, ctx)
cg.follow(node.ID, "out", ctx)
case "action.dialog":
cg.engine.emitDialog(node)
cg.follow(node.ID, "out", ctx)
default:
cg.follow(node.ID, "out", ctx)
}
+2
View File
@@ -29,6 +29,8 @@ package controllogic
// action.delay — waits `ms` before continuing.
// action.log — logs an expression value to the server log.
// action.lua — runs a sandboxed Lua script with get/set/log host funcs.
// action.dialog — pushes an info/error/input dialog to connected clients
// filtered by user/group; input responses write `target`.
// Node is a single node in a control-logic graph. Params are stored as strings
// (matching the panel logic model) and parsed per-kind by the engine.
+37
View File
@@ -0,0 +1,37 @@
package controllogic
import "strings"
// Dialog is a user-facing notification or input request emitted by an
// action.dialog node. It is delivered to connected clients whose identity
// matches Users/Groups (both empty = everyone). For an "input" dialog the
// client's response is written back to Target (a "ds:name" reference, e.g.
// "srv:approved") so control logic can read it on a later activation.
type Dialog struct {
ID string `json:"id"`
Kind string `json:"kind"` // "info" | "error" | "input"
Title string `json:"title"`
Message string `json:"message"`
Target string `json:"target,omitempty"`
Users []string `json:"users,omitempty"`
Groups []string `json:"groups,omitempty"`
}
// Notifier delivers control-logic dialogs to connected clients. The server
// implements it; the engine calls Notify when an action.dialog node runs.
// Notify must not block (the hub fans out without waiting on slow clients).
type Notifier interface {
Notify(Dialog)
}
// splitCSV parses a comma-separated user/group filter into trimmed,
// non-empty tokens. An empty string yields a nil slice (no filter).
func splitCSV(s string) []string {
var out []string
for _, p := range strings.Split(s, ",") {
if t := strings.TrimSpace(p); t != "" {
out = append(out, t)
}
}
return out
}
+29 -7
View File
@@ -7,6 +7,7 @@ import (
"os"
"path/filepath"
"sync"
"time"
)
const definitionsFile = "controllogic.json"
@@ -17,16 +18,18 @@ var ErrNotFound = errors.New("control logic graph not found")
// Store persists control-logic graphs as a single JSON file in the storage dir.
// Writes are atomic (tmp file + rename); all access is mutex-guarded.
type Store struct {
mu sync.RWMutex
path string
items map[string]Graph
mu sync.RWMutex
path string
trashDir string
items map[string]Graph
}
// NewStore opens (or initialises) the control-logic store under storageDir.
func NewStore(storageDir string) (*Store, error) {
s := &Store{
path: filepath.Join(storageDir, definitionsFile),
items: map[string]Graph{},
path: filepath.Join(storageDir, definitionsFile),
trashDir: filepath.Join(storageDir, "trash", "controllogic"),
items: map[string]Graph{},
}
if err := s.load(); err != nil {
return nil, err
@@ -99,13 +102,32 @@ func (s *Store) Save(g Graph) error {
return s.saveLocked()
}
// Delete removes a graph by id.
// Delete removes a graph by id, first writing a copy into the trash folder so it
// can be recovered if needed. The trash backup is best-effort: a failure to write
// it does not prevent the delete (but is reported).
func (s *Store) Delete(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.items[id]; !ok {
g, ok := s.items[id]
if !ok {
return ErrNotFound
}
if err := s.trash(g); err != nil {
return fmt.Errorf("move control logic to trash: %w", err)
}
delete(s.items, id)
return s.saveLocked()
}
// trash writes a single graph as a timestamped JSON file under the trash folder.
func (s *Store) trash(g Graph) error {
if err := os.MkdirAll(s.trashDir, 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(g, "", " ")
if err != nil {
return err
}
dst := filepath.Join(s.trashDir, fmt.Sprintf("%s.%d.json", g.ID, time.Now().UnixMilli()))
return os.WriteFile(dst, data, 0o644)
}
+2
View File
@@ -112,6 +112,8 @@ func fetchArchiveHistory(ctx context.Context, archiveURL, signal string, start,
Timestamp: ts,
Data: data,
Quality: q,
Severity: p.Severity,
Status: p.Status,
})
}
+9 -2
View File
@@ -22,7 +22,7 @@ import (
//
//export goCAMonitorCallback
func goCAMonitorCallback(handle C.uintptr_t, dbrType C.int, count C.long,
dbr unsafe.Pointer, severity C.int, epicsTimeSecs C.double) {
dbr unsafe.Pointer, severity C.int, status C.int, epicsTimeSecs C.double) {
h := uintptr(handle)
@@ -79,7 +79,13 @@ func goCAMonitorCallback(handle C.uintptr_t, dbrType C.int, count C.long,
data = float64(0)
}
v := datasource.Value{Timestamp: ts, Data: data, Quality: q}
v := datasource.Value{
Timestamp: ts,
Data: data,
Quality: q,
Severity: int(severity),
Status: int(status),
}
// Look up the subscription channel under the global handle table lock and
// perform a non-blocking send so we never stall the CA callback thread.
@@ -129,6 +135,7 @@ func goCAConnectionCallback(handle C.uintptr_t, connected C.int) {
Timestamp: time.Now(),
Data: float64(0),
Quality: datasource.QualityBad,
Severity: 3, // INVALID
}
select {
case ch <- v:
+10 -3
View File
@@ -13,7 +13,7 @@
* goCAConnectionCallback is called when a channel connects or disconnects.
*/
extern void goCAMonitorCallback(uintptr_t handle, int dbrType, long count,
void *dbr, int severity,
void *dbr, int severity, int status,
double epicsTimeSecs);
extern void goCAConnectionCallback(uintptr_t handle, int connected);
@@ -30,9 +30,10 @@ static void caMonitorCallbackShim(struct event_handler_args args) {
double timeSecs = 0.0;
int severity = 0;
int status = 0;
/*
* Determine the timestamp and alarm severity from the DBR type.
* Determine the timestamp and alarm severity/status from the DBR type.
* We request DBR_TIME_* types so the timestamp is embedded in the value
* buffer right after the alarm fields (struct dbr_time_double et al.).
*/
@@ -41,36 +42,42 @@ static void caMonitorCallbackShim(struct event_handler_args args) {
const struct dbr_time_double *p = (const struct dbr_time_double *)args.dbr;
timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9;
severity = (int)p->severity;
status = (int)p->status;
break;
}
case DBR_TIME_FLOAT: {
const struct dbr_time_float *p = (const struct dbr_time_float *)args.dbr;
timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9;
severity = (int)p->severity;
status = (int)p->status;
break;
}
case DBR_TIME_LONG: {
const struct dbr_time_long *p = (const struct dbr_time_long *)args.dbr;
timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9;
severity = (int)p->severity;
status = (int)p->status;
break;
}
case DBR_TIME_SHORT: {
const struct dbr_time_short *p = (const struct dbr_time_short *)args.dbr;
timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9;
severity = (int)p->severity;
status = (int)p->status;
break;
}
case DBR_TIME_STRING: {
const struct dbr_time_string *p = (const struct dbr_time_string *)args.dbr;
timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9;
severity = (int)p->severity;
status = (int)p->status;
break;
}
case DBR_TIME_ENUM: {
const struct dbr_time_enum *p = (const struct dbr_time_enum *)args.dbr;
timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9;
severity = (int)p->severity;
status = (int)p->status;
break;
}
default:
@@ -78,7 +85,7 @@ static void caMonitorCallbackShim(struct event_handler_args args) {
}
goCAMonitorCallback((uintptr_t)args.usr, (int)args.type, (long)args.count,
(void *)args.dbr, severity, timeSecs);
(void *)args.dbr, severity, status, timeSecs);
}
/*
+2
View File
@@ -275,6 +275,8 @@ func (e *EPICS) timeValueToDS(signal string, tv proto.TimeValue) datasource.Valu
Timestamp: tv.Timestamp,
Data: data,
Quality: quality,
Severity: int(tv.Severity),
Status: int(tv.Status),
}
}
+2
View File
@@ -45,6 +45,8 @@ type Value struct {
Timestamp time.Time
Data any // float64 | []float64 | string | int64 | bool
Quality Quality
Severity int // raw EPICS alarm severity (0=NO_ALARM,1=MINOR,2=MAJOR,3=INVALID); 0 for sources without alarm info
Status int // raw EPICS alarm status (.STAT); 0 for sources without alarm info
MetaUpdate bool // if true, metadata was refreshed — dispatcher should re-send meta
}
+23
View File
@@ -180,6 +180,7 @@ func structToValue(sv pvdata.StructValue) datasource.Value {
val := fieldByName(sv, "value")
ts := extractTimestamp(sv)
quality := extractQuality(sv)
severity, status := extractSeverityStatus(sv)
var data any
if val != nil {
@@ -190,6 +191,8 @@ func structToValue(sv pvdata.StructValue) datasource.Value {
Timestamp: ts,
Data: data,
Quality: quality,
Severity: severity,
Status: status,
}
}
@@ -329,6 +332,26 @@ func extractQuality(sv pvdata.StructValue) datasource.Quality {
}
}
// extractSeverityStatus pulls the raw EPICS alarm severity and status from the
// NTScalar "alarm" struct. Returns (0, 0) when no alarm struct is present.
func extractSeverityStatus(sv pvdata.StructValue) (severity, status int) {
alarm := structByName(sv, "alarm")
if alarm == nil {
return 0, 0
}
if v := fieldByName(*alarm, "severity"); v != nil {
if s, ok := v.(int32); ok {
severity = int(s)
}
}
if v := fieldByName(*alarm, "status"); v != nil {
if s, ok := v.(int32); ok {
status = int(s)
}
}
return severity, status
}
func toFloat64(v any) (float64, bool) {
switch x := v.(type) {
case float64:
+212
View File
@@ -0,0 +1,212 @@
// Package servervar provides a small persistent key/value data source for
// "server variables": named scalar values that the server-side control-logic
// engine writes (e.g. the state of a sequence) and that interface panels can
// read live. Panels may read any variable; writes from panels are gated to
// control-logic editors in the WebSocket write handler.
package servervar
import (
"context"
"encoding/json"
"os"
"path/filepath"
"sort"
"sync"
"time"
"github.com/uopi/uopi/internal/datasource"
)
const fileName = "servervars.json"
type variable struct {
value float64
ts time.Time
}
// Source is the "srv" data source. Variables are created on first write and
// persisted so their last value survives a restart.
type Source struct {
path string
mu sync.RWMutex
vars map[string]*variable
subs map[string]map[int]chan<- datasource.Value
nextID int
}
// New opens (or initialises) the server-variable store under storageDir.
func New(storageDir string) (*Source, error) {
s := &Source{
path: filepath.Join(storageDir, fileName),
vars: map[string]*variable{},
subs: map[string]map[int]chan<- datasource.Value{},
}
if err := s.load(); err != nil {
return nil, err
}
return s, nil
}
func (s *Source) load() error {
data, err := os.ReadFile(s.path)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return err
}
var raw map[string]float64
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
now := time.Now()
for name, v := range raw {
s.vars[name] = &variable{value: v, ts: now}
}
return nil
}
// saveLocked persists the current values atomically. Caller holds s.mu.
func (s *Source) saveLocked() {
raw := make(map[string]float64, len(s.vars))
for name, v := range s.vars {
raw[name] = v.value
}
data, err := json.MarshalIndent(raw, "", " ")
if err != nil {
return
}
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, data, 0o644); err != nil {
return
}
_ = os.Rename(tmp, s.path)
}
func meta(name string) datasource.Metadata {
return datasource.Metadata{
Name: name,
Type: datasource.TypeFloat64,
Description: "Server variable",
Writable: true,
}
}
// Name implements datasource.DataSource.
func (s *Source) Name() string { return "srv" }
// Connect is a no-op — the store is opened in New.
func (s *Source) Connect(_ context.Context) error { return nil }
// ListSignals returns metadata for every defined server variable.
func (s *Source) ListSignals(_ context.Context) ([]datasource.Metadata, error) {
s.mu.RLock()
defer s.mu.RUnlock()
names := make([]string, 0, len(s.vars))
for name := range s.vars {
names = append(names, name)
}
sort.Strings(names)
out := make([]datasource.Metadata, 0, len(names))
for _, name := range names {
out = append(out, meta(name))
}
return out, nil
}
// GetMetadata returns metadata for a single variable. Unknown names still report
// writable metadata so that control logic / authorised panels may create them.
func (s *Source) GetMetadata(_ context.Context, signal string) (datasource.Metadata, error) {
return meta(signal), nil
}
// Subscribe registers ch for updates and immediately delivers the current value.
func (s *Source) Subscribe(ctx context.Context, signal string, ch chan<- datasource.Value) (datasource.CancelFunc, error) {
s.mu.Lock()
if s.subs[signal] == nil {
s.subs[signal] = map[int]chan<- datasource.Value{}
}
id := s.nextID
s.nextID++
s.subs[signal][id] = ch
cur, ok := s.vars[signal]
var first datasource.Value
if ok {
first = datasource.Value{Timestamp: cur.ts, Data: cur.value, Quality: datasource.QualityGood}
}
s.mu.Unlock()
if ok {
go func() {
select {
case ch <- first:
case <-ctx.Done():
}
}()
}
return func() {
s.mu.Lock()
if m := s.subs[signal]; m != nil {
delete(m, id)
if len(m) == 0 {
delete(s.subs, signal)
}
}
s.mu.Unlock()
}, nil
}
func toFloat64(v any) float64 {
switch x := v.(type) {
case float64:
return x
case float32:
return float64(x)
case int:
return float64(x)
case int64:
return float64(x)
case bool:
if x {
return 1
}
return 0
case json.Number:
f, _ := x.Float64()
return f
default:
return 0
}
}
// Write sets a variable (creating it if needed), persists, and fans the new value
// out to all subscribers.
func (s *Source) Write(_ context.Context, signal string, value any) error {
v := toFloat64(value)
s.mu.Lock()
now := time.Now()
s.vars[signal] = &variable{value: v, ts: now}
s.saveLocked()
subs := make([]chan<- datasource.Value, 0, len(s.subs[signal]))
for _, ch := range s.subs[signal] {
subs = append(subs, ch)
}
s.mu.Unlock()
upd := datasource.Value{Timestamp: now, Data: v, Quality: datasource.QualityGood}
for _, ch := range subs {
select {
case ch <- upd:
default:
}
}
return nil
}
// History is not supported.
func (s *Source) History(_ context.Context, _ string, _, _ time.Time, _ int) ([]datasource.Value, error) {
return nil, datasource.ErrHistoryUnavailable
}
@@ -0,0 +1,74 @@
package servervar
import (
"context"
"testing"
"time"
"github.com/uopi/uopi/internal/datasource"
)
func TestWriteSubscribePersist(t *testing.T) {
dir := t.TempDir()
s, err := New(dir)
if err != nil {
t.Fatalf("New: %v", err)
}
ctx := context.Background()
if err := s.Write(ctx, "seq_state", 3.0); err != nil {
t.Fatalf("Write: %v", err)
}
// Subscribe should deliver the current value immediately.
ch := make(chan datasource.Value, 4)
cancel, err := s.Subscribe(ctx, "seq_state", ch)
if err != nil {
t.Fatalf("Subscribe: %v", err)
}
defer cancel()
select {
case v := <-ch:
if got := v.Data.(float64); got != 3.0 {
t.Fatalf("initial value = %v, want 3", got)
}
case <-time.After(time.Second):
t.Fatal("no initial value delivered")
}
// A later write fans out to the subscriber.
if err := s.Write(ctx, "seq_state", 7.0); err != nil {
t.Fatalf("Write 2: %v", err)
}
select {
case v := <-ch:
if got := v.Data.(float64); got != 7.0 {
t.Fatalf("updated value = %v, want 7", got)
}
case <-time.After(time.Second):
t.Fatal("no update delivered")
}
// ListSignals reports the variable.
sigs, err := s.ListSignals(ctx)
if err != nil || len(sigs) != 1 || sigs[0].Name != "seq_state" {
t.Fatalf("ListSignals = %+v, err %v", sigs, err)
}
// A fresh store over the same dir recovers the last value.
s2, err := New(dir)
if err != nil {
t.Fatalf("reopen: %v", err)
}
ch2 := make(chan datasource.Value, 1)
cancel2, _ := s2.Subscribe(ctx, "seq_state", ch2)
defer cancel2()
select {
case v := <-ch2:
if got := v.Data.(float64); got != 7.0 {
t.Fatalf("persisted value = %v, want 7", got)
}
case <-time.After(time.Second):
t.Fatal("persisted value not delivered")
}
}
@@ -0,0 +1,222 @@
package synthetic
import (
"context"
"log/slog"
"math"
"os"
"testing"
"time"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/datasource"
"github.com/uopi/uopi/internal/dsp"
)
// evalSampleDef compiles a SignalDef and evaluates it against per-source
// Samples keyed by source node id, returning the output Sample.
func evalSampleDef(t *testing.T, def SignalDef, srcVals map[string]dsp.Sample) dsp.Sample {
t.Helper()
rg, err := compileGraph(def)
if err != nil {
t.Fatalf("compileGraph: %v", err)
}
out, err := rg.evalSample(srcVals)
if err != nil {
t.Fatalf("evalSample: %v", err)
}
return out
}
// TestArrayElementwiseChain runs an array source through an elementwise op
// (gain) and asserts the output stays an array, broadcast per element.
func TestArrayElementwiseChain(t *testing.T) {
def := SignalDef{
Name: "scaled",
Graph: &Graph{
Output: "out",
Nodes: []GraphNode{
{ID: "a", Kind: "source", DS: "x", Signal: "wave"},
{ID: "g", Kind: "op", Op: "gain", Inputs: []string{"a"}, Params: map[string]any{"gain": 2.0}},
{ID: "out", Kind: "output", Inputs: []string{"g"}},
},
},
}
out := evalSampleDef(t, def, map[string]dsp.Sample{"a": dsp.Array([]float64{1, 2, 3})})
if !out.IsArray {
t.Fatalf("want array output, got %v", out)
}
want := []float64{2, 4, 6}
for i, v := range want {
if out.Arr[i] != v {
t.Errorf("scaled[%d]: want %v, got %v", i, v, out.Arr[i])
}
}
}
// TestArrayReductionToScalar runs an array source into mean (array→scalar) and
// asserts a scalar output and an array-output compile type for the producer.
func TestArrayReductionToScalar(t *testing.T) {
def := SignalDef{
Name: "avg",
Graph: &Graph{
Output: "out",
Nodes: []GraphNode{
{ID: "a", Kind: "source", DS: "x", Signal: "wave"},
{ID: "m", Kind: "op", Op: "mean", Inputs: []string{"a"}},
{ID: "out", Kind: "output", Inputs: []string{"m"}},
},
},
}
out := evalSampleDef(t, def, map[string]dsp.Sample{"a": dsp.Array([]float64{2, 4, 6, 8})})
if out.IsArray {
t.Fatalf("want scalar output, got array %v", out.Arr)
}
if math.Abs(out.F-5) > 1e-9 {
t.Errorf("mean: want 5, got %v", out.F)
}
}
// TestArrayOutTypeMetadata verifies compileGraph reports an array output type
// for a pure-elementwise array graph and scalar for a reduction graph.
func TestArrayOutTypeMetadata(t *testing.T) {
arrayGraph := SignalDef{
Name: "fftout",
Graph: &Graph{
Output: "out",
Nodes: []GraphNode{
{ID: "a", Kind: "source", DS: "x", Signal: "wave"},
{ID: "f", Kind: "op", Op: "fft", Inputs: []string{"a"}},
{ID: "out", Kind: "output", Inputs: []string{"f"}},
},
},
}
rg, err := compileGraph(arrayGraph)
if err != nil {
t.Fatalf("compileGraph: %v", err)
}
if rg.outType != dsp.ValArray {
t.Errorf("fft graph outType: want ValArray, got %v", rg.outType)
}
reduction := SignalDef{
Name: "sumout",
Graph: &Graph{
Output: "out",
Nodes: []GraphNode{
{ID: "a", Kind: "source", DS: "x", Signal: "wave"},
{ID: "s", Kind: "op", Op: "sum", Inputs: []string{"a"}},
{ID: "out", Kind: "output", Inputs: []string{"s"}},
},
},
}
rg2, err := compileGraph(reduction)
if err != nil {
t.Fatalf("compileGraph: %v", err)
}
if rg2.outType != dsp.ValScalar {
t.Errorf("sum graph outType: want ValScalar, got %v", rg2.outType)
}
}
// TestStatefulRejectsArray verifies a stateful op (moving_average) errors when
// fed an array input at runtime.
func TestStatefulRejectsArray(t *testing.T) {
def := SignalDef{
Name: "ma",
Graph: &Graph{
Output: "out",
Nodes: []GraphNode{
{ID: "a", Kind: "source", DS: "x", Signal: "wave"},
{ID: "m", Kind: "op", Op: "moving_average", Inputs: []string{"a"}, Params: map[string]any{"window": 3.0}},
{ID: "out", Kind: "output", Inputs: []string{"m"}},
},
},
}
rg, err := compileGraph(def)
if err != nil {
t.Fatalf("compileGraph: %v", err)
}
if _, err := rg.evalSample(map[string]dsp.Sample{"a": dsp.Array([]float64{1, 2, 3})}); err == nil {
t.Error("expected moving_average to reject an array input")
}
}
// TestSubscribeArrayPassthrough is an end-to-end check that a synthetic with an
// array-valued source and an elementwise op emits a []float64 over the broker,
// and that GetMetadata reports the waveform type.
func TestSubscribeArrayPassthrough(t *testing.T) {
log := slog.New(slog.NewTextHandler(os.Stderr, nil))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
base := time.Date(2026, 6, 19, 10, 0, 0, 0, time.UTC)
src := &seqSource{name: "src", seq: []datasource.Value{
{Timestamp: base.Add(1 * time.Second), Data: []float64{1, 2, 3}, Quality: datasource.QualityGood},
{Timestamp: base.Add(2 * time.Second), Data: []float64{4, 5, 6}, Quality: datasource.QualityGood},
}}
brk := broker.New(ctx, log)
brk.Register(src)
syn := New(t.TempDir(), brk, log)
if err := syn.Connect(ctx); err != nil {
t.Fatal(err)
}
// gain x2 elementwise keeps the value an array.
if err := syn.AddSignal(SignalDef{
Name: "scaled",
Graph: &Graph{Output: "out", Nodes: []GraphNode{
{ID: "a", Kind: "source", DS: "src", Signal: "x"},
{ID: "g", Kind: "op", Op: "gain", Inputs: []string{"a"}, Params: map[string]any{"gain": 2.0}},
{ID: "out", Kind: "output", Inputs: []string{"g"}},
}},
}); err != nil {
t.Fatal(err)
}
// An fft-based signal has a statically-known array output type (the source's
// runtime type need not be known), so its metadata reports the waveform type.
if err := syn.AddSignal(SignalDef{
Name: "spectrum",
Graph: &Graph{Output: "out", Nodes: []GraphNode{
{ID: "a", Kind: "source", DS: "src", Signal: "x"},
{ID: "f", Kind: "op", Op: "fft", Inputs: []string{"a"}},
{ID: "out", Kind: "output", Inputs: []string{"f"}},
}},
}); err != nil {
t.Fatal(err)
}
meta, err := syn.GetMetadata(ctx, "spectrum")
if err != nil {
t.Fatal(err)
}
if meta.Type != datasource.TypeFloat64Array {
t.Errorf("metadata type: want TypeFloat64Array, got %v", meta.Type)
}
ch := make(chan datasource.Value, 8)
if _, err := syn.Subscribe(ctx, "scaled", ch); err != nil {
t.Fatal(err)
}
want := [][]float64{{2, 4, 6}, {8, 10, 12}}
for i, w := range want {
select {
case v := <-ch:
arr, ok := v.Data.([]float64)
if !ok {
t.Fatalf("emit #%d: want []float64, got %T", i, v.Data)
}
if len(arr) != len(w) {
t.Fatalf("emit #%d: want len %d, got %d", i, len(w), len(arr))
}
for k, val := range w {
if arr[k] != val {
t.Errorf("emit #%d [%d]: want %v, got %v", i, k, val, arr[k])
}
}
case <-time.After(2 * time.Second):
t.Fatalf("timeout waiting for emit #%d", i)
}
}
}
@@ -9,6 +9,7 @@ type SignalDef struct {
Signal string `json:"signal"` // upstream signal name (or "" for constant)
Inputs []InputRef `json:"inputs"` // alternative multi-input format
Pipeline []NodeDef `json:"pipeline"` // ordered list of DSP nodes
Graph *Graph `json:"graph,omitempty"` // DAG form (preferred when present)
Meta MetaOverride `json:"meta"` // optional metadata overrides
// Visibility controls who sees this signal in the signal tree:
@@ -34,6 +35,37 @@ type NodeDef struct {
Params map[string]any `json:"params,omitempty"`
}
// Graph is the DAG form of a synthetic signal: a set of nodes (sources, ops and
// one output) wired together by explicit per-node ordered input lists. It
// supersedes the linear Inputs+Pipeline form: when SignalDef.Graph is set it is
// authoritative; otherwise the legacy linear fields are converted into an
// equivalent graph at load time (see toGraph).
type Graph struct {
Nodes []GraphNode `json:"nodes"`
Output string `json:"output"` // id of the output node
}
// GraphNode is one node in a Graph.
//
// kind=="source": carries DS+Signal; has no Inputs (a graph root).
// kind=="op": carries Op + Params; Inputs lists upstream node IDs in the
// order the op receives them (input 0, 1, … e.g. ab, a÷b).
// kind=="output": Inputs has a single upstream node whose value is the result.
//
// X/Y are the editor layout coordinates, persisted so a reloaded graph keeps its
// shape; they have no effect on evaluation.
type GraphNode struct {
ID string `json:"id"`
Kind string `json:"kind"`
Op string `json:"op,omitempty"`
Params map[string]any `json:"params,omitempty"`
DS string `json:"ds,omitempty"`
Signal string `json:"signal,omitempty"`
Inputs []string `json:"inputs,omitempty"`
X float64 `json:"x,omitempty"`
Y float64 `json:"y,omitempty"`
}
// MetaOverride allows the synthetic signal to override display metadata.
type MetaOverride struct {
Unit string `json:"unit,omitempty"`
+49 -14
View File
@@ -38,21 +38,32 @@ func stringParam(params map[string]any, key string) string {
return s
}
// BuildPipeline converts a []NodeDef (from JSON) into a []dsp.Node ready for
// execution. JSON numbers are float64, so all numeric params are handled as
// float64 regardless of the final type needed.
func BuildPipeline(defs []NodeDef) ([]dsp.Node, error) {
nodes := make([]dsp.Node, 0, len(defs))
for i, d := range defs {
n, err := buildNode(d)
if err != nil {
return nil, fmt.Errorf("pipeline node %d (%q): %w", i, d.Type, err)
}
nodes = append(nodes, n)
// stringSliceParam extracts a []string from params; JSON arrays decode to []any,
// so each element is coerced via its string value. Returns nil if missing.
func stringSliceParam(params map[string]any, key string) []string {
if params == nil {
return nil
}
return nodes, nil
v, ok := params[key]
if !ok {
return nil
}
arr, ok := v.([]any)
if !ok {
return nil
}
out := make([]string, 0, len(arr))
for _, e := range arr {
if s, ok := e.(string); ok && s != "" {
out = append(out, s)
}
}
return out
}
// buildNode converts a single NodeDef (from JSON) into a dsp.Node ready for
// execution. JSON numbers are float64, so all numeric params are handled as
// float64 regardless of the final type needed.
func buildNode(d NodeDef) (dsp.Node, error) {
p := d.Params
switch d.Type {
@@ -100,7 +111,7 @@ func buildNode(d NodeDef) (dsp.Node, error) {
}, nil
case "expr":
return &dsp.ExprNode{Expr: stringParam(p, "expr")}, nil
return &dsp.ExprNode{Expr: stringParam(p, "expr"), Vars: stringSliceParam(p, "vars")}, nil
case "lowpass":
order := int(floatParam(p, "order"))
@@ -113,7 +124,31 @@ func buildNode(d NodeDef) (dsp.Node, error) {
}, nil
case "lua":
return &dsp.LuaNode{Script: stringParam(p, "script")}, nil
return &dsp.LuaNode{Script: stringParam(p, "script"), Vars: stringSliceParam(p, "vars")}, nil
case "index":
return &dsp.IndexNode{I: int(floatParam(p, "i"))}, nil
case "slice":
return &dsp.SliceNode{Start: int(floatParam(p, "start")), End: int(floatParam(p, "end"))}, nil
case "sum":
return &dsp.SumNode{}, nil
case "mean":
return &dsp.MeanNode{}, nil
case "min":
return &dsp.MinNode{}, nil
case "max":
return &dsp.MaxNode{}, nil
case "length":
return &dsp.LengthNode{}, nil
case "fft":
return &dsp.FFTNode{}, nil
default:
return nil, fmt.Errorf("unknown node type %q", d.Type)
+263
View File
@@ -0,0 +1,263 @@
package synthetic
import (
"errors"
"fmt"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/dsp"
)
// runtimeGraph is the executable form of a synthetic signal's DAG. Nodes are
// held in topological order so a single forward pass computes every value with
// each node's inputs already resolved. Op-node state maps persist across
// evaluations (for stateful nodes like moving_average / lua).
type runtimeGraph struct {
order []*rtNode // topological order (sources first, output last)
sources []rtSource // source nodes, in topological order
outputID string // id of the output node
outType dsp.ValType // best-effort output type (scalar/array/unknown)
}
type rtNode struct {
id string
kind string // source | op | output
op dsp.Node // set for kind==op
state map[string]any // persistent per-node state (op only)
inputs []string // upstream node ids, in input order
}
type rtSource struct {
id string
ref broker.SignalRef
}
// sourceRefs returns the broker references for every source node, in a stable
// order matching rg.sources.
func (rg *runtimeGraph) sourceRefs() []broker.SignalRef {
refs := make([]broker.SignalRef, len(rg.sources))
for i, s := range rg.sources {
refs[i] = s.ref
}
return refs
}
// evalSample computes the output Sample (scalar or array) given the latest
// value for each source node (keyed by source node id). Nodes are visited in
// topological order so every input is present by the time a node is processed.
//
// Op dispatch:
// - ArrayNode ops (reductions/producers) run natively on Samples.
// - stateless elementwise ops broadcast over array inputs.
// - stateful ops (filters) and lua are scalar-only; an array input errors,
// since their per-evaluation state cannot be split across array lanes.
func (rg *runtimeGraph) evalSample(sourceVals map[string]dsp.Sample) (dsp.Sample, error) {
vals := make(map[string]dsp.Sample, len(rg.order))
for id, v := range sourceVals {
vals[id] = v
}
for _, n := range rg.order {
switch n.kind {
case "op":
in := make([]dsp.Sample, len(n.inputs))
for i, id := range n.inputs {
in[i] = vals[id]
}
r, err := evalOp(n, in)
if err != nil {
return dsp.Sample{}, fmt.Errorf("node %s (%s): %w", n.id, n.op.Type(), err)
}
vals[n.id] = r
case "output":
if len(n.inputs) > 0 {
vals[n.id] = vals[n.inputs[0]]
}
}
}
return vals[rg.outputID], nil
}
// evalOp runs a single op node over its Sample inputs, choosing the right
// execution path for the node type.
func evalOp(n *rtNode, in []dsp.Sample) (dsp.Sample, error) {
if an, ok := n.op.(dsp.ArrayNode); ok {
return an.ProcessSample(in, n.state)
}
if dsp.StatelessElementwise(n.op.Type()) {
return dsp.ApplyElementwise(n.op, in, n.state)
}
// Stateful / lua: scalar-only.
row := make([]float64, len(in))
for i, s := range in {
if s.IsArray {
return dsp.Sample{}, fmt.Errorf("does not accept an array input")
}
row[i] = s.F
}
r, err := n.op.Process(row, n.state)
if err != nil {
return dsp.Sample{}, err
}
return dsp.Scalar(r), nil
}
// eval is the scalar wrapper around evalSample, kept so callers and tests that
// deal purely in float64 (legacy linear graphs, scalar sources) are unchanged.
func (rg *runtimeGraph) eval(sourceVals map[string]float64) (float64, error) {
sv := make(map[string]dsp.Sample, len(sourceVals))
for id, v := range sourceVals {
sv[id] = dsp.Scalar(v)
}
out, err := rg.evalSample(sv)
if err != nil {
return 0, err
}
return out.F, nil
}
// compileGraph converts a SignalDef into an executable runtimeGraph. When the
// def carries an explicit Graph it is used directly; otherwise the legacy
// Inputs+Pipeline form is converted to an equivalent linear graph (see toGraph).
func compileGraph(def SignalDef) (*runtimeGraph, error) {
g := toGraph(def)
if g == nil || len(g.Nodes) == 0 {
return &runtimeGraph{}, nil
}
order, err := topoOrder(g)
if err != nil {
return nil, err
}
rg := &runtimeGraph{outputID: g.Output}
// nodeType tracks each node's best-effort output type for static
// propagation. Sources are unknown at compile time (their real type is
// only known once data flows), so type errors here are advisory; runtime
// Sample typing is authoritative.
nodeType := make(map[string]dsp.ValType, len(order))
for _, gn := range order {
switch gn.Kind {
case "source":
rg.sources = append(rg.sources, rtSource{id: gn.ID, ref: broker.SignalRef{DS: gn.DS, Name: gn.Signal}})
nodeType[gn.ID] = dsp.ValUnknown
case "op":
node, err := buildNode(NodeDef{Type: gn.Op, Params: gn.Params})
if err != nil {
return nil, fmt.Errorf("node %q: %w", gn.ID, err)
}
inTypes := make([]dsp.ValType, len(gn.Inputs))
for i, id := range gn.Inputs {
inTypes[i] = nodeType[id]
}
ot, terr := dsp.OpOutputType(gn.Op, inTypes)
if terr != nil {
return nil, fmt.Errorf("node %q: %w", gn.ID, terr)
}
nodeType[gn.ID] = ot
rg.order = append(rg.order, &rtNode{id: gn.ID, kind: "op", op: node, state: map[string]any{}, inputs: gn.Inputs})
case "output":
rg.outputID = gn.ID
if len(gn.Inputs) > 0 {
nodeType[gn.ID] = nodeType[gn.Inputs[0]]
}
rg.order = append(rg.order, &rtNode{id: gn.ID, kind: "output", inputs: gn.Inputs})
default:
return nil, fmt.Errorf("node %q: unknown kind %q", gn.ID, gn.Kind)
}
}
rg.outType = nodeType[rg.outputID]
return rg, nil
}
// topoOrder returns the graph's nodes in a topological (dependency-first) order,
// treating each node's Inputs as its predecessors. It errors on dangling input
// references or cycles.
func topoOrder(g *Graph) ([]GraphNode, error) {
byID := make(map[string]GraphNode, len(g.Nodes))
for _, n := range g.Nodes {
byID[n.ID] = n
}
indeg := make(map[string]int, len(g.Nodes))
succ := make(map[string][]string, len(g.Nodes))
for _, n := range g.Nodes {
if _, ok := indeg[n.ID]; !ok {
indeg[n.ID] = 0
}
for _, in := range n.Inputs {
if _, ok := byID[in]; !ok {
return nil, fmt.Errorf("node %q references unknown input %q", n.ID, in)
}
indeg[n.ID]++
succ[in] = append(succ[in], n.ID)
}
}
// Seed the queue with roots, preserving the node slice order for determinism.
queue := make([]string, 0, len(g.Nodes))
for _, n := range g.Nodes {
if indeg[n.ID] == 0 {
queue = append(queue, n.ID)
}
}
order := make([]GraphNode, 0, len(g.Nodes))
for len(queue) > 0 {
id := queue[0]
queue = queue[1:]
order = append(order, byID[id])
for _, s := range succ[id] {
indeg[s]--
if indeg[s] == 0 {
queue = append(queue, s)
}
}
}
if len(order) != len(g.Nodes) {
return nil, errors.New("graph contains a cycle")
}
return order, nil
}
// toGraph returns the DAG for a SignalDef. If def.Graph is set it is returned
// as-is. Otherwise the legacy linear form is converted: each input signal
// becomes a source node, the pipeline becomes a chain of op nodes (the first op
// receiving every source, each later op the previous op's output), terminated by
// an output node. With no pipeline the output takes the first source directly,
// matching the old runPipeline behaviour.
func toGraph(def SignalDef) *Graph {
if def.Graph != nil && len(def.Graph.Nodes) > 0 {
return def.Graph
}
inputs := def.Inputs
if len(inputs) == 0 && def.DS != "" && def.Signal != "" {
inputs = []InputRef{{DS: def.DS, Signal: def.Signal}}
}
nodes := make([]GraphNode, 0, len(inputs)+len(def.Pipeline)+1)
srcIDs := make([]string, 0, len(inputs))
for i, inp := range inputs {
id := fmt.Sprintf("s%d", i)
nodes = append(nodes, GraphNode{ID: id, Kind: "source", DS: inp.DS, Signal: inp.Signal})
srcIDs = append(srcIDs, id)
}
opIDs := make([]string, 0, len(def.Pipeline))
for i, nd := range def.Pipeline {
id := fmt.Sprintf("p%d", i)
var ins []string
if i == 0 {
ins = srcIDs
} else {
ins = []string{opIDs[i-1]}
}
nodes = append(nodes, GraphNode{ID: id, Kind: "op", Op: nd.Type, Params: nd.Params, Inputs: ins})
opIDs = append(opIDs, id)
}
var outInputs []string
if len(opIDs) > 0 {
outInputs = []string{opIDs[len(opIDs)-1]}
} else if len(srcIDs) > 0 {
outInputs = []string{srcIDs[0]}
}
nodes = append(nodes, GraphNode{ID: "out", Kind: "output", Inputs: outInputs})
return &Graph{Nodes: nodes, Output: "out"}
}
+130
View File
@@ -0,0 +1,130 @@
package synthetic
import (
"math"
"testing"
)
// evalDef compiles a SignalDef and evaluates it against per-source values keyed
// by source node id.
func evalDef(t *testing.T, def SignalDef, srcVals map[string]float64) float64 {
t.Helper()
rg, err := compileGraph(def)
if err != nil {
t.Fatalf("compileGraph: %v", err)
}
out, err := rg.eval(srcVals)
if err != nil {
t.Fatalf("eval: %v", err)
}
return out
}
// TestGraphMultiInputDAG verifies that an intermediate op can take two
// independently-wired sources — the capability the old linear pipeline lacked.
func TestGraphMultiInputDAG(t *testing.T) {
def := SignalDef{
Name: "diff",
Graph: &Graph{
Output: "out",
Nodes: []GraphNode{
{ID: "a", Kind: "source", DS: "x", Signal: "left"},
{ID: "b", Kind: "source", DS: "x", Signal: "right"},
{ID: "sub", Kind: "op", Op: "subtract", Inputs: []string{"a", "b"}},
{ID: "out", Kind: "output", Inputs: []string{"sub"}},
},
},
}
got := evalDef(t, def, map[string]float64{"a": 10, "b": 3})
if got != 7 {
t.Errorf("subtract DAG: want 7, got %v", got)
}
}
// TestGraphExprNamedInputs verifies expr nodes bind arbitrary named inputs in
// wired order.
func TestGraphExprNamedInputs(t *testing.T) {
def := SignalDef{
Name: "formula",
Graph: &Graph{
Output: "out",
Nodes: []GraphNode{
{ID: "a", Kind: "source", DS: "x", Signal: "p"},
{ID: "b", Kind: "source", DS: "x", Signal: "q"},
{ID: "e", Kind: "op", Op: "expr", Inputs: []string{"a", "b"},
Params: map[string]any{"expr": "price * qty", "vars": []any{"price", "qty"}}},
{ID: "out", Kind: "output", Inputs: []string{"e"}},
},
},
}
got := evalDef(t, def, map[string]float64{"a": 4, "b": 2.5})
if math.Abs(got-10) > 1e-9 {
t.Errorf("expr named inputs: want 10, got %v", got)
}
}
// TestGraphFanInToExpr exercises a non-trivial DAG: two ops feeding one expr.
func TestGraphFanInToExpr(t *testing.T) {
def := SignalDef{
Name: "combo",
Graph: &Graph{
Output: "out",
Nodes: []GraphNode{
{ID: "a", Kind: "source", DS: "x", Signal: "p"},
{ID: "b", Kind: "source", DS: "x", Signal: "q"},
{ID: "g", Kind: "op", Op: "gain", Inputs: []string{"a"}, Params: map[string]any{"gain": 2.0}},
{ID: "o", Kind: "op", Op: "offset", Inputs: []string{"b"}, Params: map[string]any{"offset": 1.0}},
{ID: "e", Kind: "op", Op: "expr", Inputs: []string{"g", "o"}, Params: map[string]any{"expr": "a + b"}},
{ID: "out", Kind: "output", Inputs: []string{"e"}},
},
},
}
// g = 5*2 = 10 ; o = 4+1 = 5 ; a+b = 15
got := evalDef(t, def, map[string]float64{"a": 5, "b": 4})
if math.Abs(got-15) > 1e-9 {
t.Errorf("fan-in DAG: want 15, got %v", got)
}
}
// TestGraphLegacyConversion verifies the linear Inputs+Pipeline form still
// evaluates correctly via the graph runtime.
func TestGraphLegacyConversion(t *testing.T) {
def := SignalDef{
Name: "legacy",
DS: "x",
Signal: "p",
Pipeline: []NodeDef{{Type: "gain", Params: map[string]any{"gain": 3.0}}},
}
rg, err := compileGraph(def)
if err != nil {
t.Fatalf("compileGraph: %v", err)
}
if len(rg.sources) != 1 {
t.Fatalf("want 1 source, got %d", len(rg.sources))
}
got, err := rg.eval(map[string]float64{rg.sources[0].id: 4})
if err != nil {
t.Fatalf("eval: %v", err)
}
if got != 12 {
t.Errorf("legacy gain: want 12, got %v", got)
}
}
// TestGraphCycleRejected ensures a cyclic graph is refused at compile time.
func TestGraphCycleRejected(t *testing.T) {
def := SignalDef{
Name: "cyclic",
Graph: &Graph{
Output: "out",
Nodes: []GraphNode{
{ID: "a", Kind: "op", Op: "gain", Inputs: []string{"b"}, Params: map[string]any{"gain": 1.0}},
{ID: "b", Kind: "op", Op: "gain", Inputs: []string{"a"}, Params: map[string]any{"gain": 1.0}},
{ID: "out", Kind: "output", Inputs: []string{"a"}},
},
},
}
if _, err := compileGraph(def); err == nil {
t.Error("expected cycle to be rejected")
}
}
+85 -95
View File
@@ -20,9 +20,8 @@ const definitionsFile = "synthetic.json"
// signalState holds everything needed to run one synthetic signal.
type signalState struct {
def SignalDef
nodes []dsp.Node
states []map[string]any // one map per node, persistent across calls
def SignalDef
rg *runtimeGraph // compiled DAG; op-node state persists across evaluations
// cancel stops the goroutine driving this signal.
cancel context.CancelFunc
@@ -80,7 +79,7 @@ func (s *Synthetic) ListSignals(_ context.Context) ([]datasource.Metadata, error
out := make([]datasource.Metadata, 0, len(s.signals))
for _, st := range s.signals {
out = append(out, defToMetadata(st.def))
out = append(out, defToMetadata(st.def, outTypeOf(st)))
}
return out, nil
}
@@ -95,7 +94,7 @@ func (s *Synthetic) FilteredMetadata(keep func(SignalDef) bool) []datasource.Met
out := make([]datasource.Metadata, 0, len(s.signals))
for _, st := range s.signals {
if keep(st.def) {
out = append(out, defToMetadata(st.def))
out = append(out, defToMetadata(st.def, outTypeOf(st)))
}
}
return out
@@ -110,7 +109,7 @@ func (s *Synthetic) GetMetadata(_ context.Context, signal string) (datasource.Me
if !ok {
return datasource.Metadata{}, datasource.ErrNotFound
}
return defToMetadata(st.def), nil
return defToMetadata(st.def, outTypeOf(st)), nil
}
// Subscribe registers ch to receive computed values for the named signal.
@@ -123,19 +122,25 @@ func (s *Synthetic) Subscribe(ctx context.Context, signal string, ch chan<- data
return nil, datasource.ErrNotFound
}
// Collect the upstream references for this signal.
refs := upstreamRefs(st.def)
// Collect the source node references for this signal's DAG.
refs := st.rg.sourceRefs()
if len(refs) == 0 {
return nil, fmt.Errorf("synthetic: signal %q has no upstream inputs", signal)
}
// Source node ids, index-aligned with refs, so updates map to graph inputs.
srcIDs := make([]string, len(st.rg.sources))
for i, s := range st.rg.sources {
srcIDs[i] = s.id
}
ctx, cancel := context.WithCancel(ctx)
go func() {
defer cancel()
// Latest value per upstream input index.
latest := make([]float64, len(refs))
// Latest value and timestamp per source node id.
latest := make(map[string]dsp.Sample, len(refs))
latestTs := make([]time.Time, len(refs))
ready := make([]bool, len(refs))
// Subscribe to every upstream ref via the broker.
@@ -159,7 +164,7 @@ func (s *Synthetic) Subscribe(ctx context.Context, signal string, ch chan<- data
if !ok {
return
}
val := toFloat64(u.Value.Data)
val := toSample(u.Value.Data)
select {
case updateCh <- indexedUpdate{idx: idx, val: val, ts: u.Value.Timestamp}:
default:
@@ -184,7 +189,8 @@ func (s *Synthetic) Subscribe(ctx context.Context, signal string, ch chan<- data
return
case upd := <-updateCh:
latest[upd.idx] = upd.val
latest[srcIDs[upd.idx]] = upd.val
latestTs[upd.idx] = upd.ts
ready[upd.idx] = true
// Only compute once we have at least one value for every input.
@@ -199,7 +205,19 @@ func (s *Synthetic) Subscribe(ctx context.Context, signal string, ch chan<- data
continue
}
// Run the pipeline.
// The output is computed from the latest value of every input, so
// its timestamp is the most recent contributing sample time. Using
// the triggering update's timestamp instead would drag the output
// back in time whenever a slow/stale input fired, producing
// non-monotonic or duplicated timestamps on plots.
outTs := latestTs[0]
for _, ts := range latestTs[1:] {
if ts.After(outTs) {
outTs = ts
}
}
// Evaluate the DAG.
s.mu.RLock()
cur, stillExists := s.signals[signal]
s.mu.RUnlock()
@@ -207,15 +225,15 @@ func (s *Synthetic) Subscribe(ctx context.Context, signal string, ch chan<- data
return
}
result, err := runPipeline(cur.nodes, cur.states, latest)
result, err := cur.rg.evalSample(latest)
if err != nil {
s.log.Warn("synthetic: pipeline error", "signal", signal, "err", err)
continue
}
v := datasource.Value{
Timestamp: upd.ts,
Data: result,
Timestamp: outTs,
Data: result.AsAny(),
Quality: datasource.QualityGood,
}
select {
@@ -246,9 +264,9 @@ func (s *Synthetic) AddSignal(def SignalDef) error {
return errors.New("signal name must not be empty")
}
nodes, err := BuildPipeline(def.Pipeline)
rg, err := compileGraph(def)
if err != nil {
return fmt.Errorf("build pipeline: %w", err)
return fmt.Errorf("compile graph: %w", err)
}
s.mu.Lock()
@@ -257,16 +275,7 @@ func (s *Synthetic) AddSignal(def SignalDef) error {
return fmt.Errorf("signal %q already exists", def.Name)
}
states := make([]map[string]any, len(nodes))
for i := range states {
states[i] = make(map[string]any)
}
st := &signalState{
def: def,
nodes: nodes,
states: states,
}
st := &signalState{def: def, rg: rg}
s.signals[def.Name] = st
s.mu.Unlock()
@@ -320,14 +329,9 @@ func (s *Synthetic) UpdateSignal(def SignalDef) error {
return errors.New("signal name must not be empty")
}
nodes, err := BuildPipeline(def.Pipeline)
rg, err := compileGraph(def)
if err != nil {
return fmt.Errorf("build pipeline: %w", err)
}
states := make([]map[string]any, len(nodes))
for i := range states {
states[i] = make(map[string]any)
return fmt.Errorf("compile graph: %w", err)
}
s.mu.Lock()
@@ -339,7 +343,7 @@ func (s *Synthetic) UpdateSignal(def SignalDef) error {
if old.cancel != nil {
old.cancel()
}
s.signals[def.Name] = &signalState{def: def, nodes: nodes, states: states}
s.signals[def.Name] = &signalState{def: def, rg: rg}
s.mu.Unlock()
if err := s.saveDefs(); err != nil {
@@ -402,78 +406,33 @@ func (s *Synthetic) saveDefs() error {
return os.WriteFile(s.defsFilePath(), data, 0o644)
}
// startSignal builds the pipeline for def and registers the signalState.
// startSignal compiles the DAG for def and registers the signalState.
// The actual goroutines are started lazily by Subscribe.
func (s *Synthetic) startSignal(def SignalDef) error {
nodes, err := BuildPipeline(def.Pipeline)
rg, err := compileGraph(def)
if err != nil {
return fmt.Errorf("build pipeline for %q: %w", def.Name, err)
}
states := make([]map[string]any, len(nodes))
for i := range states {
states[i] = make(map[string]any)
return fmt.Errorf("compile graph for %q: %w", def.Name, err)
}
s.mu.Lock()
s.signals[def.Name] = &signalState{
def: def,
nodes: nodes,
states: states,
}
s.signals[def.Name] = &signalState{def: def, rg: rg}
s.mu.Unlock()
s.log.Info("synthetic: signal registered", "name", def.Name)
return nil
}
// runPipeline executes all nodes in sequence. The output of node N becomes
// input[0] of node N+1. For the first node, inputs is the full upstream slice.
func runPipeline(nodes []dsp.Node, states []map[string]any, inputs []float64) (float64, error) {
if len(nodes) == 0 {
if len(inputs) == 0 {
return 0, nil
}
return inputs[0], nil
// defToMetadata converts a SignalDef into a datasource.Metadata. outType is the
// compiled graph's best-effort output type; an array output is reported as a
// waveform (TypeFloat64Array) so widgets can pick a compatible view.
func defToMetadata(def SignalDef, outType dsp.ValType) datasource.Metadata {
dt := datasource.TypeFloat64
if outType == dsp.ValArray {
dt = datasource.TypeFloat64Array
}
// First node receives all upstream inputs.
cur := inputs
var result float64
var err error
for i, node := range nodes {
result, err = node.Process(cur, states[i])
if err != nil {
return 0, fmt.Errorf("node %d (%s): %w", i, node.Type(), err)
}
// Subsequent nodes receive only the single output of the previous node.
cur = []float64{result}
}
return result, nil
}
// upstreamRefs returns the broker.SignalRef list for a SignalDef.
// If Inputs is set, those take precedence; otherwise DS+Signal is used.
func upstreamRefs(def SignalDef) []broker.SignalRef {
if len(def.Inputs) > 0 {
refs := make([]broker.SignalRef, len(def.Inputs))
for i, inp := range def.Inputs {
refs[i] = broker.SignalRef{DS: inp.DS, Name: inp.Signal}
}
return refs
}
if def.DS != "" && def.Signal != "" {
return []broker.SignalRef{{DS: def.DS, Name: def.Signal}}
}
return nil
}
// defToMetadata converts a SignalDef into a datasource.Metadata.
func defToMetadata(def SignalDef) datasource.Metadata {
return datasource.Metadata{
Name: def.Name,
Type: datasource.TypeFloat64,
Type: dt,
Unit: def.Meta.Unit,
Description: def.Meta.Description,
DisplayLow: def.Meta.DisplayLow,
@@ -482,7 +441,38 @@ func defToMetadata(def SignalDef) datasource.Metadata {
}
}
// toFloat64 coerces any numeric value from a datasource.Value.Data to float64.
// outTypeOf returns the compiled output type for a signal state, or unknown.
func outTypeOf(st *signalState) dsp.ValType {
if st == nil || st.rg == nil {
return dsp.ValUnknown
}
return st.rg.outType
}
// toSample coerces a datasource.Value.Data into a dsp.Sample: arrays become
// array Samples (waveforms), everything else a scalar Sample.
func toSample(v any) dsp.Sample {
switch val := v.(type) {
case []float64:
return dsp.Array(val)
case []float32:
out := make([]float64, len(val))
for i, e := range val {
out[i] = float64(e)
}
return dsp.Array(out)
case []int:
out := make([]float64, len(val))
for i, e := range val {
out[i] = float64(e)
}
return dsp.Array(out)
default:
return dsp.Scalar(toFloat64(v))
}
}
// toFloat64 coerces any numeric scalar value from a datasource.Value.Data to float64.
func toFloat64(v any) float64 {
switch val := v.(type) {
case float64:
@@ -508,6 +498,6 @@ func toFloat64(v any) float64 {
// indexedUpdate carries a value from one upstream goroutine to the pipeline runner.
type indexedUpdate struct {
idx int
val float64
val dsp.Sample
ts time.Time
}
@@ -0,0 +1,154 @@
package synthetic
import (
"context"
"log/slog"
"os"
"testing"
"time"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/datasource"
)
// seqSource is a test DataSource that emits a fixed sequence of values, each
// carrying its own timestamp, so tests can control upstream sample times.
type seqSource struct {
name string
seq []datasource.Value
}
func (s *seqSource) Name() string { return s.name }
func (s *seqSource) Connect(context.Context) error { return nil }
func (s *seqSource) ListSignals(context.Context) ([]datasource.Metadata, error) { return nil, nil }
func (s *seqSource) GetMetadata(context.Context, string) (datasource.Metadata, error) {
return datasource.Metadata{Name: "x", Type: datasource.TypeFloat64}, nil
}
func (s *seqSource) Write(context.Context, string, any) error { return datasource.ErrNotWritable }
func (s *seqSource) History(context.Context, string, time.Time, time.Time, int) ([]datasource.Value, error) {
return nil, datasource.ErrHistoryUnavailable
}
func (s *seqSource) Subscribe(ctx context.Context, _ string, ch chan<- datasource.Value) (datasource.CancelFunc, error) {
go func() {
for _, v := range s.seq {
select {
case ch <- v:
case <-ctx.Done():
return
}
time.Sleep(8 * time.Millisecond)
}
}()
return func() {}, nil
}
// TestSubscribePreservesUpstreamTimestamp verifies a single-source synthetic
// emits each computed value with the upstream sample's timestamp.
func TestSubscribePreservesUpstreamTimestamp(t *testing.T) {
log := slog.New(slog.NewTextHandler(os.Stderr, nil))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
base := time.Date(2026, 6, 19, 10, 0, 0, 0, time.UTC)
src := &seqSource{name: "src", seq: []datasource.Value{
{Timestamp: base.Add(1 * time.Second), Data: 1.0, Quality: datasource.QualityGood},
{Timestamp: base.Add(2 * time.Second), Data: 2.0, Quality: datasource.QualityGood},
{Timestamp: base.Add(3 * time.Second), Data: 3.0, Quality: datasource.QualityGood},
}}
brk := broker.New(ctx, log)
brk.Register(src)
syn := New(t.TempDir(), brk, log)
if err := syn.Connect(ctx); err != nil {
t.Fatal(err)
}
if err := syn.AddSignal(SignalDef{
Name: "g", DS: "src", Signal: "x",
Pipeline: []NodeDef{{Type: "gain", Params: map[string]any{"gain": 10.0}}},
}); err != nil {
t.Fatal(err)
}
ch := make(chan datasource.Value, 8)
if _, err := syn.Subscribe(ctx, "g", ch); err != nil {
t.Fatal(err)
}
want := []time.Time{base.Add(1 * time.Second), base.Add(2 * time.Second), base.Add(3 * time.Second)}
for i, w := range want {
select {
case v := <-ch:
if !v.Timestamp.Equal(w) {
t.Errorf("emit #%d timestamp: want %s, got %s", i, w, v.Timestamp)
}
case <-time.After(2 * time.Second):
t.Fatalf("timeout waiting for emit #%d", i)
}
}
}
// TestSubscribeMultiSourceUsesLatestTimestamp verifies that a synthetic combining
// two independent sources stamps each output with the MOST RECENT contributing
// sample time — not the timestamp of whichever source happened to trigger the
// computation. A slow source carrying a stale timestamp must not drag the output
// backwards in time (which previously produced wrong/non-monotonic plot points).
func TestSubscribeMultiSourceUsesLatestTimestamp(t *testing.T) {
log := slog.New(slog.NewTextHandler(os.Stderr, nil))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
now := time.Date(2026, 6, 19, 12, 0, 0, 0, time.UTC)
// Fast source A with current timestamps.
a := &seqSource{name: "A", seq: []datasource.Value{
{Timestamp: now.Add(10 * time.Second), Data: 1.0, Quality: datasource.QualityGood},
{Timestamp: now.Add(11 * time.Second), Data: 2.0, Quality: datasource.QualityGood},
{Timestamp: now.Add(12 * time.Second), Data: 3.0, Quality: datasource.QualityGood},
{Timestamp: now.Add(13 * time.Second), Data: 4.0, Quality: datasource.QualityGood},
}}
// Slow source B: a single sample with a much older timestamp.
b := &seqSource{name: "B", seq: []datasource.Value{
{Timestamp: now.Add(1 * time.Second), Data: 100.0, Quality: datasource.QualityGood},
}}
brk := broker.New(ctx, log)
brk.Register(a)
brk.Register(b)
syn := New(t.TempDir(), brk, log)
if err := syn.Connect(ctx); err != nil {
t.Fatal(err)
}
if err := syn.AddSignal(SignalDef{
Name: "diff",
Graph: &Graph{Output: "out", Nodes: []GraphNode{
{ID: "sa", Kind: "source", DS: "A", Signal: "x"},
{ID: "sb", Kind: "source", DS: "B", Signal: "x"},
{ID: "sub", Kind: "op", Op: "subtract", Inputs: []string{"sa", "sb"}},
{ID: "out", Kind: "output", Inputs: []string{"sub"}},
}},
}); err != nil {
t.Fatal(err)
}
ch := make(chan datasource.Value, 16)
if _, err := syn.Subscribe(ctx, "diff", ch); err != nil {
t.Fatal(err)
}
var last time.Time
for i := 0; i < 4; i++ {
select {
case v := <-ch:
// The stale source-B timestamp (t=1s) must never be used: every output
// is stamped with the newest input time, so emits stay monotonic.
if v.Timestamp.Equal(now.Add(1 * time.Second)) {
t.Errorf("emit #%d used the stale source-B timestamp %s", i, v.Timestamp)
}
if !last.IsZero() && v.Timestamp.Before(last) {
t.Errorf("emit #%d went backwards: %s before previous %s", i, v.Timestamp, last)
}
last = v.Timestamp
case <-time.After(2 * time.Second):
t.Fatalf("timeout waiting for emit #%d", i)
}
}
}
+230
View File
@@ -0,0 +1,230 @@
package dsp
import (
"errors"
"fmt"
"math"
)
// This file holds ArrayNode ops: those that operate natively on waveform
// (float64 array) Samples — reductions (array→scalar), producers (array→array),
// and element access. Each also implements the legacy scalar Node interface
// (treating a scalar as a single-element array) so it remains usable from the
// scalar eval path.
// reductionProcess adapts a scalar Process call to a reduction ArrayNode.
func reductionProcess(n ArrayNode, in []float64, st map[string]any) (float64, error) {
s, err := n.ProcessSample(scalarInputs(in), st)
if err != nil {
return 0, err
}
return s.F, nil
}
// ── IndexNode ───────────────────────────────────────────────────────────────
// IndexNode extracts element I of an array input (array→scalar).
type IndexNode struct{ I int }
func (n *IndexNode) Type() string { return "index" }
func (n *IndexNode) Process(in []float64, st map[string]any) (float64, error) {
return reductionProcess(n, in, st)
}
func (n *IndexNode) ProcessSample(in []Sample, _ map[string]any) (Sample, error) {
if len(in) == 0 {
return Sample{}, errors.New("index: no inputs")
}
arr := in[0].AsArray()
if n.I < 0 || n.I >= len(arr) {
return Sample{}, fmt.Errorf("index: %d out of range [0,%d)", n.I, len(arr))
}
return Scalar(arr[n.I]), nil
}
// ── SliceNode ───────────────────────────────────────────────────────────────
// SliceNode returns a sub-range [Start,End) of an array input (array→array),
// clamped to the array bounds. End <= 0 means "to the end".
type SliceNode struct{ Start, End int }
func (n *SliceNode) Type() string { return "slice" }
func (n *SliceNode) Process(in []float64, st map[string]any) (float64, error) {
s, err := n.ProcessSample(scalarInputs(in), st)
if err != nil {
return 0, err
}
if len(s.Arr) == 0 {
return 0, nil
}
return s.Arr[0], nil
}
func (n *SliceNode) ProcessSample(in []Sample, _ map[string]any) (Sample, error) {
if len(in) == 0 {
return Sample{}, errors.New("slice: no inputs")
}
arr := in[0].AsArray()
start := n.Start
if start < 0 {
start = 0
}
if start > len(arr) {
start = len(arr)
}
end := n.End
if end <= 0 || end > len(arr) {
end = len(arr)
}
if end < start {
end = start
}
out := make([]float64, end-start)
copy(out, arr[start:end])
return Array(out), nil
}
// ── reductions ────────────────────────────────────────────────────────────────
// SumNode sums an array input (array→scalar).
type SumNode struct{}
func (n *SumNode) Type() string { return "sum" }
func (n *SumNode) Process(in []float64, st map[string]any) (float64, error) {
return reductionProcess(n, in, st)
}
func (n *SumNode) ProcessSample(in []Sample, _ map[string]any) (Sample, error) {
if len(in) == 0 {
return Sample{}, errors.New("sum: no inputs")
}
var s float64
for _, v := range in[0].AsArray() {
s += v
}
return Scalar(s), nil
}
// MeanNode averages an array input (array→scalar).
type MeanNode struct{}
func (n *MeanNode) Type() string { return "mean" }
func (n *MeanNode) Process(in []float64, st map[string]any) (float64, error) {
return reductionProcess(n, in, st)
}
func (n *MeanNode) ProcessSample(in []Sample, _ map[string]any) (Sample, error) {
if len(in) == 0 {
return Sample{}, errors.New("mean: no inputs")
}
arr := in[0].AsArray()
if len(arr) == 0 {
return Scalar(0), nil
}
var s float64
for _, v := range arr {
s += v
}
return Scalar(s / float64(len(arr))), nil
}
// MinNode returns the minimum element of an array input (array→scalar).
type MinNode struct{}
func (n *MinNode) Type() string { return "min" }
func (n *MinNode) Process(in []float64, st map[string]any) (float64, error) {
return reductionProcess(n, in, st)
}
func (n *MinNode) ProcessSample(in []Sample, _ map[string]any) (Sample, error) {
if len(in) == 0 {
return Sample{}, errors.New("min: no inputs")
}
arr := in[0].AsArray()
if len(arr) == 0 {
return Scalar(0), nil
}
m := arr[0]
for _, v := range arr[1:] {
if v < m {
m = v
}
}
return Scalar(m), nil
}
// MaxNode returns the maximum element of an array input (array→scalar).
type MaxNode struct{}
func (n *MaxNode) Type() string { return "max" }
func (n *MaxNode) Process(in []float64, st map[string]any) (float64, error) {
return reductionProcess(n, in, st)
}
func (n *MaxNode) ProcessSample(in []Sample, _ map[string]any) (Sample, error) {
if len(in) == 0 {
return Sample{}, errors.New("max: no inputs")
}
arr := in[0].AsArray()
if len(arr) == 0 {
return Scalar(0), nil
}
m := arr[0]
for _, v := range arr[1:] {
if v > m {
m = v
}
}
return Scalar(m), nil
}
// LengthNode returns the element count of an array input (array→scalar).
type LengthNode struct{}
func (n *LengthNode) Type() string { return "length" }
func (n *LengthNode) Process(in []float64, st map[string]any) (float64, error) {
return reductionProcess(n, in, st)
}
func (n *LengthNode) ProcessSample(in []Sample, _ map[string]any) (Sample, error) {
if len(in) == 0 {
return Sample{}, errors.New("length: no inputs")
}
return Scalar(float64(len(in[0].AsArray()))), nil
}
// ── FFTNode ────────────────────────────────────────────────────────────────
// FFTNode computes the magnitude spectrum of an array input (array→array). The
// input is zero-padded to the next power of two; the output has that length and
// holds |X[k]| for each frequency bin.
type FFTNode struct{}
func (n *FFTNode) Type() string { return "fft" }
func (n *FFTNode) Process(in []float64, st map[string]any) (float64, error) {
s, err := n.ProcessSample(scalarInputs(in), st)
if err != nil {
return 0, err
}
if len(s.Arr) == 0 {
return 0, nil
}
return s.Arr[0], nil
}
func (n *FFTNode) ProcessSample(in []Sample, _ map[string]any) (Sample, error) {
if len(in) == 0 {
return Sample{}, errors.New("fft: no inputs")
}
return Array(fftMagnitude(in[0].AsArray())), nil
}
// fftMagnitude returns the magnitude spectrum of x, zero-padded to the next
// power of two. Returns an empty slice for empty input.
func fftMagnitude(x []float64) []float64 {
if len(x) == 0 {
return nil
}
n := nextPow2(len(x))
re := make([]float64, n)
im := make([]float64, n)
copy(re, x)
fftRadix2(re, im)
mag := make([]float64, n)
for i := range mag {
mag[i] = math.Hypot(re[i], im[i])
}
return mag
}
+213
View File
@@ -0,0 +1,213 @@
package dsp
import (
"math"
"testing"
)
func TestSampleRoundTrip(t *testing.T) {
s := Scalar(3.5)
if s.IsArray {
t.Error("Scalar should not be an array")
}
if s.Type() != ValScalar {
t.Errorf("Scalar type: want ValScalar, got %v", s.Type())
}
if s.AsAny() != 3.5 {
t.Errorf("Scalar AsAny: want 3.5, got %v", s.AsAny())
}
if got := s.AsArray(); len(got) != 1 || got[0] != 3.5 {
t.Errorf("Scalar AsArray: want [3.5], got %v", got)
}
a := Array([]float64{1, 2, 3})
if !a.IsArray {
t.Error("Array should be an array")
}
if a.Type() != ValArray {
t.Errorf("Array type: want ValArray, got %v", a.Type())
}
got, ok := a.AsAny().([]float64)
if !ok || len(got) != 3 {
t.Errorf("Array AsAny: want []float64 len 3, got %v", a.AsAny())
}
}
func TestApplyElementwiseAllScalar(t *testing.T) {
n := &AddNode{}
out, err := ApplyElementwise(n, []Sample{Scalar(2), Scalar(3)}, map[string]any{})
if err != nil {
t.Fatal(err)
}
if out.IsArray || out.F != 5 {
t.Errorf("all-scalar add: want scalar 5, got %v", out)
}
}
func TestApplyElementwiseBroadcast(t *testing.T) {
// array ⊕ scalar: scalar is a constant broadcast across the array.
n := &AddNode{}
out, err := ApplyElementwise(n, []Sample{Array([]float64{1, 2, 3}), Scalar(10)}, map[string]any{})
if err != nil {
t.Fatal(err)
}
if !out.IsArray {
t.Fatalf("array+scalar: want array, got %v", out)
}
want := []float64{11, 12, 13}
for i, v := range want {
if out.Arr[i] != v {
t.Errorf("array+scalar[%d]: want %v, got %v", i, v, out.Arr[i])
}
}
}
func TestApplyElementwiseArrayArray(t *testing.T) {
n := &MultiplyNode{}
out, err := ApplyElementwise(n, []Sample{Array([]float64{1, 2, 3}), Array([]float64{4, 5, 6})}, map[string]any{})
if err != nil {
t.Fatal(err)
}
want := []float64{4, 10, 18}
for i, v := range want {
if out.Arr[i] != v {
t.Errorf("array*array[%d]: want %v, got %v", i, v, out.Arr[i])
}
}
}
func TestApplyElementwiseLengthMismatch(t *testing.T) {
n := &AddNode{}
_, err := ApplyElementwise(n, []Sample{Array([]float64{1, 2}), Array([]float64{1, 2, 3})}, map[string]any{})
if err == nil {
t.Error("expected length-mismatch error")
}
}
func TestReductionNodes(t *testing.T) {
arr := []Sample{Array([]float64{2, 4, 6, 8})}
cases := []struct {
name string
node ArrayNode
want float64
}{
{"sum", &SumNode{}, 20},
{"mean", &MeanNode{}, 5},
{"min", &MinNode{}, 2},
{"max", &MaxNode{}, 8},
{"length", &LengthNode{}, 4},
{"index", &IndexNode{I: 2}, 6},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
out, err := tc.node.ProcessSample(arr, map[string]any{})
if err != nil {
t.Fatal(err)
}
if out.IsArray || out.F != tc.want {
t.Errorf("%s: want scalar %v, got %v", tc.name, tc.want, out)
}
})
}
}
func TestIndexNodeOutOfRange(t *testing.T) {
n := &IndexNode{I: 9}
_, err := n.ProcessSample([]Sample{Array([]float64{1, 2, 3})}, map[string]any{})
if err == nil {
t.Error("expected out-of-range error")
}
}
func TestSliceNode(t *testing.T) {
n := &SliceNode{Start: 1, End: 3}
out, err := n.ProcessSample([]Sample{Array([]float64{10, 20, 30, 40})}, map[string]any{})
if err != nil {
t.Fatal(err)
}
want := []float64{20, 30}
if len(out.Arr) != len(want) {
t.Fatalf("slice: want len %d, got %d", len(want), len(out.Arr))
}
for i, v := range want {
if out.Arr[i] != v {
t.Errorf("slice[%d]: want %v, got %v", i, v, out.Arr[i])
}
}
}
func TestFFTMagnitude(t *testing.T) {
// A constant signal has all energy in bin 0 (the DC term equals the sum).
x := []float64{1, 1, 1, 1}
mag := fftMagnitude(x)
if len(mag) != 4 {
t.Fatalf("fft len: want 4, got %d", len(mag))
}
if math.Abs(mag[0]-4) > 1e-9 {
t.Errorf("fft DC bin: want 4, got %v", mag[0])
}
for k := 1; k < len(mag); k++ {
if math.Abs(mag[k]) > 1e-9 {
t.Errorf("fft bin %d: want ~0, got %v", k, mag[k])
}
}
}
func TestFFTSingleTone(t *testing.T) {
// One full cycle of a cosine over 8 samples → energy in bins 1 and N-1.
n := 8
x := make([]float64, n)
for i := range x {
x[i] = math.Cos(2 * math.Pi * float64(i) / float64(n))
}
mag := fftMagnitude(x)
if math.Abs(mag[1]-float64(n)/2) > 1e-6 {
t.Errorf("fft tone bin 1: want %v, got %v", float64(n)/2, mag[1])
}
if math.Abs(mag[n-1]-float64(n)/2) > 1e-6 {
t.Errorf("fft tone bin %d: want %v, got %v", n-1, float64(n)/2, mag[n-1])
}
}
func TestOpOutputType(t *testing.T) {
cases := []struct {
op string
in []ValType
want ValType
wantErr bool
}{
// reductions → scalar regardless of input
{"sum", []ValType{ValArray}, ValScalar, false},
{"mean", []ValType{ValScalar}, ValScalar, false},
{"index", []ValType{ValUnknown}, ValScalar, false},
// array producers require array, yield array
{"fft", []ValType{ValArray}, ValArray, false},
{"slice", []ValType{ValUnknown}, ValArray, false},
{"fft", []ValType{ValScalar}, ValUnknown, true},
// scalar-only reject arrays
{"moving_average", []ValType{ValScalar}, ValScalar, false},
{"lua", []ValType{ValArray}, ValUnknown, true},
{"rms", []ValType{ValUnknown}, ValScalar, false},
// elementwise: array if any array, scalar if all scalar, else unknown
{"add", []ValType{ValScalar, ValScalar}, ValScalar, false},
{"add", []ValType{ValArray, ValScalar}, ValArray, false},
{"gain", []ValType{ValUnknown}, ValUnknown, false},
{"expr", []ValType{ValArray, ValScalar}, ValArray, false},
}
for _, tc := range cases {
got, err := OpOutputType(tc.op, tc.in)
if tc.wantErr {
if err == nil {
t.Errorf("OpOutputType(%q,%v): expected error", tc.op, tc.in)
}
continue
}
if err != nil {
t.Errorf("OpOutputType(%q,%v): unexpected error %v", tc.op, tc.in, err)
continue
}
if got != tc.want {
t.Errorf("OpOutputType(%q,%v): want %v, got %v", tc.op, tc.in, tc.want, got)
}
}
}
+58
View File
@@ -0,0 +1,58 @@
package dsp
import "math"
// nextPow2 returns the smallest power of two >= n (and at least 1).
func nextPow2(n int) int {
p := 1
for p < n {
p <<= 1
}
return p
}
// fftRadix2 computes the in-place iterative radix-2 Cooley-Tukey FFT of the
// complex signal held in re/im. len(re) == len(im) must be a power of two. The
// transform overwrites re/im with the frequency-domain result. This is a small
// self-contained implementation (no external dependency) used by the synthetic
// fft op.
func fftRadix2(re, im []float64) {
n := len(re)
if n <= 1 {
return
}
// Bit-reversal permutation.
for i, j := 1, 0; i < n; i++ {
bit := n >> 1
for ; j&bit != 0; bit >>= 1 {
j ^= bit
}
j ^= bit
if i < j {
re[i], re[j] = re[j], re[i]
im[i], im[j] = im[j], im[i]
}
}
// Danielson-Lanczos butterflies.
for length := 2; length <= n; length <<= 1 {
ang := -2 * math.Pi / float64(length)
wReal, wImag := math.Cos(ang), math.Sin(ang)
for i := 0; i < n; i += length {
curReal, curImag := 1.0, 0.0
half := length >> 1
for k := 0; k < half; k++ {
a := i + k
b := i + k + half
tReal := curReal*re[b] - curImag*im[b]
tImag := curReal*im[b] + curImag*re[b]
re[b] = re[a] - tReal
im[b] = im[a] - tImag
re[a] += tReal
im[a] += tImag
curReal, curImag = curReal*wReal-curImag*wImag, curReal*wImag+curImag*wReal
}
}
}
}
+21 -10
View File
@@ -277,16 +277,28 @@ func (n *ThresholdNode) Process(inputs []float64, _ map[string]any) (float64, er
// ── ExprNode ──────────────────────────────────────────────────────────────────
// ExprNode evaluates a simple arithmetic expression with variables a, b, c, d
// bound to inputs[0..3]. It uses a hand-written recursive descent parser.
// ExprNode evaluates a simple arithmetic expression. Inputs are bound to named
// variables: Vars[i] -> inputs[i]. When Vars is empty it defaults to a, b, c, d
// (bound to inputs[0..3]) for backward compatibility. It uses a hand-written
// recursive descent parser.
type ExprNode struct {
Expr string
Vars []string
}
// defaultVarNames returns the variable names for an expr/lua node: the explicit
// list when set, otherwise the legacy a,b,c,d.
func defaultVarNames(vars []string) []string {
if len(vars) > 0 {
return vars
}
return []string{"a", "b", "c", "d"}
}
func (n *ExprNode) Type() string { return "expr" }
func (n *ExprNode) Process(inputs []float64, _ map[string]any) (float64, error) {
vars := map[string]float64{}
names := []string{"a", "b", "c", "d"}
names := defaultVarNames(n.Vars)
for i, name := range names {
if i < len(inputs) {
vars[name] = inputs[i]
@@ -507,13 +519,10 @@ func (p *exprParser) parseCall() (float64, error) {
}
}
// Not a function call — must be a single-letter variable.
if len(name) != 1 {
return 0, fmt.Errorf("unknown identifier %q (use ad for variables, or a known function name)", name)
}
// Not a function call — must be a declared input variable.
val, ok := p.vars[name]
if !ok {
return 0, fmt.Errorf("unknown variable %q (allowed: a, b, c, d)", name)
return 0, fmt.Errorf("unknown variable %q (declare it as a named input)", name)
}
return val, nil
}
@@ -628,10 +637,12 @@ func (n *LowPassNode) Process(inputs []float64, state map[string]any) (float64,
// ── LuaNode ───────────────────────────────────────────────────────────────────
// LuaNode runs a Lua script in a sandboxed gopher-lua VM.
// Inputs are bound to globals a, b, c, d. The script's return value is the output.
// Inputs are bound to globals named by Vars (Vars[i] -> inputs[i]); when Vars is
// empty it defaults to a, b, c, d. The script's return value is the output.
// The os, io, package, and debug libraries are disabled.
type LuaNode struct {
Script string
Vars []string
}
func (n *LuaNode) Type() string { return "lua" }
@@ -682,7 +693,7 @@ func (n *LuaNode) Process(inputs []float64, state map[string]any) (result float6
L.SetTop(0)
// Bind inputs.
names := []string{"a", "b", "c", "d"}
names := defaultVarNames(n.Vars)
for i, name := range names {
if i < len(inputs) {
L.SetGlobal(name, lua.LNumber(inputs[i]))
+151
View File
@@ -0,0 +1,151 @@
package dsp
import "fmt"
// ValType is the data type of a Sample: a scalar float, a float array
// (waveform), or — at graph-compile time, before a source's real type is
// known — unknown.
type ValType uint8
const (
ValUnknown ValType = iota
ValScalar
ValArray
)
func (t ValType) String() string {
switch t {
case ValScalar:
return "scalar"
case ValArray:
return "array"
default:
return "unknown"
}
}
// Sample is a value flowing through the synthetic DSP graph: either a scalar
// float64 or a float64 array (waveform). It is the array-aware counterpart of
// the bare float64 the legacy scalar Node interface uses.
type Sample struct {
F float64
Arr []float64
IsArray bool
}
// Scalar wraps a float64 as a scalar Sample.
func Scalar(f float64) Sample { return Sample{F: f} }
// Array wraps a []float64 as an array Sample.
func Array(a []float64) Sample { return Sample{Arr: a, IsArray: true} }
// Type reports whether the sample is a scalar or an array.
func (s Sample) Type() ValType {
if s.IsArray {
return ValArray
}
return ValScalar
}
// AsAny returns the value in the form datasource.Value.Data expects: a
// []float64 for arrays, a float64 for scalars.
func (s Sample) AsAny() any {
if s.IsArray {
return s.Arr
}
return s.F
}
// AsArray returns the sample's data as a slice: the array itself, or a
// single-element slice for a scalar. Used by reductions that accept either.
func (s Sample) AsArray() []float64 {
if s.IsArray {
return s.Arr
}
return []float64{s.F}
}
// ArrayNode is an optional extension of Node implemented by ops that operate
// natively on Samples (reductions array→scalar, producers array→array, etc.).
// eval prefers ProcessSample when a node implements it.
type ArrayNode interface {
Node
ProcessSample(inputs []Sample, state map[string]any) (Sample, error)
}
// statelessElementwise lists scalar ops that are safe to broadcast element-wise
// over array inputs: they hold no per-evaluation state, so running the legacy
// Process once per array lane is well-defined. Stateful ops (moving_average,
// rms, lowpass, derivative, integrate) and lua are excluded — a single shared
// state map cannot be meaningfully split across lanes.
var statelessElementwise = map[string]bool{
"gain": true, "offset": true, "add": true, "subtract": true,
"multiply": true, "divide": true, "clamp": true, "threshold": true,
"expr": true,
}
// StatelessElementwise reports whether a scalar op type may be broadcast over
// array inputs via ApplyElementwise.
func StatelessElementwise(nodeType string) bool { return statelessElementwise[nodeType] }
// scalarInputs wraps a legacy float64 input slice as scalar Samples.
func scalarInputs(in []float64) []Sample {
out := make([]Sample, len(in))
for i, v := range in {
out[i] = Scalar(v)
}
return out
}
// ApplyElementwise runs a stateless scalar Node over Sample inputs. If every
// input is scalar it calls Process once and wraps the result. If any input is
// an array it broadcasts: scalar inputs act as constants, all array inputs must
// share a common length (else an error), and Process is invoked once per index.
//
// The node MUST be stateless (see StatelessElementwise) — a shared state map
// cannot be split across array lanes.
func ApplyElementwise(n Node, inputs []Sample, state map[string]any) (Sample, error) {
// Determine the array length, if any input is an array.
length := -1
for _, s := range inputs {
if !s.IsArray {
continue
}
if length == -1 {
length = len(s.Arr)
} else if len(s.Arr) != length {
return Sample{}, fmt.Errorf("%s: array length mismatch (%d vs %d)", n.Type(), length, len(s.Arr))
}
}
if length == -1 {
// All scalar — single legacy call.
row := make([]float64, len(inputs))
for i, s := range inputs {
row[i] = s.F
}
r, err := n.Process(row, state)
if err != nil {
return Sample{}, err
}
return Scalar(r), nil
}
out := make([]float64, length)
row := make([]float64, len(inputs))
for i := 0; i < length; i++ {
for j, s := range inputs {
if s.IsArray {
row[j] = s.Arr[i]
} else {
row[j] = s.F
}
}
r, err := n.Process(row, state)
if err != nil {
return Sample{}, err
}
out[i] = r
}
return Array(out), nil
}
+74
View File
@@ -0,0 +1,74 @@
package dsp
import "fmt"
// Op type categories for static (compile-time / editor) type propagation.
// These mirror the runtime dispatch in the synthetic graph evaluator and the
// frontend's inferNodeTypes (web/src/lib/synthTypes.ts) — keep the three in
// sync; a parity test guards the Go/TS pair.
var (
// reductionOps collapse an array (or scalar) to a single scalar.
reductionOps = map[string]bool{
"index": true, "length": true, "sum": true,
"mean": true, "min": true, "max": true,
}
// arrayProducerOps require an array input and yield an array.
arrayProducerOps = map[string]bool{
"fft": true, "slice": true,
}
// scalarOnlyOps reject array inputs and yield a scalar. Stateful filters
// plus lua (whose state/closure cannot be broadcast per array lane).
scalarOnlyOps = map[string]bool{
"moving_average": true, "rms": true, "lowpass": true,
"derivative": true, "integrate": true, "lua": true,
}
)
// OpOutputType reports the output ValType of an op given its input types, and
// an error if the inputs are definitely incompatible with the op. Inputs may be
// ValUnknown (a source whose real type is not yet known at compile time); such
// inputs never trigger an error — runtime Sample typing is authoritative.
func OpOutputType(op string, in []ValType) (ValType, error) {
switch {
case reductionOps[op]:
return ValScalar, nil
case arrayProducerOps[op]:
for _, t := range in {
if t == ValScalar {
return ValUnknown, fmt.Errorf("%s requires an array input", op)
}
}
return ValArray, nil
case scalarOnlyOps[op]:
for _, t := range in {
if t == ValArray {
return ValUnknown, fmt.Errorf("%s does not accept an array input", op)
}
}
return ValScalar, nil
default:
// Elementwise stateless ops (gain, offset, add, subtract, multiply,
// divide, clamp, threshold, expr): array if any input is an array,
// scalar if all inputs are definitely scalar, otherwise unknown.
anyArray, anyUnknown := false, false
for _, t := range in {
switch t {
case ValArray:
anyArray = true
case ValUnknown:
anyUnknown = true
}
}
switch {
case anyArray:
return ValArray, nil
case anyUnknown:
return ValUnknown, nil
default:
return ValScalar, nil
}
}
}
+199
View File
@@ -0,0 +1,199 @@
package server
import (
"context"
"encoding/json"
"log/slog"
"strconv"
"strings"
"sync"
"github.com/uopi/uopi/internal/access"
"github.com/uopi/uopi/internal/audit"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/controllogic"
"github.com/uopi/uopi/internal/datasource"
)
// DialogHub fans control-logic dialog requests out to connected WebSocket
// clients whose identity matches the dialog's user/group filter, and routes
// input responses back to the dialog's target server variable.
//
// It implements controllogic.Notifier; the engine calls Notify when an
// action.dialog node runs. Input dialogs are remembered as pending so a later
// dialogResponse can be correlated by id and validated against its recipient
// filter — this is why panels can write the response target even though direct
// srv writes are otherwise gated to control-logic editors.
type DialogHub struct {
broker *broker.Broker
policy *access.Policy
audit audit.Recorder
log *slog.Logger
mu sync.Mutex
clients map[*wsClient]struct{}
pending map[string]controllogic.Dialog // input dialogs awaiting a response
}
// NewDialogHub builds an empty hub. rec is never nil after construction.
func NewDialogHub(brk *broker.Broker, policy *access.Policy, rec audit.Recorder, log *slog.Logger) *DialogHub {
if rec == nil {
rec = audit.Nop()
}
return &DialogHub{
broker: brk,
policy: policy,
audit: rec,
log: log,
clients: map[*wsClient]struct{}{},
pending: map[string]controllogic.Dialog{},
}
}
func (h *DialogHub) add(c *wsClient) {
h.mu.Lock()
h.clients[c] = struct{}{}
h.mu.Unlock()
}
func (h *DialogHub) remove(c *wsClient) {
h.mu.Lock()
delete(h.clients, c)
h.mu.Unlock()
}
// dialogOut is the client-bound JSON form of a dialog request.
type dialogOut struct {
Type string `json:"type"` // always "dialog"
ID string `json:"id"`
Kind string `json:"kind"` // "info" | "error" | "input"
Title string `json:"title,omitempty"`
Message string `json:"message,omitempty"`
}
// Notify implements controllogic.Notifier: serialise the dialog and push it to
// every connected client matching its user/group filter.
func (h *DialogHub) Notify(d controllogic.Dialog) {
b, err := json.Marshal(dialogOut{
Type: "dialog",
ID: d.ID,
Kind: d.Kind,
Title: d.Title,
Message: d.Message,
})
if err != nil {
return
}
h.mu.Lock()
if d.Kind == "input" && strings.TrimSpace(d.Target) != "" {
h.pending[d.ID] = d
}
var targets []*wsClient
for c := range h.clients {
if h.matches(c.user, d) {
targets = append(targets, c)
}
}
h.mu.Unlock()
for _, c := range targets {
select {
case c.outCh <- b:
default:
}
}
}
// matches reports whether user is a recipient of d. An empty user+group filter
// targets everyone; otherwise the user must be named or in a named group.
func (h *DialogHub) matches(user string, d controllogic.Dialog) bool {
if len(d.Users) == 0 && len(d.Groups) == 0 {
return true
}
if h.policy != nil {
user = h.policy.ResolveUser(user)
}
for _, u := range d.Users {
if u == user {
return true
}
}
if len(d.Groups) > 0 && h.policy != nil {
groups := map[string]bool{}
for _, g := range h.policy.GroupsOf(user) {
groups[g] = true
}
for _, g := range d.Groups {
if groups[g] {
return true
}
}
}
return false
}
// cancel drops a pending input dialog without writing (user dismissed it).
func (h *DialogHub) cancel(id string) {
h.mu.Lock()
delete(h.pending, id)
h.mu.Unlock()
}
// respond writes an input dialog's response to its target server variable. The
// dialog must be pending and the responding client must have been a recipient;
// this gate replaces the usual srv write-permission check for sanctioned
// control-logic responses.
func (h *DialogHub) respond(ctx context.Context, c *wsClient, id string, value float64) {
h.mu.Lock()
d, ok := h.pending[id]
if ok {
delete(h.pending, id)
}
h.mu.Unlock()
if !ok || !h.matches(c.user, d) {
return
}
ds, name, ok := parseDialogTarget(d.Target)
if !ok || ds == "local" {
return
}
src, ok := h.broker.Source(ds)
if !ok {
h.log.Warn("dialog response: unknown data source", "ds", ds, "target", d.Target)
return
}
ev := audit.Event{
Actor: c.user,
ActorType: audit.ActorUser,
Action: "signal.write",
DS: ds,
Signal: name,
Value: strconv.FormatFloat(value, 'g', -1, 64),
Detail: "control logic dialog response",
IP: c.ip,
Outcome: audit.OutcomeOK,
}
wctx := datasource.WithUser(ctx, c.user)
if err := src.Write(wctx, name, value); err != nil {
h.log.Warn("dialog response: write failed", "ds", ds, "signal", name, "err", err)
ev.Outcome = audit.OutcomeError
ev.Error = err.Error()
}
h.audit.Record(ev)
}
// parseDialogTarget splits a "ds:name" dialog target on the first ':'. A bare
// name (no ':') defaults to the persistent server-variable source "srv".
func parseDialogTarget(t string) (ds, name string, ok bool) {
t = strings.TrimSpace(t)
if t == "" {
return "", "", false
}
if i := strings.IndexByte(t, ':'); i >= 0 {
return t[:i], t[i+1:], true
}
return "srv", t, true
}
+8 -3
View File
@@ -10,7 +10,9 @@ import (
"github.com/uopi/uopi/internal/access"
"github.com/uopi/uopi/internal/api"
"github.com/uopi/uopi/internal/audit"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/confmgr"
"github.com/uopi/uopi/internal/controllogic"
"github.com/uopi/uopi/internal/datasource/synthetic"
"github.com/uopi/uopi/internal/metrics"
@@ -27,7 +29,10 @@ type Server struct {
// New creates the HTTP server, registers all routes, and returns a ready-to-start Server.
// synth may be nil if the synthetic data source is not enabled.
func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, policy *access.Policy, acl *panelacl.Store, ctrlLogic *controllogic.Store, ctrlEngine *controllogic.Engine, channelFinderURL, archiverURL, trustedUserHeader string, log *slog.Logger) *Server {
func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, cfgStore *confmgr.Store, policy *access.Policy, acl *panelacl.Store, ctrlLogic *controllogic.Store, ctrlEngine *controllogic.Engine, dialogs *DialogHub, rec audit.Recorder, channelFinderURL, archiverURL, trustedUserHeader string, log *slog.Logger) *Server {
if rec == nil {
rec = audit.Nop()
}
mux := http.NewServeMux()
// Health check
@@ -37,7 +42,7 @@ func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Syntheti
})
// WebSocket endpoint
mux.Handle("/ws", &wsHandler{broker: brk, log: log, userHeader: trustedUserHeader, policy: policy})
mux.Handle("/ws", &wsHandler{broker: brk, log: log, userHeader: trustedUserHeader, policy: policy, audit: rec, dialogs: dialogs})
// Prometheus-format metrics
mux.HandleFunc("/metrics", metrics.Handler(brk.ActiveSubscriptions))
@@ -45,7 +50,7 @@ func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Syntheti
// REST API — registered on a dedicated mux so it can be wrapped with the
// access-control middleware (identity resolution + global level enforcement).
apiMux := http.NewServeMux()
api.New(brk, synth, store, policy, acl, ctrlLogic, ctrlEngine, channelFinderURL, archiverURL, log).Register(apiMux, apiPrefix)
api.New(brk, synth, store, cfgStore, policy, acl, ctrlLogic, ctrlEngine, rec, channelFinderURL, archiverURL, log).Register(apiMux, apiPrefix)
mux.Handle(apiPrefix+"/", accessMiddleware(policy, trustedUserHeader, apiMux))
// Embedded frontend — must be last (catch-all)
+131 -12
View File
@@ -5,7 +5,9 @@ import (
"encoding/json"
"errors"
"log/slog"
"net"
"net/http"
"strconv"
"strings"
"sync"
"time"
@@ -13,6 +15,7 @@ import (
"github.com/coder/websocket"
"github.com/uopi/uopi/internal/access"
"github.com/uopi/uopi/internal/audit"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/datasource"
"github.com/uopi/uopi/internal/metrics"
@@ -31,6 +34,9 @@ type inMsg struct {
Name string `json:"name,omitempty"`
Value json.RawMessage `json:"value,omitempty"`
// dialogResponse — id of the control-logic dialog being answered.
ID string `json:"id,omitempty"`
// history
Start time.Time `json:"start"`
End time.Time `json:"end"`
@@ -48,11 +54,13 @@ type outMsg struct {
Type string `json:"type"`
// update
DS string `json:"ds,omitempty"`
Name string `json:"name,omitempty"`
TS string `json:"ts,omitempty"`
Value any `json:"value,omitempty"`
Quality string `json:"quality,omitempty"`
DS string `json:"ds,omitempty"`
Name string `json:"name,omitempty"`
TS string `json:"ts,omitempty"`
Value any `json:"value,omitempty"`
Quality string `json:"quality,omitempty"`
Severity int `json:"severity,omitempty"` // raw EPICS alarm severity (0=NO_ALARM)
Status int `json:"status,omitempty"` // raw EPICS alarm status
// meta
Meta *metaPayload `json:"meta,omitempty"`
@@ -94,6 +102,10 @@ type wsHandler struct {
userHeader string
// policy enforces the global access level (blacklist) on signal writes.
policy *access.Policy
// audit records signal writes (never nil; audit.Nop when disabled).
audit audit.Recorder
// dialogs fans control-logic dialogs to clients and routes responses.
dialogs *DialogHub
}
func (h *wsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -103,6 +115,12 @@ func (h *wsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if h.userHeader != "" {
user = strings.TrimSpace(r.Header.Get(h.userHeader))
}
// Resolve to the configured default_user when the header is absent so the
// session carries a real identity (used for audit + EPICS write attribution).
if h.policy != nil {
user = h.policy.ResolveUser(user)
}
clientIP := clientIP(r)
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
// Permissive origin check for LAN / SSH-tunnel use; tighten when auth lands.
@@ -118,17 +136,31 @@ func (h *wsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
rec := h.audit
if rec == nil {
rec = audit.Nop()
}
c := &wsClient{
conn: conn,
broker: h.broker,
user: user,
ip: clientIP,
policy: h.policy,
audit: rec,
dialogs: h.dialogs,
outCh: make(chan []byte, 512),
updateCh: make(chan broker.Update, 1024),
subs: make(map[broker.SignalRef]func()),
log: h.log,
}
// Register for control-logic dialog pushes for this client's identity.
if h.dialogs != nil {
h.dialogs.add(c)
defer h.dialogs.remove(c)
}
var wg sync.WaitGroup
wg.Add(3)
go func() { defer wg.Done(); c.readLoop(ctx, cancel) }()
@@ -153,7 +185,10 @@ type wsClient struct {
broker *broker.Broker
log *slog.Logger
user string // end-user identity from the trusted proxy header ("" if none)
policy *access.Policy // global access-level enforcement
ip string // client address, for audit attribution
policy *access.Policy // global access-level enforcement
audit audit.Recorder // signal-write audit recorder (never nil)
dialogs *DialogHub // control-logic dialog fan-out (nil if disabled)
outCh chan []byte // serialised outgoing messages
updateCh chan broker.Update // raw updates from the broker
@@ -190,12 +225,14 @@ func (c *wsClient) dispatchLoop(ctx context.Context) {
continue
}
msg := outMsg{
Type: "update",
DS: u.Ref.DS,
Name: u.Ref.Name,
TS: u.Value.Timestamp.UTC().Format(time.RFC3339Nano),
Value: u.Value.Data,
Quality: u.Value.Quality.String(),
Type: "update",
DS: u.Ref.DS,
Name: u.Ref.Name,
TS: u.Value.Timestamp.UTC().Format(time.RFC3339Nano),
Value: u.Value.Data,
Quality: u.Value.Quality.String(),
Severity: u.Value.Severity,
Status: u.Value.Status,
}
b, err := json.Marshal(msg)
if err != nil {
@@ -248,6 +285,8 @@ func (c *wsClient) handleMessage(ctx context.Context, data []byte) {
c.handleWrite(ctx, msg)
case "history":
c.handleHistory(ctx, msg)
case "dialogResponse":
c.handleDialogResponse(ctx, msg)
default:
c.sendError(ctx, "UNKNOWN_TYPE", "unknown message type: "+msg.Type)
}
@@ -303,6 +342,14 @@ func (c *wsClient) handleWrite(ctx context.Context, msg inMsg) {
return
}
// Server variables are read-any but write only for control-logic editors, so a
// panel cannot mutate sequence state unless its user owns control-logic access.
if msg.DS == "srv" && c.policy != nil && !c.policy.CanEditLogic(c.policy.ResolveUser(c.user)) {
c.log.Warn("write: server variable denied", "name", msg.Name, "user", c.user)
c.sendError(ctx, "ACCESS_DENIED", "writing server variables requires control-logic access")
return
}
ds, ok := c.broker.Source(msg.DS)
if !ok {
c.log.Warn("write: unknown data source", "ds", msg.DS, "name", msg.Name)
@@ -336,10 +383,44 @@ func (c *wsClient) handleWrite(ctx context.Context, msg inMsg) {
// Attribute the write to the connecting end-user so data sources (EPICS) can
// act under that identity rather than the server's.
ctx = datasource.WithUser(ctx, c.user)
ev := audit.Event{
Actor: c.user,
ActorType: audit.ActorUser,
Action: "signal.write",
DS: msg.DS,
Signal: msg.Name,
Value: formatAuditValue(value),
IP: c.ip,
Outcome: audit.OutcomeOK,
}
if err := ds.Write(ctx, msg.Name, value); err != nil {
c.log.Warn("write: ds.Write failed", "ds", msg.DS, "name", msg.Name, "err", err)
ev.Outcome = audit.OutcomeError
ev.Error = err.Error()
c.audit.Record(ev)
c.sendError(ctx, "WRITE_ERROR", err.Error())
return
}
c.audit.Record(ev)
}
// handleDialogResponse routes the answer to a control-logic input dialog. An
// empty/null value means the user dismissed the dialog (drop it without
// writing); otherwise the numeric value is written to the dialog's target.
func (c *wsClient) handleDialogResponse(ctx context.Context, msg inMsg) {
if c.dialogs == nil || msg.ID == "" {
return
}
if len(msg.Value) == 0 || string(msg.Value) == "null" {
c.dialogs.cancel(msg.ID)
return
}
var num float64
if err := json.Unmarshal(msg.Value, &num); err != nil {
c.dialogs.cancel(msg.ID)
return
}
c.dialogs.respond(ctx, c, msg.ID, num)
}
func (c *wsClient) handleHistory(ctx context.Context, msg inMsg) {
@@ -416,6 +497,44 @@ func (c *wsClient) sendError(ctx context.Context, code, message string) {
// ── Helpers ───────────────────────────────────────────────────────────────────
// clientIP returns the best-effort client address for audit attribution,
// preferring the X-Forwarded-For / X-Real-IP headers set by a reverse proxy and
// falling back to the TCP peer address.
func clientIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if i := strings.IndexByte(xff, ','); i >= 0 {
return strings.TrimSpace(xff[:i])
}
return strings.TrimSpace(xff)
}
if xr := r.Header.Get("X-Real-IP"); xr != "" {
return strings.TrimSpace(xr)
}
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
return host
}
return r.RemoteAddr
}
// formatAuditValue renders a written value as a compact string for the audit log.
func formatAuditValue(v any) string {
switch x := v.(type) {
case string:
return x
case float64:
return strconv.FormatFloat(x, 'g', -1, 64)
case bool:
return strconv.FormatBool(x)
case nil:
return ""
default:
if b, err := json.Marshal(v); err == nil {
return string(b)
}
return ""
}
}
func metadataToPayload(m datasource.Metadata) *metaPayload {
return &metaPayload{
Type: dataTypeName(m.Type),
+32 -5
View File
@@ -474,16 +474,43 @@ func setXMLAttribute(data []byte, attr, value string) ([]byte, error) {
return []byte(s[:valStart] + value + s[valStart+valEnd:]), nil
}
// Delete removes the interface with the given ID.
// Delete moves the interface with the given ID — and all of its version
// backups — into a timestamped trash folder so it can be recovered if needed.
// Nothing is destroyed; recovery is a matter of moving the files back.
func (s *Store) Delete(id string) error {
if err := validateID(id); err != nil {
return ErrNotFound
}
err := os.Remove(s.filePath(id))
if errors.Is(err, os.ErrNotExist) {
return ErrNotFound
if _, err := os.Stat(s.filePath(id)); err != nil {
if errors.Is(err, os.ErrNotExist) {
return ErrNotFound
}
return err
}
return err
trashDir := filepath.Join(s.rootDir, "trash", "interfaces", fmt.Sprintf("%s.%d", id, time.Now().UnixMilli()))
if err := os.MkdirAll(trashDir, 0o755); err != nil {
return fmt.Errorf("create trash dir: %w", err)
}
// Move the current file plus every versioned backup (id.vN.xml), preserving
// their original names so a restore is a straight move-back.
entries, err := os.ReadDir(s.dir)
if err != nil {
return err
}
for _, e := range entries {
name := e.Name()
if e.IsDir() {
continue
}
if name == id+".xml" || (strings.HasPrefix(name, id+".v") && isVersioned(name)) {
if err := os.Rename(filepath.Join(s.dir, name), filepath.Join(trashDir, name)); err != nil {
return fmt.Errorf("move %s to trash: %w", name, err)
}
}
}
return nil
}
// Clone duplicates an interface, assigning a new unique ID.
+2
View File
@@ -4,6 +4,7 @@ import { wsClient } from './lib/ws';
import ViewMode from './ViewMode';
import EditMode from './EditMode';
import FullscreenMode from './FullscreenMode';
import ControlDialogs from './ControlDialogs';
import { applyZoom, getStoredZoom } from './ZoomControl';
import { AuthContext, DEFAULT_ME, fetchMe, canRead } from './lib/auth';
import type { Interface, Me } from './lib/types';
@@ -69,6 +70,7 @@ export default function App() {
return (
<AuthContext.Provider value={me}>
<div class="app-root">
<ControlDialogs />
{wsStatus === 'disconnected' && (
<div class="connection-banner">WebSocket disconnected reconnecting</div>
)}
+169
View File
@@ -0,0 +1,169 @@
import { h } from 'preact';
import { useState, useEffect, useCallback } from 'preact/hooks';
// AuditEvent mirrors the JSON returned by GET /api/v1/audit (internal/audit.Event).
interface AuditEvent {
time: string;
actor: string;
actorType: string; // "user" | "system"
action: string;
ds?: string;
signal?: string;
value?: string;
detail?: string;
ip?: string;
outcome: string; // "ok" | "error"
error?: string;
}
interface Props { onClose: () => void; }
/** Convert an RFC3339/ISO timestamp to a compact local datetime string. */
function fmtTime(iso: string): string {
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
/** Convert a datetime-local input value (local time) to an RFC3339 string. */
function localToRFC3339(v: string): string {
if (!v) return '';
const d = new Date(v);
return isNaN(d.getTime()) ? '' : d.toISOString();
}
export default function AuditViewer({ onClose }: Props) {
const [events, setEvents] = useState<AuditEvent[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Filters
const [user, setUser] = useState('');
const [action, setAction] = useState('');
const [signal, setSignal] = useState('');
const [start, setStart] = useState('');
const [end, setEnd] = useState('');
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const q = new URLSearchParams();
if (user.trim()) q.set('user', user.trim());
if (action) q.set('action', action);
if (signal.trim()) q.set('signal', signal.trim());
const s = localToRFC3339(start);
const e = localToRFC3339(end);
if (s) q.set('start', s);
if (e) q.set('end', e);
const res = await fetch(`/api/v1/audit?${q.toString()}`);
if (res.status === 403) throw new Error('You are not permitted to view the audit log.');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
setEvents(Array.isArray(data) ? data : []);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setEvents([]);
} finally {
setLoading(false);
}
}, [user, action, signal, start, end]);
// Initial load.
useEffect(() => { load(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
function resetFilters() {
setUser(''); setAction(''); setSignal(''); setStart(''); setEnd('');
}
return (
<div class="cl-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div class="cl-modal audit-modal">
<header class="cl-header">
<span class="cl-title">Audit log</span>
<span class="hint cl-subtitle">Every system-affecting action (signal writes, control-logic and interface changes).</span>
<div class="cl-header-actions">
<button class="panel-btn" disabled={loading} onClick={load}>{loading ? 'Loading…' : 'Refresh'}</button>
<button class="panel-btn" onClick={onClose}>Close</button>
</div>
</header>
<div class="audit-filters">
<input class="audit-filter" type="text" placeholder="User" value={user}
onInput={(e) => setUser((e.target as HTMLInputElement).value)} />
<select class="audit-filter" value={action}
onChange={(e) => setAction((e.target as HTMLSelectElement).value)}>
<option value="">All actions</option>
<option value="signal.write">signal.write</option>
<option value="interface.create">interface.create</option>
<option value="interface.update">interface.update</option>
<option value="interface.delete">interface.delete</option>
<option value="controllogic.create">controllogic.create</option>
<option value="controllogic.update">controllogic.update</option>
<option value="controllogic.delete">controllogic.delete</option>
</select>
<input class="audit-filter" type="text" placeholder="Signal contains…" value={signal}
onInput={(e) => setSignal((e.target as HTMLInputElement).value)} />
<label class="audit-filter-label">From
<input class="audit-filter" type="datetime-local" value={start}
onInput={(e) => setStart((e.target as HTMLInputElement).value)} />
</label>
<label class="audit-filter-label">To
<input class="audit-filter" type="datetime-local" value={end}
onInput={(e) => setEnd((e.target as HTMLInputElement).value)} />
</label>
<button class="panel-btn" disabled={loading} onClick={load}>Apply</button>
<button class="panel-btn" onClick={resetFilters}>Reset</button>
</div>
{error && <div class="cl-error">{error}</div>}
<div class="audit-body">
{!error && events.length === 0 && !loading && (
<div class="hint audit-empty">No matching audit entries.</div>
)}
{events.length > 0 && (
<table class="audit-table">
<thead>
<tr>
<th>Time</th>
<th>Actor</th>
<th>Action</th>
<th>Source</th>
<th>Signal / target</th>
<th>Value</th>
<th>Detail</th>
<th>Client</th>
<th>Result</th>
</tr>
</thead>
<tbody>
{events.map((ev, i) => (
<tr key={i} class={ev.outcome === 'error' ? 'audit-row-error' : ''}>
<td class="audit-time">{fmtTime(ev.time)}</td>
<td>
<span class={`audit-actor-tag audit-actor-${ev.actorType}`}>{ev.actorType}</span>
{' '}{ev.actor || '—'}
</td>
<td>{ev.action}</td>
<td>{ev.ds || '—'}</td>
<td class="audit-signal" title={ev.signal}>{ev.signal || '—'}</td>
<td class="audit-value" title={ev.value}>{ev.value ?? '—'}</td>
<td class="audit-detail" title={ev.detail}>{ev.detail || '—'}</td>
<td>{ev.ip || '—'}</td>
<td>
{ev.outcome === 'error'
? <span class="audit-result-err" title={ev.error}>error</span>
: <span class="audit-result-ok">ok</span>}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
</div>
);
}
+1 -1
View File
@@ -155,7 +155,7 @@ export default function Canvas({ iface, onNavigate, timeRange }: Props) {
return (
<div class="canvas-container">
<div class="canvas-area" style={`width:${iface.w}px;height:${iface.h}px;`}>
<div class="canvas-area canvas-view-bare" style={`width:${iface.w}px;height:${iface.h}px;`}>
{iface.widgets.map(widget => {
const Comp = COMPONENTS[widget.type];
const inner = Comp
+816
View File
@@ -0,0 +1,816 @@
import { h, Fragment } from 'preact';
import { useState, useEffect, useCallback } from 'preact/hooks';
import SignalPicker, { SignalOption } from './SignalPicker';
// ── Types (mirror internal/confmgr) ──────────────────────────────────────────
type ParamType = 'float64' | 'int64' | 'bool' | 'string' | 'enum';
interface Parameter {
key: string;
label?: string;
group?: string;
subgroup?: string;
ds: string;
signal: string;
type: ParamType;
default?: any;
mandatory?: boolean;
min?: number;
max?: number;
enumValues?: string[];
unit?: string;
description?: string;
}
interface ConfigSet {
id: string;
name: string;
description?: string;
version: number;
tag?: string;
owner?: string;
parameters: Parameter[];
}
interface ConfigInstance {
id: string;
name: string;
setId: string;
setVersion?: number;
version: number;
tag?: string;
owner?: string;
values: Record<string, any>;
}
interface Meta { id: string; name: string; version: number; }
interface VersionMeta { version: number; name: string; tag?: string; current: boolean; savedAt: string; }
interface ApplyEntry { key: string; ds: string; signal: string; value?: any; ok: boolean; skipped?: boolean; error?: string; }
interface ApplyResult { instanceId: string; setId: string; entries: ApplyEntry[]; applied: number; failed: number; skipped: number; }
type DiffStatus = 'added' | 'removed' | 'changed' | 'unchanged';
interface SetDiffEntry { key: string; status: DiffStatus; left?: Parameter; right?: Parameter; }
interface InstanceDiffEntry { key: string; status: DiffStatus; left?: any; right?: any; }
const PARAM_TYPES: ParamType[] = ['float64', 'int64', 'bool', 'string', 'enum'];
// ── API helper ───────────────────────────────────────────────────────────────
async function apiJSON<T = any>(url: string, opts?: RequestInit): Promise<T | null> {
const res = await fetch(url, opts);
if (!res.ok && res.status !== 204) {
let msg = `HTTP ${res.status}`;
try { const e = await res.json(); if (e && e.error) msg = e.error; } catch { /* ignore */ }
throw new Error(msg);
}
if (res.status === 204) return null;
return res.json();
}
// Lazily-loaded data-source + signal options powering the SignalPicker.
function useSignals() {
const [dataSources, setDataSources] = useState<string[]>([]);
const [dsSignals, setDsSignals] = useState<Record<string, string[]>>({});
useEffect(() => {
fetch('/api/v1/datasources')
.then(r => (r.ok ? r.json() : []))
.then((ds: { name: string }[]) => setDataSources(ds.map(d => d.name)))
.catch(() => {});
}, []);
const loadSignals = useCallback((ds: string) => {
if (!ds) return;
setDsSignals(prev => {
if (prev[ds]) return prev;
fetch(`/api/v1/signals?ds=${encodeURIComponent(ds)}`)
.then(r => (r.ok ? r.json() : []))
.then((sigs: { name: string }[]) => setDsSignals(p => ({ ...p, [ds]: sigs.map(s => s.name) })))
.catch(() => {});
return prev;
});
}, []);
function allOptions(): SignalOption[] {
const out: SignalOption[] = [];
for (const ds of dataSources) for (const name of dsSignals[ds] ?? []) out.push({ ds, name });
return out;
}
function openAll() { dataSources.forEach(loadSignals); }
return { allOptions, openAll };
}
function splitRef(ref: string): { ds: string; signal: string } {
const i = ref.indexOf(':');
if (i < 0) return { ds: '', signal: ref };
return { ds: ref.slice(0, i), signal: ref.slice(i + 1) };
}
function fmtTime(iso: string): string {
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
// ── Top-level modal ───────────────────────────────────────────────────────────
interface Props { onClose: () => void; }
export default function ConfigManager({ onClose }: Props) {
const [tab, setTab] = useState<'sets' | 'instances'>('sets');
return (
<div class="cl-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div class="cl-modal">
<header class="cl-header">
<span class="cl-title">Configuration manager</span>
<span class="hint cl-subtitle">Versioned configuration sets (schemas) and instances (values) that apply to signals.</span>
<div class="cl-header-actions">
<button class="panel-btn" onClick={onClose}>Close</button>
</div>
</header>
<div class="cfg-tabs">
<button class={`cfg-tab${tab === 'sets' ? ' cfg-tab-active' : ''}`} onClick={() => setTab('sets')}>Sets</button>
<button class={`cfg-tab${tab === 'instances' ? ' cfg-tab-active' : ''}`} onClick={() => setTab('instances')}>Instances</button>
</div>
{tab === 'sets' ? <SetsManager /> : <InstancesManager />}
</div>
</div>
);
}
// ── Sets manager ──────────────────────────────────────────────────────────────
function SetsManager() {
const [sets, setSets] = useState<Meta[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [working, setWorking] = useState<ConfigSet | null>(null);
const [dirty, setDirty] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [diff, setDiff] = useState<{ title: string; entries: SetDiffEntry[] } | null>(null);
const { allOptions, openAll } = useSignals();
const reload = useCallback(async () => {
try { setSets((await apiJSON<Meta[]>('/api/v1/config/sets')) ?? []); }
catch (err) { setError(`Failed to load sets: ${msg(err)}`); }
}, []);
useEffect(() => { reload(); }, [reload]);
async function select(id: string) {
if (dirty && !confirm('Discard unsaved changes?')) return;
setError(null);
try {
const s = await apiJSON<ConfigSet>(`/api/v1/config/sets/${encodeURIComponent(id)}`);
setSelectedId(id);
setWorking(s);
setDirty(false);
} catch (err) { setError(msg(err)); }
}
async function createSet() {
setBusy(true); setError(null);
try {
const body = { name: 'New config set', description: '', parameters: [] };
const created = await apiJSON<ConfigSet>('/api/v1/config/sets', jsonPost(body));
await reload();
setSelectedId(created!.id);
setWorking(created);
setDirty(false);
} catch (err) { setError(`Create failed: ${msg(err)}`); }
finally { setBusy(false); }
}
async function save() {
if (!working) return;
setBusy(true); setError(null);
try {
const saved = await apiJSON<ConfigSet>(`/api/v1/config/sets/${encodeURIComponent(working.id)}`, jsonPut(working));
setWorking(saved);
setDirty(false);
await reload();
} catch (err) { setError(`Save failed: ${msg(err)}`); }
finally { setBusy(false); }
}
async function remove(id: string) {
if (!confirm('Delete this config set? A server-side copy is kept in trash.')) return;
setBusy(true); setError(null);
try {
await apiJSON(`/api/v1/config/sets/${encodeURIComponent(id)}`, { method: 'DELETE' });
if (selectedId === id) { setSelectedId(null); setWorking(null); setDirty(false); }
await reload();
} catch (err) { setError(`Delete failed: ${msg(err)}`); }
finally { setBusy(false); }
}
function patch(p: Partial<ConfigSet>) {
if (!working) return;
setWorking({ ...working, ...p });
setDirty(true);
}
function patchParam(i: number, p: Partial<Parameter>) {
if (!working) return;
const params = working.parameters.map((x, idx) => (idx === i ? { ...x, ...p } : x));
patch({ parameters: params });
}
function addParam() {
if (!working) return;
const n = working.parameters.length + 1;
patch({ parameters: [...working.parameters, { key: `param${n}`, ds: '', signal: '', type: 'float64' }] });
}
function removeParam(i: number) {
if (!working) return;
patch({ parameters: working.parameters.filter((_, idx) => idx !== i) });
}
async function showDiff(av: number, bv: number) {
if (!working) return;
setError(null);
try {
const q = new URLSearchParams({ a: working.id, av: String(av), b: working.id, bv: String(bv) });
const entries = (await apiJSON<SetDiffEntry[]>(`/api/v1/config/sets/diff?${q}`)) ?? [];
setDiff({ title: `${working.name}: v${av} → v${bv}`, entries });
} catch (err) { setError(`Diff failed: ${msg(err)}`); }
}
return (
<div class="cl-body">
<div class="cl-list">
<div class="cl-list-head">
<span>Config sets</span>
<button class="panel-btn" disabled={busy} onClick={createSet}>+ New</button>
</div>
{sets.length === 0 && <div class="hint cl-list-empty">No config sets yet.</div>}
{sets.map(s => (
<div key={s.id} class={`cl-list-item${selectedId === s.id ? ' cl-list-item-active' : ''}`} onClick={() => select(s.id)}>
<span class="cl-list-name" title={s.name}>{s.name || '(unnamed)'}</span>
<span class="cfg-badge" title="Current version">v{s.version}</span>
<button class="cl-mini-btn" title="Delete" onClick={(e) => { e.stopPropagation(); remove(s.id); }}></button>
</div>
))}
</div>
<div class="cl-editor-wrap">
{error && <div class="cl-error">{error}</div>}
{!working && <div class="cl-empty hint">Select a config set on the left, or create a new one.</div>}
{working && (
<div class="cfg-editor">
<div class="cfg-edit-bar">
<input class="prop-input cl-name-input" value={working.name} placeholder="Set name"
onInput={(e) => patch({ name: (e.target as HTMLInputElement).value })} />
<span class="cfg-badge">v{working.version}</span>
{dirty && <span class="cl-dirty">unsaved</span>}
<button class="panel-btn panel-btn-primary" disabled={busy || !dirty} onClick={save}>Save</button>
</div>
<input class="prop-input" value={working.description ?? ''} placeholder="Description (optional)"
onInput={(e) => patch({ description: (e.target as HTMLInputElement).value })} />
<div class="cfg-section-head">
<span>Parameters</span>
<button class="panel-btn" onClick={addParam}>+ Add parameter</button>
</div>
{working.parameters.length === 0 && <div class="hint">No parameters. Each parameter binds a target signal with a default and constraints.</div>}
<div class="cfg-params">
{working.parameters.map((p, i) => (
<ParamEditor key={i} param={p} allOptions={allOptions()} onOpenSignals={openAll}
onChange={(patch2) => patchParam(i, patch2)} onRemove={() => removeParam(i)} />
))}
</div>
<VersionPanel kind="sets" id={working.id} onReload={reload}
onForked={(id) => { reload(); select(id); }}
onPromoted={() => select(working.id)}
onCompare={showDiff} onError={setError} />
</div>
)}
</div>
{diff && <DiffModal title={diff.title} onClose={() => setDiff(null)}>
<SetDiffTable entries={diff.entries} />
</DiffModal>}
</div>
);
}
function ParamEditor({ param, allOptions, onOpenSignals, onChange, onRemove }: {
param: Parameter;
allOptions: SignalOption[];
onOpenSignals: () => void;
onChange: (p: Partial<Parameter>) => void;
onRemove: () => void;
}) {
const ref = param.ds || param.signal ? `${param.ds}:${param.signal}` : '';
const isNum = param.type === 'float64' || param.type === 'int64';
return (
<div class="cfg-param">
<div class="cfg-param-row">
<div class="cfg-field cfg-field-key">
<label>Key</label>
<input class="prop-input" value={param.key} onInput={(e) => onChange({ key: (e.target as HTMLInputElement).value })} />
</div>
<div class="cfg-field cfg-field-label">
<label>Label</label>
<input class="prop-input" value={param.label ?? ''} placeholder="(optional)"
onInput={(e) => onChange({ label: (e.target as HTMLInputElement).value })} />
</div>
<div class="cfg-field cfg-field-type">
<label>Type</label>
<select class="prop-select" value={param.type} onChange={(e) => onChange({ type: (e.target as HTMLSelectElement).value as ParamType })}>
{PARAM_TYPES.map(t => <option key={t} value={t}>{t}</option>)}
</select>
</div>
<button class="cl-mini-btn cfg-param-del" title="Remove parameter" onClick={onRemove}></button>
</div>
<div class="cfg-param-row">
<div class="cfg-field cfg-field-target">
<label>Target signal</label>
<SignalPicker value={ref} options={allOptions} onOpen={onOpenSignals} allowFreeform
onChange={(r) => { const s = splitRef(r); onChange({ ds: s.ds, signal: s.signal }); }} />
</div>
<div class="cfg-field cfg-field-default">
<label>Default</label>
{param.type === 'bool' ? (
<select class="prop-select" value={String(param.default ?? '')}
onChange={(e) => { const v = (e.target as HTMLSelectElement).value; onChange({ default: v === '' ? undefined : v === 'true' }); }}>
<option value="">(none)</option>
<option value="true">true</option>
<option value="false">false</option>
</select>
) : (
<input class="prop-input" type={isNum ? 'number' : 'text'} value={param.default ?? ''}
onInput={(e) => onChange({ default: coerceVal((e.target as HTMLInputElement).value, param.type) })} />
)}
</div>
<label class="cfg-mandatory">
<input type="checkbox" checked={!!param.mandatory} onChange={(e) => onChange({ mandatory: (e.target as HTMLInputElement).checked })} />
Mandatory
</label>
</div>
<div class="cfg-param-row">
<div class="cfg-field">
<label>Group</label>
<input class="prop-input" value={param.group ?? ''} placeholder="(optional)"
onInput={(e) => onChange({ group: (e.target as HTMLInputElement).value })} />
</div>
<div class="cfg-field">
<label>Unit</label>
<input class="prop-input" value={param.unit ?? ''} placeholder="(optional)"
onInput={(e) => onChange({ unit: (e.target as HTMLInputElement).value })} />
</div>
{isNum && (
<Fragment>
<div class="cfg-field cfg-field-num">
<label>Min</label>
<input class="prop-input" type="number" value={param.min ?? ''}
onInput={(e) => onChange({ min: numOrUndef((e.target as HTMLInputElement).value) })} />
</div>
<div class="cfg-field cfg-field-num">
<label>Max</label>
<input class="prop-input" type="number" value={param.max ?? ''}
onInput={(e) => onChange({ max: numOrUndef((e.target as HTMLInputElement).value) })} />
</div>
</Fragment>
)}
{param.type === 'enum' && (
<div class="cfg-field cfg-field-enum">
<label>Enum values (comma-separated)</label>
<input class="prop-input" value={(param.enumValues ?? []).join(', ')} placeholder="a, b, c"
onInput={(e) => onChange({ enumValues: (e.target as HTMLInputElement).value.split(',').map(s => s.trim()).filter(Boolean) })} />
</div>
)}
</div>
</div>
);
}
// ── Instances manager ─────────────────────────────────────────────────────────
function InstancesManager() {
const [instances, setInstances] = useState<Meta[]>([]);
const [sets, setSets] = useState<Meta[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [working, setWorking] = useState<ConfigInstance | null>(null);
const [boundSet, setBoundSet] = useState<ConfigSet | null>(null);
const [dirty, setDirty] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [applyResult, setApplyResult] = useState<ApplyResult | null>(null);
const [diff, setDiff] = useState<{ title: string; entries: InstanceDiffEntry[] } | null>(null);
const [showNew, setShowNew] = useState(false);
const reload = useCallback(async () => {
try {
setInstances((await apiJSON<Meta[]>('/api/v1/config/instances')) ?? []);
setSets((await apiJSON<Meta[]>('/api/v1/config/sets')) ?? []);
} catch (err) { setError(`Failed to load: ${msg(err)}`); }
}, []);
useEffect(() => { reload(); }, [reload]);
async function loadSet(setId: string, setVersion?: number): Promise<ConfigSet | null> {
const path = setVersion && setVersion > 0
? `/api/v1/config/sets/${encodeURIComponent(setId)}/versions/${setVersion}`
: `/api/v1/config/sets/${encodeURIComponent(setId)}`;
return apiJSON<ConfigSet>(path);
}
async function select(id: string) {
if (dirty && !confirm('Discard unsaved changes?')) return;
setError(null); setApplyResult(null);
try {
const inst = await apiJSON<ConfigInstance>(`/api/v1/config/instances/${encodeURIComponent(id)}`);
const set = await loadSet(inst!.setId, inst!.setVersion);
setSelectedId(id);
setWorking(inst);
setBoundSet(set);
setDirty(false);
} catch (err) { setError(msg(err)); }
}
async function createInstance(name: string, setId: string) {
setBusy(true); setError(null);
try {
const body = { name, setId, values: {} };
const created = await apiJSON<ConfigInstance>('/api/v1/config/instances', jsonPost(body));
const set = await loadSet(created!.setId, created!.setVersion);
await reload();
setSelectedId(created!.id);
setWorking(created);
setBoundSet(set);
setDirty(false);
setShowNew(false);
} catch (err) { setError(`Create failed: ${msg(err)}`); }
finally { setBusy(false); }
}
async function save() {
if (!working) return;
setBusy(true); setError(null);
try {
const saved = await apiJSON<ConfigInstance>(`/api/v1/config/instances/${encodeURIComponent(working.id)}`, jsonPut(working));
setWorking(saved);
setDirty(false);
await reload();
} catch (err) { setError(`Save failed: ${msg(err)}`); }
finally { setBusy(false); }
}
async function remove(id: string) {
if (!confirm('Delete this config instance? A server-side copy is kept in trash.')) return;
setBusy(true); setError(null);
try {
await apiJSON(`/api/v1/config/instances/${encodeURIComponent(id)}`, { method: 'DELETE' });
if (selectedId === id) { setSelectedId(null); setWorking(null); setBoundSet(null); setDirty(false); }
await reload();
} catch (err) { setError(`Delete failed: ${msg(err)}`); }
finally { setBusy(false); }
}
async function apply() {
if (!working) return;
if (!confirm('Apply this configuration? Each value is written to its target signal.')) return;
setBusy(true); setError(null); setApplyResult(null);
try {
const res = await apiJSON<ApplyResult>(`/api/v1/config/instances/${encodeURIComponent(working.id)}/apply`, { method: 'POST' });
setApplyResult(res);
} catch (err) { setError(`Apply failed: ${msg(err)}`); }
finally { setBusy(false); }
}
function setValue(key: string, value: any) {
if (!working) return;
const values = { ...working.values };
if (value === undefined) delete values[key];
else values[key] = value;
setWorking({ ...working, values });
setDirty(true);
}
async function showDiff(av: number, bv: number) {
if (!working) return;
setError(null);
try {
const q = new URLSearchParams({ a: working.id, av: String(av), b: working.id, bv: String(bv) });
const entries = (await apiJSON<InstanceDiffEntry[]>(`/api/v1/config/instances/diff?${q}`)) ?? [];
setDiff({ title: `${working.name}: v${av} → v${bv}`, entries });
} catch (err) { setError(`Diff failed: ${msg(err)}`); }
}
const setName = (id: string) => sets.find(s => s.id === id)?.name ?? id;
return (
<div class="cl-body">
<div class="cl-list">
<div class="cl-list-head">
<span>Instances</span>
<button class="panel-btn" disabled={busy || sets.length === 0} onClick={() => setShowNew(true)}>+ New</button>
</div>
{sets.length === 0 && <div class="hint cl-list-empty">Create a config set first.</div>}
{sets.length > 0 && instances.length === 0 && <div class="hint cl-list-empty">No instances yet.</div>}
{instances.map(s => (
<div key={s.id} class={`cl-list-item${selectedId === s.id ? ' cl-list-item-active' : ''}`} onClick={() => select(s.id)}>
<span class="cl-list-name" title={s.name}>{s.name || '(unnamed)'}</span>
<span class="cfg-badge">v{s.version}</span>
<button class="cl-mini-btn" title="Delete" onClick={(e) => { e.stopPropagation(); remove(s.id); }}></button>
</div>
))}
</div>
<div class="cl-editor-wrap">
{error && <div class="cl-error">{error}</div>}
{!working && <div class="cl-empty hint">Select an instance on the left, or create a new one.</div>}
{working && (
<div class="cfg-editor">
<div class="cfg-edit-bar">
<input class="prop-input cl-name-input" value={working.name} placeholder="Instance name"
onInput={(e) => { setWorking({ ...working, name: (e.target as HTMLInputElement).value }); setDirty(true); }} />
<span class="cfg-badge">v{working.version}</span>
{dirty && <span class="cl-dirty">unsaved</span>}
<button class="panel-btn panel-btn-primary" disabled={busy || !dirty} onClick={save}>Save</button>
<button class="panel-btn" disabled={busy || dirty} title={dirty ? 'Save before applying' : 'Write values to signals'} onClick={apply}>Apply</button>
</div>
<div class="hint">Based on set <b>{setName(working.setId)}</b>{working.setVersion ? ` (v${working.setVersion})` : ''}.</div>
{!boundSet && <div class="cl-error">Bound set could not be loaded.</div>}
{boundSet && (
<div class="cfg-values">
{boundSet.parameters.length === 0 && <div class="hint">The bound set has no parameters.</div>}
{boundSet.parameters.map(p => (
<ValueRow key={p.key} param={p} value={working.values[p.key]} onChange={(v) => setValue(p.key, v)} />
))}
</div>
)}
{applyResult && <ApplyResultView result={applyResult} />}
<VersionPanel kind="instances" id={working.id} onReload={reload}
onForked={(id) => { reload(); select(id); }}
onPromoted={() => select(working.id)}
onCompare={showDiff} onError={setError} />
</div>
)}
</div>
{showNew && <NewInstanceDialog sets={sets} busy={busy} onCancel={() => setShowNew(false)} onCreate={createInstance} />}
{diff && <DiffModal title={diff.title} onClose={() => setDiff(null)}>
<InstanceDiffTable entries={diff.entries} />
</DiffModal>}
</div>
);
}
function ValueRow({ param, value, onChange }: { param: Parameter; value: any; onChange: (v: any) => void }) {
const label = param.label || param.key;
const isNum = param.type === 'float64' || param.type === 'int64';
const defText = param.default !== undefined && param.default !== null ? String(param.default) : '';
return (
<div class="cfg-value-row">
<div class="cfg-value-label">
{param.mandatory && <span class="cfg-req" title="Mandatory">*</span>}
<span title={`${param.ds}:${param.signal}`}>{label}</span>
{param.unit && <span class="hint cfg-unit">{param.unit}</span>}
</div>
<div class="cfg-value-input">
{param.type === 'bool' ? (
<select class="prop-select" value={value === undefined ? '' : String(value)}
onChange={(e) => { const v = (e.target as HTMLSelectElement).value; onChange(v === '' ? undefined : v === 'true'); }}>
<option value="">{defText ? `default (${defText})` : '(unset)'}</option>
<option value="true">true</option>
<option value="false">false</option>
</select>
) : param.type === 'enum' ? (
<select class="prop-select" value={value === undefined ? '' : String(value)}
onChange={(e) => { const v = (e.target as HTMLSelectElement).value; onChange(v === '' ? undefined : v); }}>
<option value="">{defText ? `default (${defText})` : '(unset)'}</option>
{(param.enumValues ?? []).map(ev => <option key={ev} value={ev}>{ev}</option>)}
</select>
) : (
<input class="prop-input" type={isNum ? 'number' : 'text'}
value={value === undefined ? '' : value}
placeholder={defText ? `default: ${defText}` : '(unset)'}
onInput={(e) => { const raw = (e.target as HTMLInputElement).value; onChange(raw === '' ? undefined : coerceVal(raw, param.type)); }} />
)}
</div>
<span class="cfg-value-target hint" title="Target signal">{param.ds}:{param.signal}</span>
</div>
);
}
function NewInstanceDialog({ sets, busy, onCancel, onCreate }: {
sets: Meta[]; busy: boolean; onCancel: () => void; onCreate: (name: string, setId: string) => void;
}) {
const [name, setName] = useState('New instance');
const [setId, setSetId] = useState(sets[0]?.id ?? '');
return (
<div class="cl-confirm-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) onCancel(); }}>
<div class="cl-confirm">
<div class="cl-confirm-title">New config instance</div>
<div class="cfg-field">
<label>Name</label>
<input class="prop-input" value={name} autoFocus onInput={(e) => setName((e.target as HTMLInputElement).value)} />
</div>
<div class="cfg-field">
<label>Config set</label>
<select class="prop-select" value={setId} onChange={(e) => setSetId((e.target as HTMLSelectElement).value)}>
{sets.map(s => <option key={s.id} value={s.id}>{s.name} (v{s.version})</option>)}
</select>
</div>
<div class="cl-confirm-actions">
<button class="panel-btn" onClick={onCancel}>Cancel</button>
<button class="panel-btn panel-btn-primary" disabled={busy || !setId || !name.trim()}
onClick={() => onCreate(name.trim(), setId)}>Create</button>
</div>
</div>
</div>
);
}
function ApplyResultView({ result }: { result: ApplyResult }) {
return (
<div class="cfg-apply-result">
<div class="cfg-apply-head">
Applied <b>{result.applied}</b>, failed <b class={result.failed ? 'cfg-fail' : ''}>{result.failed}</b>, skipped <b>{result.skipped}</b>
</div>
<table class="cfg-apply-table">
<thead><tr><th>Parameter</th><th>Target</th><th>Value</th><th>Result</th></tr></thead>
<tbody>
{result.entries.map(e => (
<tr key={e.key}>
<td>{e.key}</td>
<td class="hint">{e.ds}:{e.signal}</td>
<td>{e.value !== undefined ? String(e.value) : '—'}</td>
<td>
{e.skipped ? <span class="hint">skipped</span>
: e.ok ? <span class="cfg-ok">ok</span>
: <span class="cfg-fail" title={e.error}>failed</span>}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
// ── Versions panel (shared) ───────────────────────────────────────────────────
function VersionPanel({ kind, id, onForked, onPromoted, onCompare, onError }: {
kind: 'sets' | 'instances';
id: string;
onReload: () => void;
onForked: (newId: string) => void;
onPromoted: () => void;
onCompare: (av: number, bv: number) => void;
onError: (m: string) => void;
}) {
const [versions, setVersions] = useState<VersionMeta[]>([]);
const [open, setOpen] = useState(false);
const [a, setA] = useState<number | null>(null);
const [b, setB] = useState<number | null>(null);
const base = `/api/v1/config/${kind}`;
const load = useCallback(async () => {
try {
const v = (await apiJSON<VersionMeta[]>(`${base}/${encodeURIComponent(id)}/versions`)) ?? [];
setVersions(v);
if (v.length >= 2) { setA(v[1].version); setB(v[0].version); }
else if (v.length === 1) { setA(v[0].version); setB(v[0].version); }
} catch (err) { onError(`Versions failed: ${msg(err)}`); }
}, [base, id, onError]);
useEffect(() => { if (open) load(); }, [open, load]);
// Reset when switching object.
useEffect(() => { setOpen(false); setVersions([]); }, [id]);
async function fork(version: number) {
try {
const obj = await apiJSON<{ id: string }>(`${base}/${encodeURIComponent(id)}/versions/${version}/fork`, { method: 'POST' });
onForked(obj!.id);
} catch (err) { onError(`Fork failed: ${msg(err)}`); }
}
async function promote(version: number) {
if (!confirm(`Promote v${version} to a new current version?`)) return;
try {
await apiJSON(`${base}/${encodeURIComponent(id)}/versions/${version}/promote`, { method: 'POST' });
onPromoted();
} catch (err) { onError(`Promote failed: ${msg(err)}`); }
}
return (
<div class="cfg-versions">
<button class="cfg-versions-toggle" onClick={() => setOpen(o => !o)}>
{open ? '▾' : '▸'} Version history
</button>
{open && (
<div class="cfg-versions-body">
{versions.length === 0 && <div class="hint">No versions.</div>}
{versions.map(v => (
<div key={v.version} class={`cfg-version-row${v.current ? ' cfg-version-current' : ''}`}>
<span class="cfg-version-num">v{v.version}{v.current && <span class="cfg-badge">current</span>}</span>
<span class="cfg-version-tag hint" title={v.tag}>{v.tag || ''}</span>
<span class="cfg-version-time hint">{fmtTime(v.savedAt)}</span>
<button class="cl-mini-btn" title="Fork into a new object" onClick={() => fork(v.version)}>fork</button>
{!v.current && <button class="cl-mini-btn" title="Promote to current" onClick={() => promote(v.version)}>promote</button>}
</div>
))}
{versions.length >= 2 && (
<div class="cfg-compare">
<span class="hint">Compare</span>
<select class="prop-select" value={String(a ?? '')} onChange={(e) => setA(Number((e.target as HTMLSelectElement).value))}>
{versions.map(v => <option key={v.version} value={v.version}>v{v.version}</option>)}
</select>
<span></span>
<select class="prop-select" value={String(b ?? '')} onChange={(e) => setB(Number((e.target as HTMLSelectElement).value))}>
{versions.map(v => <option key={v.version} value={v.version}>v{v.version}</option>)}
</select>
<button class="panel-btn" disabled={a === null || b === null} onClick={() => onCompare(a!, b!)}>Diff</button>
</div>
)}
</div>
)}
</div>
);
}
// ── Diff views ────────────────────────────────────────────────────────────────
function DiffModal({ title, onClose, children }: { title: string; onClose: () => void; children: any }) {
return (
<div class="cl-confirm-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div class="cl-confirm cfg-diff-modal">
<div class="cfg-diff-head">
<span class="cl-confirm-title">Diff {title}</span>
<button class="panel-btn" onClick={onClose}>Close</button>
</div>
{children}
</div>
</div>
);
}
function paramSummary(p?: Parameter): string {
if (!p) return '—';
const parts = [`${p.ds}:${p.signal}`, p.type];
if (p.default !== undefined) parts.push(`default=${p.default}`);
if (p.mandatory) parts.push('mandatory');
if (p.min !== undefined) parts.push(`min=${p.min}`);
if (p.max !== undefined) parts.push(`max=${p.max}`);
return parts.join(', ');
}
function SetDiffTable({ entries }: { entries: SetDiffEntry[] }) {
if (entries.length === 0) return <div class="hint">No parameters.</div>;
return (
<table class="cfg-diff-table">
<thead><tr><th>Parameter</th><th>Left</th><th>Right</th></tr></thead>
<tbody>
{entries.map(e => (
<tr key={e.key} class={`cfg-diff-${e.status}`}>
<td>{e.key} <span class="cfg-diff-status">{e.status}</span></td>
<td>{paramSummary(e.left)}</td>
<td>{paramSummary(e.right)}</td>
</tr>
))}
</tbody>
</table>
);
}
function InstanceDiffTable({ entries }: { entries: InstanceDiffEntry[] }) {
if (entries.length === 0) return <div class="hint">No values.</div>;
return (
<table class="cfg-diff-table">
<thead><tr><th>Parameter</th><th>Left</th><th>Right</th></tr></thead>
<tbody>
{entries.map(e => (
<tr key={e.key} class={`cfg-diff-${e.status}`}>
<td>{e.key} <span class="cfg-diff-status">{e.status}</span></td>
<td>{e.left !== undefined ? String(e.left) : '—'}</td>
<td>{e.right !== undefined ? String(e.right) : '—'}</td>
</tr>
))}
</tbody>
</table>
);
}
// ── small helpers ─────────────────────────────────────────────────────────────
function msg(err: unknown): string { return err instanceof Error ? err.message : String(err); }
function jsonPost(body: any): RequestInit { return { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }; }
function jsonPut(body: any): RequestInit { return { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }; }
function numOrUndef(v: string): number | undefined { if (v.trim() === '') return undefined; const n = Number(v); return isNaN(n) ? undefined : n; }
function coerceVal(v: string, type: ParamType): any {
if (type === 'float64' || type === 'int64') { const n = Number(v); return isNaN(n) ? v : n; }
return v;
}
-95
View File
@@ -1,95 +0,0 @@
import { h } from 'preact';
import { useState, useRef, useEffect } from 'preact/hooks';
interface Props {
mode: 'view' | 'edit';
onOpenManual: (section?: string) => void;
}
const TIPS: Record<string, Array<{ text: string; section?: string }>> = {
view: [
{ text: 'Click any interface in the left panel to load it on the canvas.', section: 'view' },
{ text: 'Right-click a widget to view signal info or export data as CSV.', section: 'view' },
{ text: 'The ● Live button shows live streaming is active. Use the date pickers to load historical data.', section: 'history' },
{ text: 'Click Edit (top-right) to open the drag-and-drop panel builder.', section: 'edit' },
{ text: 'Use the Share button on a panel to grant access to users or groups.', section: 'sharing' },
{ text: 'The ⚙ Control logic button opens server-side automation that runs even with no panel open.', section: 'control' },
{ text: 'The dot in the status chip turns green when the WebSocket is connected.', section: 'view' },
],
edit: [
{ text: 'Drag a signal from the left tree onto the canvas to create a widget.', section: 'edit' },
{ text: 'Click a widget to select it, then adjust its properties on the right.', section: 'edit' },
{ text: 'Ctrl+click to select multiple widgets; then use the align toolbar.', section: 'edit' },
{ text: 'Ctrl+Z to undo, Ctrl+Y to redo. Up to 50 steps are remembered.', section: 'shortcuts' },
{ text: 'Use "+ Synthetic" in the signal tree to create computed/filtered signals.', section: 'signals' },
{ text: 'Switch to the Logic tab to add interactive behaviour with a node-graph flow editor.', section: 'logic' },
{ text: 'Add Local variables for set-points and counters that live inside the panel.', section: 'edit' },
{ text: 'Arrow keys nudge the selected widget by 1 px (Shift for grid size).', section: 'shortcuts' },
],
};
export default function ContextualHelp({ mode, onOpenManual }: Props) {
const [open, setOpen] = useState(false);
const [tipIndex, setTipIndex] = useState(0);
const ref = useRef<HTMLDivElement>(null);
const tips = TIPS[mode];
// Close on outside click
useEffect(() => {
if (!open) return;
function handler(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open]);
// Cycle to a new random tip each time the popover opens
function handleOpen() {
setTipIndex(t => (t + 1) % tips.length);
setOpen(o => !o);
}
const tip = tips[tipIndex];
return (
<div class="ctx-help" ref={ref}>
<button
class="ctx-help-btn"
onClick={handleOpen}
title="Contextual help"
aria-label="Open contextual help"
aria-expanded={open}
>
?
</button>
{open && (
<div class="ctx-help-popover" role="tooltip">
<div class="ctx-help-tip">
<span class="ctx-help-icon">💡</span>
<span>{tip.text}</span>
</div>
<div class="ctx-help-footer">
<button
class="ctx-help-nav"
onClick={() => setTipIndex(t => (t + 1) % tips.length)}
title="Next tip"
>
Next tip
</button>
<button
class="ctx-help-link"
onClick={() => { setOpen(false); onOpenManual(tip.section); }}
>
Open manual
</button>
</div>
</div>
)}
</div>
);
}
+60
View File
@@ -0,0 +1,60 @@
import { h, Fragment } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import { wsClient } from './lib/ws';
import { controlDialogs, dismissControlDialog, type ControlDialog } from './lib/controldialogs';
// Renders dialogs pushed by server-side control-logic action.dialog nodes over
// the WebSocket. Mounted once globally (App) since these are addressed to the
// connected user, independent of which panel is open. info/error dialogs are
// acknowledgements; input dialogs send a numeric response back to the server.
export default function ControlDialogs() {
const [reqs, setReqs] = useState<ControlDialog[]>([]);
useEffect(() => controlDialogs.subscribe(setReqs), []);
if (reqs.length === 0) return null;
return <Fragment>{reqs.map(r => <ControlDialogModal key={r.id} req={r} />)}</Fragment>;
}
function ControlDialogModal({ req }: { req: ControlDialog; key?: string }) {
const isInput = req.kind === 'input';
const [val, setVal] = useState('');
function ok() {
if (isInput) {
const n = parseFloat(val);
wsClient.sendDialogResponse(req.id, Number.isFinite(n) ? n : null);
}
dismissControlDialog(req.id);
}
function cancel() {
if (isInput) wsClient.sendDialogResponse(req.id, null);
dismissControlDialog(req.id);
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Enter') { e.preventDefault(); ok(); }
if (e.key === 'Escape') { e.preventDefault(); cancel(); }
}
return (
<div class="logic-dialog-backdrop" onKeyDown={onKey}>
<div class={`logic-dialog logic-dialog-${req.kind}`} role="dialog">
<div class="logic-dialog-header">
{req.title || (req.kind === 'error' ? 'Error' : isInput ? 'Input required' : 'Info')}
</div>
{req.message && <div class="logic-dialog-message">{req.message}</div>}
{isInput && (
<div class="logic-dialog-field">
<input class="prop-input logic-dialog-input" type="number"
autoFocus value={val}
onInput={(e) => setVal((e.target as HTMLInputElement).value)}
onKeyDown={onKey} />
</div>
)}
<div class="logic-dialog-actions">
{isInput && <button class="panel-btn" onClick={cancel}>Cancel</button>}
<button class="panel-btn panel-btn-primary" onClick={ok}>OK</button>
</div>
</div>
</div>
);
}
+134 -54
View File
@@ -1,6 +1,6 @@
import { h, Fragment } from 'preact';
import { useState, useEffect, useRef } from 'preact/hooks';
import SearchableSelect from './SearchableSelect';
import SignalPicker, { SignalOption } from './SignalPicker';
import LuaEditor from './LuaEditor';
import { checkExpr } from './lib/expr';
@@ -18,7 +18,8 @@ type CLNodeKind =
| 'action.write'
| 'action.delay'
| 'action.log'
| 'action.lua';
| 'action.lua'
| 'action.dialog';
interface CLNode {
id: string;
@@ -55,6 +56,11 @@ const NODE_W = 11.5 * REM;
const PORT_TOP = 2 * REM;
const PORT_GAP = 1.375 * REM;
const PORT_R = 0.375 * REM;
// Ports sit inside the node's padding box, offset from the node border-box origin
// by the node borders (3px colored top accent, 1px sides). Anchors add the same
// offsets so wires meet the port centers, not their top edges.
const BORDER = 1;
const BORDER_TOP = 3;
interface PaletteEntry { kind: CLNodeKind; label: string; params: Record<string, string>; }
@@ -71,6 +77,7 @@ const PALETTE: PaletteEntry[] = [
{ kind: 'action.delay', label: 'Delay', params: { ms: '500' } },
{ kind: 'action.log', label: 'Log', params: { expr: '', label: '' } },
{ kind: 'action.lua', label: 'Lua script', params: { script: '-- get("ds:name") reads, set("ds:name", v) writes,\n-- log("msg") logs to the server.\n' } },
{ kind: 'action.dialog', label: 'Dialog', params: { kind: 'info', title: '', message: '', users: '', groups: '', target: '' } },
];
const KIND_LABEL: Record<CLNodeKind, string> = {
@@ -86,6 +93,7 @@ const KIND_LABEL: Record<CLNodeKind, string> = {
'action.delay': 'Delay',
'action.log': 'Log',
'action.lua': 'Lua script',
'action.dialog': 'Dialog',
};
const THRESHOLD_OPS = ['>', '<', '>=', '<=', '==', '!='] as const;
@@ -117,11 +125,11 @@ function nodeHeight(kind: CLNodeKind): number { return PORT_TOP + outputs(kind).
function genId(): string { return 'n_' + Math.random().toString(36).slice(2, 9); }
function inAnchor(n: CLNode) { return { x: n.x, y: n.y + PORT_TOP }; }
function inAnchor(n: CLNode) { return { x: n.x + BORDER, y: n.y + BORDER_TOP + PORT_TOP }; }
function outAnchor(n: CLNode, 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 };
return { x: n.x + NODE_W - BORDER, y: n.y + BORDER_TOP + 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);
@@ -139,6 +147,16 @@ export default function ControlLogicEditor({ onClose }: Props) {
const [dirty, setDirty] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showCloseConfirm, setShowCloseConfirm] = useState(false);
// Closing with unsaved changes prompts instead of silently discarding them.
function requestClose() {
if (dirty) { setShowCloseConfirm(true); return; }
onClose();
}
async function saveAndClose() {
if (await saveGraph()) { setShowCloseConfirm(false); onClose(); }
}
async function reload() {
try {
@@ -180,8 +198,8 @@ export default function ControlLogicEditor({ onClose }: Props) {
}
}
async function saveGraph() {
if (!graph) return;
async function saveGraph(): Promise<boolean> {
if (!graph) return false;
setBusy(true);
setError(null);
try {
@@ -191,8 +209,10 @@ export default function ControlLogicEditor({ onClose }: Props) {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
setDirty(false);
await reload();
return true;
} catch (err) {
setError(`Save failed: ${err instanceof Error ? err.message : err}`);
return false;
} finally {
setBusy(false);
}
@@ -240,7 +260,7 @@ export default function ControlLogicEditor({ onClose }: Props) {
}
return (
<div class="cl-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div class="cl-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) requestClose(); }}>
<div class="cl-modal">
<header class="cl-header">
<span class="cl-title">Control logic</span>
@@ -248,7 +268,7 @@ export default function ControlLogicEditor({ onClose }: Props) {
<div class="cl-header-actions">
{graph && dirty && <span class="cl-dirty">unsaved</span>}
{graph && <button class="panel-btn" disabled={busy || !dirty} onClick={saveGraph}>Save</button>}
<button class="panel-btn" onClick={onClose}>Close</button>
<button class="panel-btn" onClick={requestClose}>Close</button>
</div>
</header>
@@ -299,6 +319,21 @@ export default function ControlLogicEditor({ onClose }: Props) {
)}
</div>
</div>
{showCloseConfirm && (
<div class="cl-confirm-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) setShowCloseConfirm(false); }}>
<div class="cl-confirm">
<div class="cl-confirm-title">Unsaved changes</div>
<p class="hint">This graph has unsaved changes. What would you like to do?</p>
<div class="cl-confirm-actions">
<button class="panel-btn" onClick={() => setShowCloseConfirm(false)}>Cancel</button>
<button class="panel-btn" disabled={busy}
onClick={() => { setShowCloseConfirm(false); onClose(); }}>Close without saving</button>
<button class="panel-btn panel-btn-primary" disabled={busy} onClick={saveAndClose}>Save and close</button>
</div>
</div>
</div>
)}
</div>
</div>
);
@@ -341,12 +376,15 @@ function FlowEditor({ graph, onChange }: {
} catch {}
}
// Bare-name write targets are graph-local variables; offer them under 'local'.
// Graph-local write targets (bare name, or an explicit "local:" prefix) become
// the suggestions offered when the target data source is 'local'.
const localNames = Array.from(new Set(
nodes.filter(n => n.kind === 'action.write')
.map(n => n.params.target ?? '')
.map(t => t.startsWith('local:') ? t.slice('local:'.length) : t)
.filter(t => t && !t.includes(':'))
));
// 'srv' is a registered data source, so it already appears in dataSources.
const dsOptions = ['local', 'sys', ...dataSources];
function signalOptions(ds: string): string[] {
if (ds === 'local') return localNames;
@@ -354,6 +392,15 @@ function FlowEditor({ graph, onChange }: {
return dsSignals[ds] ?? [];
}
// Flatten every known ds:name into a single list for the fuzzy SignalPicker,
// and a hook to lazily load all data sources' signals when a picker opens.
function allSignalOptions(): SignalOption[] {
const out: SignalOption[] = [];
for (const ds of dsOptions) for (const name of signalOptions(ds)) out.push({ ds, name });
return out;
}
function openAllSignals() { dsOptions.forEach(ds => loadSignals(ds)); }
const selected = nodes.find(n => n.id === selectedNode) ?? null;
useEffect(() => {
@@ -573,19 +620,13 @@ function FlowEditor({ graph, onChange }: {
<div class="wizard-section-title">{KIND_LABEL[selected.kind]}</div>
{(selected.kind === 'trigger.threshold' || selected.kind === 'trigger.change' || selected.kind === 'trigger.alarm') && (() => {
const { ds, name } = splitRef(selected.params.signal ?? '');
return (
<Fragment>
<div class="wizard-field">
<label>Data source</label>
<SearchableSelect value={ds} options={dataSources}
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'} />
<SignalPicker value={selected.params.signal ?? ''} options={allSignalOptions()}
onOpen={openAllSignals}
onChange={(ref) => patchParams(selected.id, { signal: ref })} />
</div>
{selected.kind === 'trigger.threshold' && (
<div class="wizard-field wizard-field-row">
@@ -661,7 +702,7 @@ function FlowEditor({ graph, onChange }: {
<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}
allSignals={allSignalOptions()} onOpenSignals={openAllSignals}
hint="True → 'then' port, false → 'else'. e.g. {stub:level} > 5 && temp < 100. {sys:time}=epoch seconds, {sys:dt}=seconds since the trigger last fired." />
)}
@@ -685,36 +726,30 @@ function FlowEditor({ graph, onChange }: {
<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}
allSignals={allSignalOptions()} onOpenSignals={openAllSignals}
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="Write to a data source signal, or a bare name to store a graph-local var. e.g. ({stub:a} - {stub:b}) / {sys:dt}." />
</Fragment>
);
})()}
{selected.kind === 'action.write' && (
<Fragment>
<div class="wizard-field">
<label>Target signal / variable</label>
<SignalPicker value={selected.params.target ?? ''} options={allSignalOptions()}
onOpen={openAllSignals} allowFreeform
onChange={(ref) => patchParams(selected.id, { target: ref })} />
<p class="hint">Pick a signal, or type <code>local:name</code> for a graph-local var
or <code>srv:name</code> for a server variable that panels can read.</p>
</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)}
allSignals={allSignalOptions()} onOpenSignals={openAllSignals}
hint="Write to a data source signal, a bare local var, or srv:name for a server variable panels can read. e.g. ({stub:a} - {stub:b}) / {sys:dt}." />
</Fragment>
)}
{selected.kind === 'action.delay' && (
<div class="wizard-field">
@@ -735,7 +770,7 @@ function FlowEditor({ graph, onChange }: {
<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}
allSignals={allSignalOptions()} onOpenSignals={openAllSignals}
hint="Logs this value to the server log — handy for debugging a flow." />
</Fragment>
)}
@@ -753,6 +788,54 @@ function FlowEditor({ graph, onChange }: {
</div>
)}
{selected.kind === 'action.dialog' && (
<Fragment>
<div class="wizard-field">
<label>Type</label>
<select class="prop-input" value={selected.params.kind ?? 'info'}
onChange={(e) => patchParams(selected.id, { kind: (e.target as HTMLSelectElement).value })}>
<option value="info">Info</option>
<option value="error">Error</option>
<option value="input">Input (ask for a value)</option>
</select>
</div>
<div class="wizard-field">
<label>Title</label>
<input class="prop-input" value={selected.params.title ?? ''}
placeholder="dialog title"
onInput={(e) => patchParams(selected.id, { title: (e.target as HTMLInputElement).value })} />
</div>
<div class="wizard-field">
<label>Message</label>
<textarea class="prop-input" rows={3} value={selected.params.message ?? ''}
placeholder="shown to the user"
onInput={(e) => patchParams(selected.id, { message: (e.target as HTMLTextAreaElement).value })} />
</div>
{selected.params.kind === 'input' && (
<div class="wizard-field">
<label>Response variable</label>
<input class="prop-input" value={selected.params.target ?? ''}
placeholder="srv:approved"
onInput={(e) => patchParams(selected.id, { target: (e.target as HTMLInputElement).value })} />
<p class="hint">The user's number is written here (e.g. <code>srv:approved</code>); read it on a later activation.</p>
</div>
)}
<div class="wizard-field">
<label>Users (optional)</label>
<input class="prop-input" value={selected.params.users ?? ''}
placeholder="alice, bob"
onInput={(e) => patchParams(selected.id, { users: (e.target as HTMLInputElement).value })} />
</div>
<div class="wizard-field">
<label>Groups (optional)</label>
<input class="prop-input" value={selected.params.groups ?? ''}
placeholder="operators"
onInput={(e) => patchParams(selected.id, { groups: (e.target as HTMLInputElement).value })} />
<p class="hint">Comma-separated. Leave both empty to show the dialog to every connected user.</p>
</div>
</Fragment>
)}
<button class="panel-btn" style="margin-top:1rem;" onClick={() => deleteNode(selected.id)}>Delete node</button>
</Fragment>
)}
@@ -774,17 +857,15 @@ function FlowEditor({ graph, onChange }: {
}
}
function ExprField({ label, value, onChange, onInsert, dsOptions, signalOptions, loadSignals, hint }: {
function ExprField({ label, value, onChange, onInsert, allSignals, onOpenSignals, 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;
allSignals: SignalOption[];
onOpenSignals: () => void;
hint: string;
}) {
const [insDs, setInsDs] = useState('');
const err = checkExpr(value);
return (
<div class="wizard-field">
@@ -794,11 +875,9 @@ function ExprField({ label, value, onChange, onInsert, dsOptions, signalOptions,
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'} />
<SignalPicker value="" options={allSignals} onOpen={onOpenSignals}
placeholder="+ insert signal"
onChange={(ref) => { const s = splitRef(ref); onInsert(s.ds, s.name); }} />
</div>
<p class="hint">{hint}</p>
</div>
@@ -821,6 +900,7 @@ function nodeSummary(n: CLNode): string {
case 'action.delay': return `wait ${n.params.ms || '0'} ms`;
case 'action.log': return `log ${n.params.label ? n.params.label + ': ' : ''}${n.params.expr || ''}`;
case 'action.lua': return 'Lua script';
case 'action.dialog': return `${n.params.kind || 'info'} dialog${n.params.title ? ': ' + n.params.title : ''}`;
default: return '';
}
}
-2
View File
@@ -8,7 +8,6 @@ import EditCanvas, { genWidgetId, DEFAULT_SIZES } from './EditCanvas';
import PlotPanelCanvas from './PlotPanelCanvas';
import LogicEditor from './LogicEditor';
import PropertiesPane from './PropertiesPane';
import ContextualHelp from './ContextualHelp';
import HelpModal from './HelpModal';
import ZoomControl from './ZoomControl';
import { useAuth, canWrite } from './lib/auth';
@@ -626,7 +625,6 @@ export default function EditMode({ initial, onDone }: Props) {
<div class="toolbar-right">
<ZoomControl />
<ContextualHelp mode="edit" onOpenManual={openHelp} />
<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={handleExport}>Export</button>
+107 -5
View File
@@ -403,6 +403,7 @@ function DiagramSignalTree() {
const SECTIONS = [
{ id: 'start', label: 'Getting Started' },
{ id: 'tips', label: 'Quick Tips' },
{ id: 'view', label: 'View Mode' },
{ id: 'edit', label: 'Edit Mode' },
{ id: 'widgets', label: 'Widgets' },
@@ -411,10 +412,68 @@ const SECTIONS = [
{ id: 'logic', label: 'Panel Logic' },
{ id: 'control', label: 'Control Logic' },
{ id: 'sharing', label: 'Sharing & Access' },
{ id: 'audit', label: 'Audit Log' },
{ id: 'history', label: 'Historical Data' },
{ id: 'shortcuts', label: 'Keyboard Shortcuts' },
];
// Quick tips, grouped by the mode they apply to. Each links to the manual
// section that covers it in depth. (Previously surfaced via a floating
// contextual-help popover; now consolidated here in the manual.)
const TIPS: Record<'view' | 'edit', Array<{ text: string; section: string }>> = {
view: [
{ text: 'Click any interface in the left panel to load it on the canvas.', section: 'view' },
{ text: 'Right-click a widget to view signal info or export data as CSV.', section: 'view' },
{ text: 'The ● Live button shows live streaming is active. Use the date pickers to load historical data.', section: 'history' },
{ text: 'Click Edit (top-right) to open the drag-and-drop panel builder.', section: 'edit' },
{ text: 'Use the Share button on a panel to grant access to users or groups.', section: 'sharing' },
{ text: 'The ⚙ Control logic button opens server-side automation that runs even with no panel open.', section: 'control' },
{ text: 'The 🛡 Audit button (for permitted users) opens the log of system-affecting actions.', section: 'audit' },
{ text: 'The status chip turns green and shows your username when the WebSocket is connected.', section: 'view' },
],
edit: [
{ text: 'Drag a signal from the left tree onto the canvas to create a widget.', section: 'edit' },
{ text: 'Click a widget to select it, then adjust its properties on the right.', section: 'edit' },
{ text: 'Ctrl+click to select multiple widgets; then use the align toolbar.', section: 'edit' },
{ text: 'Ctrl+Z to undo, Ctrl+Y to redo. Up to 50 steps are remembered.', section: 'shortcuts' },
{ text: 'Use "+ Synthetic" in the signal tree to create computed/filtered signals.', section: 'signals' },
{ text: 'Switch to the Logic tab to add interactive behaviour with a node-graph flow editor.', section: 'logic' },
{ text: 'Add Local variables for set-points and counters that live inside the panel.', section: 'edit' },
{ text: 'Arrow keys nudge the selected widget by 1 px (Shift for grid size).', section: 'shortcuts' },
],
};
function SectionTips({ onNavigate }: { onNavigate: (section: string) => void }) {
const groups: Array<['view' | 'edit', string]> = [
['view', 'In View mode'],
['edit', 'In Edit mode'],
];
return (
<div>
<p class="help-lead">
A handful of quick pointers to get the most out of uopi. Click any tip to jump to the
section that explains it in detail.
</p>
{groups.map(([mode, title]) => (
<div key={mode}>
<h3 class="help-h3">{title}</h3>
<ul class="help-tips-list">
{TIPS[mode].map(tip => (
<li key={tip.text}>
<button class="help-tip-item" onClick={() => onNavigate(tip.section)}>
<span class="help-tip-icon">💡</span>
<span class="help-tip-text">{tip.text}</span>
<span class="help-tip-go"></span>
</button>
</li>
))}
</ul>
</div>
))}
</div>
);
}
function SectionStart() {
return (
<div>
@@ -481,8 +540,10 @@ function SectionView() {
<h3 class="help-h3">Connection status</h3>
<p>
The coloured chip in the top-right shows the WebSocket connection state.
A red <em>"disconnected"</em> banner appears at the top while reconnecting live values will resume automatically.
The coloured chip in the top-right shows the WebSocket connection state. When connected it
reads <em>"connected as &lt;your username&gt;"</em>, so you can confirm the identity your
actions are attributed to in the audit log. A red <em>"disconnected"</em> banner appears at
the top while reconnecting live values will resume automatically.
</p>
</div>
);
@@ -677,6 +738,44 @@ function SectionSharing() {
);
}
function SectionAudit() {
return (
<div>
<p class="help-lead">
When the server is configured with <code>[audit] enabled = true</code>, every
<strong> system-affecting action</strong> is appended to an append-only log so it can be
reviewed later. Permitted users open it with the <strong>🛡 Audit</strong> button in the
view-mode toolbar.
</p>
<h3 class="help-h3">What gets recorded</h3>
<ul class="help-list">
<li><strong>Signal writes</strong> both user-initiated (set-value widgets, buttons, panel
logic) and automated writes from the control-logic engine.</li>
<li><strong>Interface changes</strong> panels created, updated, or deleted.</li>
<li><strong>Control-logic changes</strong> graphs created, updated, or deleted.</li>
</ul>
<p>Each entry records the time, the actor (username, or the graph name for automated
actions), the action, the affected data source / signal and value, the client address, and
whether it succeeded.</p>
<h3 class="help-h3">Viewing the log</h3>
<ul class="help-list">
<li>The viewer lists the most recent events first.</li>
<li>Filter by user, action, signal, or a start/end time range.</li>
<li>System (automated) actions are tagged distinctly from user actions.</li>
</ul>
<p class="help-callout">
Access to the audit log is gated by an allowlist (<code>audit.readers</code> in the config),
following the same model as logic editing: an empty list means every user may view it,
otherwise only the listed users or groups can. The 🛡 Audit button is hidden for users who
are not permitted.
</p>
</div>
);
}
function SectionWidgets() {
return (
<div>
@@ -858,8 +957,9 @@ function SectionShortcuts() {
);
}
const SECTION_COMPONENTS: Record<string, () => JSX.Element> = {
const SECTION_COMPONENTS: Record<string, (props: { onNavigate: (section: string) => void }) => JSX.Element> = {
start: SectionStart,
tips: SectionTips,
view: SectionView,
edit: SectionEdit,
widgets: SectionWidgets,
@@ -868,6 +968,7 @@ const SECTION_COMPONENTS: Record<string, () => JSX.Element> = {
logic: SectionLogic,
control: SectionControl,
sharing: SectionSharing,
audit: SectionAudit,
history: SectionHistory,
shortcuts: SectionShortcuts,
};
@@ -877,7 +978,8 @@ const SECTION_COMPONENTS: Record<string, () => JSX.Element> = {
export default function HelpModal({ initialSection = 'start', onClose }: Props) {
const [active, setActive] = useState(initialSection);
const Content = SECTION_COMPONENTS[active] ?? SectionStart;
const Content: (props: { onNavigate: (section: string) => void }) => JSX.Element =
SECTION_COMPONENTS[active] ?? SectionStart;
// Close on backdrop click
function handleBackdrop(e: MouseEvent) {
@@ -915,7 +1017,7 @@ export default function HelpModal({ initialSection = 'start', onClose }: Props)
{/* Content area */}
<div class="help-content">
<h2 class="help-h2">{SECTIONS.find(s => s.id === active)?.label}</h2>
<Content />
<Content onNavigate={setActive} />
</div>
</div>
</div>
+54 -61
View File
@@ -1,6 +1,6 @@
import { h, Fragment } from 'preact';
import { useState, useEffect, useRef } from 'preact/hooks';
import SearchableSelect from './SearchableSelect';
import SignalPicker, { SignalOption } from './SignalPicker';
import { checkExpr } from './lib/expr';
import type { LogicGraph, LogicNode, LogicNodeKind, LogicWire, StateVar, Widget } from './lib/types';
@@ -37,6 +37,11 @@ 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)
// Ports sit inside the node's padding box, offset from the node border-box origin
// by the node borders (3px colored top accent, 1px sides). Anchors add the same
// offsets so wires meet the port centers, not their top edges.
const BORDER = 1;
const BORDER_TOP = 3;
interface PaletteEntry {
kind: LogicNodeKind;
@@ -114,11 +119,11 @@ function nodeHeight(kind: LogicNodeKind): number {
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 inAnchor(n: LogicNode) { return { x: n.x + BORDER, y: n.y + BORDER_TOP + 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 };
return { x: n.x + NODE_W - BORDER, y: n.y + BORDER_TOP + PORT_TOP + i * PORT_GAP };
}
function wirePathStr(x1: number, y1: number, x2: number, y2: number): string {
@@ -180,6 +185,15 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
return dsSignals[ds] ?? [];
}
// Flatten every known ds:name into a single list for the fuzzy SignalPicker,
// and a hook to lazily load all data sources' signals when a picker opens.
function allSignalOptions(): SignalOption[] {
const out: SignalOption[] = [];
for (const ds of dsOptions) for (const name of signalOptions(ds)) out.push({ ds, name });
return out;
}
function openAllSignals() { dsOptions.forEach(ds => loadSignals(ds)); }
const selected = nodes.find(n => n.id === selectedNode) ?? null;
useEffect(() => {
@@ -520,19 +534,13 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
)}
{(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'} />
<SignalPicker value={selected.params.signal ?? ''} options={allSignalOptions()}
onOpen={openAllSignals}
onChange={(ref) => patchParams(selected.id, { signal: ref })} />
</div>
{selected.kind === 'trigger.threshold' && (
<div class="wizard-field wizard-field-row">
@@ -573,7 +581,7 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
<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}
allSignals={allSignalOptions()} onOpenSignals={openAllSignals}
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." />
)}
@@ -597,36 +605,29 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
<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}
allSignals={allSignalOptions()} onOpenSignals={openAllSignals}
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.write' && (
<Fragment>
<div class="wizard-field">
<label>Target signal / variable</label>
<SignalPicker value={selected.params.target ?? ''} options={allSignalOptions()}
onOpen={openAllSignals} allowFreeform
onChange={(ref) => patchParams(selected.id, { target: ref })} />
<p class="hint">Pick a signal, or type <code>local:name</code> to write a panel-local variable.</p>
</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)}
allSignals={allSignalOptions()} onOpenSignals={openAllSignals}
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">
@@ -647,7 +648,7 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
<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}
allSignals={allSignalOptions()} onOpenSignals={openAllSignals}
hint="Appends this value (with a timestamp) to the array. e.g. {stub:level}, or {sys:time} for the clock." />
</Fragment>
)}
@@ -683,7 +684,7 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
<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}
allSignals={allSignalOptions()} onOpenSignals={openAllSignals}
hint="Logs this value to the browser console — handy for debugging a flow." />
</Fragment>
)}
@@ -747,7 +748,7 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
<DialogFieldsEditor allowInput={false}
fields={parseDialogFields(selected)}
onChange={(f) => patchParams(selected.id, { fields: JSON.stringify(f) })}
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals} />
allSignals={allSignalOptions()} onOpenSignals={openAllSignals} />
</Fragment>
)}
@@ -768,7 +769,7 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
<DialogFieldsEditor allowInput={true}
fields={parseDialogFields(selected)}
onChange={(f) => patchParams(selected.id, { fields: JSON.stringify(f) })}
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals} />
allSignals={allSignalOptions()} onOpenSignals={openAllSignals} />
<p class="hint">Each <b>input</b> prompts the operator and writes the entered number to its
target on OK; each <b>display</b> shows a live value. Cancel aborts the flow.</p>
</Fragment>
@@ -796,17 +797,15 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
}
// Expression input with a signal-insert helper and live validation.
function ExprField({ label, value, onChange, onInsert, dsOptions, signalOptions, loadSignals, hint }: {
function ExprField({ label, value, onChange, onInsert, allSignals, onOpenSignals, 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;
allSignals: SignalOption[];
onOpenSignals: () => void;
hint: string;
}) {
const [insDs, setInsDs] = useState('');
const err = checkExpr(value);
return (
<div class="wizard-field">
@@ -816,11 +815,9 @@ function ExprField({ label, value, onChange, onInsert, dsOptions, signalOptions,
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'} />
<SignalPicker value="" options={allSignals} onOpen={onOpenSignals}
placeholder="+ insert signal"
onChange={(ref) => { const s = splitRef(ref); onInsert(s.ds, s.name); }} />
</div>
<p class="hint">{hint}</p>
</div>
@@ -934,13 +931,12 @@ function parseDialogFields(n: LogicNode): DialogFieldSpec[] {
return [];
}
function DialogFieldsEditor({ allowInput, fields, onChange, dsOptions, signalOptions, loadSignals }: {
function DialogFieldsEditor({ allowInput, fields, onChange, allSignals, onOpenSignals }: {
allowInput: boolean;
fields: DialogFieldSpec[];
onChange: (fields: DialogFieldSpec[]) => void;
dsOptions: string[];
signalOptions: (ds: string) => string[];
loadSignals: (ds: string) => void;
allSignals: SignalOption[];
onOpenSignals: () => void;
}) {
function patch(i: number, patch: Partial<DialogFieldSpec>) {
onChange(fields.map((f, j) => (j === i ? { ...f, ...patch } : f)));
@@ -953,7 +949,6 @@ function DialogFieldsEditor({ allowInput, fields, onChange, dsOptions, signalOpt
<label>{allowInput ? 'Fields' : 'Displayed values'}</label>
{fields.length === 0 && <p class="hint">None yet.</p>}
{fields.map((f, i) => {
const { ds, name } = splitRef(f.target ?? '');
return (
<div key={i} class="flow-field-edit">
<div class="flow-field-head">
@@ -972,11 +967,9 @@ function DialogFieldsEditor({ allowInput, fields, onChange, dsOptions, signalOpt
{f.type === 'input' ? (
<Fragment>
<div class="flow-row-edit">
<SearchableSelect value={ds} options={dsOptions}
onSelect={(newDs) => { loadSignals(newDs); patch(i, { target: `${newDs}:` }); }} placeholder="ds…" />
<SearchableSelect value={name} options={signalOptions(ds)}
onSelect={(sig) => patch(i, { target: `${ds}:${sig}` })}
placeholder={ds ? 'signal…' : 'pick ds'} />
<SignalPicker value={f.target ?? ''} options={allSignals}
onOpen={onOpenSignals} allowFreeform placeholder="target signal…"
onChange={(ref) => patch(i, { target: ref })} />
</div>
<input class={`prop-input${checkExpr(f.default ?? '') ? ' prop-input-error' : ''}`}
value={f.default ?? ''} placeholder="default value (expression, optional)"
+1
View File
@@ -302,6 +302,7 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
<option value="fft">FFT</option>
<option value="waterfall">Waterfall</option>
<option value="logic">Logic analyser</option>
<option value="waveform">Waveform (array)</option>
</select>
</Field>
{(w.options['plotType'] ?? 'timeseries') === 'timeseries' && (
+113
View File
@@ -0,0 +1,113 @@
import { h } from 'preact';
import { useState, useMemo } from 'preact/hooks';
// A single fuzzy-finder input for picking a signal as one "ds:name" reference,
// replacing the old stacked "Data source" + "Signal" select pair. The caller
// supplies a flattened option list (every ds:name it knows about) and, via
// onOpen, a hook to lazily load signals for all data sources when the dropdown
// is first opened. When allowFreeform is set, typing a value that matches no
// option (e.g. a new srv:/local: variable) can still be committed.
export interface SignalOption { ds: string; name: string; }
export default function SignalPicker({
value,
options,
onChange,
onOpen,
placeholder = 'Search signals…',
allowFreeform = false,
}: {
value: string; // current "ds:name" (or "")
options: SignalOption[];
onChange: (ref: string) => void;
onOpen?: () => void;
placeholder?: string;
allowFreeform?: boolean;
}) {
const [open, setOpen] = useState(false);
const [filter, setFilter] = useState('');
const filtered = useMemo(() => {
const tokens = filter.toLowerCase().split(/\s+/).filter(Boolean);
const scored = options
.map(o => ({ o, label: `${o.ds}:${o.name}` }))
.filter(({ label }) => {
const l = label.toLowerCase();
return tokens.every(t => l.includes(t));
});
// Prefer matches where the signal name (not the ds prefix) leads.
const f = filter.toLowerCase();
scored.sort((a, b) => {
const an = a.o.name.toLowerCase().startsWith(f) ? 0 : 1;
const bn = b.o.name.toLowerCase().startsWith(f) ? 0 : 1;
if (an !== bn) return an - bn;
return a.label.localeCompare(b.label);
});
return scored.slice(0, 200);
}, [options, filter]);
function openDropdown() {
setOpen(true);
setFilter('');
onOpen?.();
}
function commit(ref: string) {
onChange(ref);
setOpen(false);
setFilter('');
}
// A freeform entry is offered when the typed text looks like a usable
// reference and is not already an exact option.
const freeform = allowFreeform && filter.trim() !== ''
&& !filtered.some(({ label }) => label === filter.trim());
return (
<div class="search-select">
<div class="search-select-trigger" onClick={() => (open ? setOpen(false) : openDropdown())}>
{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="Type to search… (ds:name)"
value={filter}
onInput={(e) => setFilter((e.target as HTMLInputElement).value)}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e: KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
if (filtered.length > 0) commit(`${filtered[0].o.ds}:${filtered[0].o.name}`);
else if (freeform) commit(filter.trim());
}
if (e.key === 'Escape') { e.preventDefault(); setOpen(false); }
}}
/>
<div class="search-select-list">
{freeform && (
<div class="search-select-item" onClick={() => commit(filter.trim())}>
Use {filter.trim()}
</div>
)}
{filtered.length === 0 && !freeform && <div class="search-select-item hint">No matches</div>}
{filtered.map(({ o, label }) => (
<div
key={label}
class={`search-select-item${label === value ? ' search-select-item-selected' : ''}`}
onClick={() => commit(`${o.ds}:${o.name}`)}
>
{label}
</div>
))}
</div>
</div>
)}
{open && <div class="search-select-backdrop" onClick={() => setOpen(false)} />}
</div>
);
}
+17 -22
View File
@@ -1,10 +1,10 @@
import { h, Fragment } from 'preact';
import { useState, useEffect, useRef, useMemo } from 'preact/hooks';
import type { SignalRef, StateVar } from './lib/types';
import SyntheticWizard from './SyntheticWizard';
import SyntheticGraphEditor from './SyntheticGraphEditor';
import GroupsTree from './GroupsTree';
import ChannelFinderModal from './ChannelFinderModal';
import SignalPicker from './SignalPicker';
type TreeTab = 'sources' | 'groups';
type GroupBy = 'datasource' | 'area' | 'system' | 'ioc';
@@ -491,10 +491,11 @@ export default function SignalTree({ onDragStart, width, panelId, statevars, onS
)}
{showWizard && (
<SyntheticWizard
currentIfaceId={panelId}
<SyntheticGraphEditor
create
panelId={panelId}
onClose={() => setShowWizard(false)}
onCreated={loadAllSignals}
onSaved={loadAllSignals}
/>
)}
@@ -523,24 +524,18 @@ export default function SignalTree({ onDragStart, width, panelId, statevars, onS
</div>
<div class="wizard-body">
<div class="wizard-field">
<label>Data Source</label>
<select class="prop-select" value={manualDs} onChange={e => setManualDs((e.target as HTMLSelectElement).value)}>
{Array.from(new Set(allSignals.map(s => s.ds))).map(ds => (
<option key={ds} value={ds}>{ds}</option>
))}
{!allSignals.some(s => s.ds === 'epics') && <option value="epics">epics</option>}
</select>
</div>
<div class="wizard-field">
<label>Signal / PV Name</label>
<input
class="prop-input"
autoFocus
placeholder="e.g. MY:PV:NAME"
value={manualName}
onInput={e => setManualName((e.target as HTMLInputElement).value)}
onKeyDown={e => e.key === 'Enter' && handleManualAdd()}
/>
<label>Signal</label>
<SignalPicker
value={manualName ? `${manualDs}:${manualName}` : ''}
options={allSignals}
allowFreeform
placeholder="Search or type ds:NAME…"
onChange={(ref) => {
const i = ref.indexOf(':');
if (i < 0) { setManualDs(ref); setManualName(''); }
else { setManualDs(ref.slice(0, i)); setManualName(ref.slice(i + 1)); }
}} />
<p class="hint">Pick an existing signal, or type <code>epics:MY:PV:NAME</code> to add a new one.</p>
</div>
</div>
<div class="wizard-footer">
+575 -161
View File
@@ -1,54 +1,82 @@
import { h, Fragment } from 'preact';
import { useState, useEffect, useRef } from 'preact/hooks';
import SearchableSelect from './SearchableSelect';
import SignalPicker, { SignalOption } from './SignalPicker';
import LuaEditor from './LuaEditor';
import type { InputRef, PipelineNode, SignalDef } from './lib/types';
import type { SignalDef, SynthGraph, SynthGraphNode } from './lib/types';
import { inferNodeTypes, SynthType } from './lib/synthTypes';
interface DataSource { name: string; }
interface SignalInfo { name: string; }
interface SignalInfo { name: string; type?: string; }
interface Props {
name: string;
// Existing signal name (edit mode). Omitted/empty in create mode.
name?: string;
// When true the editor starts blank and POSTs a new signal on save.
create?: boolean;
// Interface id used to bind panel-scoped signals when creating.
panelId?: string;
onClose: () => void;
onSaved: () => void;
}
// ── Node-type catalogue ──────────────────────────────────────────────────────
// Mirrors the backend dsp node set (internal/dsp/nodes.go). The combine nodes
// (add/subtract/…) and expr take several inputs, which only the FIRST node of a
// pipeline receives — see the compile() note below.
// Mirrors the backend dsp node set (internal/dsp/nodes.go). `arity` describes how
// many input ports an op exposes:
// fixed n — exactly n inputs (gain=1, subtract/divide=2, …)
// min n — at least n inputs; one spare port appears past the wired count so the
// user can keep adding (add/multiply)
// named — the user names each input (expr/lua); ports follow params.vars
type InArity = { kind: 'fixed'; n: number } | { kind: 'min'; n: number } | { kind: 'named' };
interface NodeParam { label: string; key: string; type: 'number' | 'text' | 'lua'; default: string; }
interface OpDef { type: string; label: string; params: NodeParam[]; }
interface OpDef { type: string; label: string; arity: InArity; params: NodeParam[]; }
const OPS: OpDef[] = [
{ type: 'gain', label: 'Gain', params: [{ label: 'Factor', key: 'gain', type: 'number', default: '1' }] },
{ type: 'offset', label: 'Offset', params: [{ label: 'Offset', key: 'offset', type: 'number', default: '0' }] },
{ type: 'add', label: 'Add (Σ inputs)', params: [] },
{ type: 'subtract', label: 'Subtract (ab)', params: [] },
{ type: 'multiply', label: 'Multiply (Π)', params: [] },
{ type: 'divide', label: 'Divide (a÷b)', params: [] },
{ type: 'moving_average', label: 'Moving Average', params: [{ label: 'Window (n)', key: 'window', type: 'number', default: '10' }] },
{ type: 'rms', label: 'RMS', params: [{ label: 'Window (n)', key: 'window', type: 'number', default: '10' }] },
{ type: 'lowpass', label: 'Low-pass', params: [
{ type: 'gain', label: 'Gain', arity: { kind: 'fixed', n: 1 }, params: [{ label: 'Factor', key: 'gain', type: 'number', default: '1' }] },
{ type: 'offset', label: 'Offset', arity: { kind: 'fixed', n: 1 }, params: [{ label: 'Offset', key: 'offset', type: 'number', default: '0' }] },
{ type: 'add', label: 'Add (Σ inputs)', arity: { kind: 'min', n: 2 }, params: [] },
{ type: 'subtract', label: 'Subtract (ab)', arity: { kind: 'fixed', n: 2 }, params: [] },
{ type: 'multiply', label: 'Multiply (Π)', arity: { kind: 'min', n: 2 }, params: [] },
{ type: 'divide', label: 'Divide (a÷b)', arity: { kind: 'fixed', n: 2 }, params: [] },
{ type: 'moving_average', label: 'Moving Average', arity: { kind: 'fixed', n: 1 }, params: [{ label: 'Window (n)', key: 'window', type: 'number', default: '10' }] },
{ type: 'rms', label: 'RMS', arity: { kind: 'fixed', n: 1 }, params: [{ label: 'Window (n)', key: 'window', type: 'number', default: '10' }] },
{ type: 'lowpass', label: 'Low-pass', arity: { kind: 'fixed', n: 1 }, params: [
{ label: 'Cutoff freq (Hz)', key: 'freq', type: 'number', default: '1' },
{ label: 'Order (18)', key: 'order', type: 'number', default: '1' },
]},
{ type: 'derivative', label: 'Derivative', params: [] },
{ type: 'integrate', label: 'Integrate', params: [] },
{ type: 'clamp', label: 'Clamp', params: [{ label: 'Min', key: 'min', type: 'number', default: '0' }, { label: 'Max', key: 'max', type: 'number', default: '100' }] },
{ type: 'threshold', label: 'Threshold', params: [
{ type: 'derivative', label: 'Derivative', arity: { kind: 'fixed', n: 1 }, params: [] },
{ type: 'integrate', label: 'Integrate', arity: { kind: 'fixed', n: 1 }, params: [] },
{ type: 'clamp', label: 'Clamp', arity: { kind: 'fixed', n: 1 }, params: [{ label: 'Min', key: 'min', type: 'number', default: '0' }, { label: 'Max', key: 'max', type: 'number', default: '100' }] },
{ type: 'threshold', label: 'Threshold', arity: { kind: 'fixed', n: 1 }, params: [
{ label: 'Threshold', key: 'threshold', type: 'number', default: '0' },
{ label: 'High output', key: 'high', type: 'number', default: '1' },
{ label: 'Low output', key: 'low', type: 'number', default: '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: 'expr', label: 'Formula', arity: { kind: 'named' }, params: [{ label: 'Expression', key: 'expr', type: 'text', default: 'a + b' }] },
{ type: 'lua', label: 'Lua Script', arity: { kind: 'named' }, params: [{ label: 'Script', key: 'script', type: 'lua', default: 'return a + b' }] },
// ── Array (waveform) ops ──
{ type: 'index', label: 'Index (a[i])', arity: { kind: 'fixed', n: 1 }, params: [{ label: 'Index (i)', key: 'i', type: 'number', default: '0' }] },
{ type: 'slice', label: 'Slice', arity: { kind: 'fixed', n: 1 }, params: [
{ label: 'Start', key: 'start', type: 'number', default: '0' },
{ label: 'End (0 = to end)', key: 'end', type: 'number', default: '0' },
]},
{ type: 'sum', label: 'Sum (Σ array)', arity: { kind: 'fixed', n: 1 }, params: [] },
{ type: 'mean', label: 'Mean (array)', arity: { kind: 'fixed', n: 1 }, params: [] },
{ type: 'min', label: 'Min (array)', arity: { kind: 'fixed', n: 1 }, params: [] },
{ type: 'max', label: 'Max (array)', arity: { kind: 'fixed', n: 1 }, params: [] },
{ type: 'length', label: 'Length', arity: { kind: 'fixed', n: 1 }, params: [] },
{ type: 'fft', label: 'FFT (magnitude)', arity: { kind: 'fixed', n: 1 }, params: [] },
];
const OP_BY_TYPE = new Map(OPS.map(o => [o.type, o]));
function opLabel(type: string): string { return OP_BY_TYPE.get(type)?.label ?? type; }
function opParamDefs(type: string): NodeParam[] { return OP_BY_TYPE.get(type)?.params ?? []; }
function opArity(type?: string): InArity { return OP_BY_TYPE.get(type ?? '')?.arity ?? { kind: 'fixed', n: 1 }; }
// ── Graph model (UI only — compiled to SignalDef on save) ───────────────────
// Default input names a,b,c,… for named (expr/lua) nodes.
function letters(n: number): string[] {
return Array.from({ length: Math.max(1, n) }, (_, i) => String.fromCharCode(97 + i));
}
// ── Graph model (UI only — compiled to SynthGraph on save) ──────────────────
type NodeKind = 'source' | 'op' | 'output';
interface GNode {
id: string;
@@ -58,9 +86,10 @@ interface GNode {
ds?: string; // source
signal?: string; // source
op?: string; // op type
params?: Record<string, any>; // op
params?: Record<string, any>; // op (named ops carry params.vars: string[])
}
interface GWire { from: string; to: string; }
// A wire terminates on a specific input port (index) of the target node.
interface GWire { from: string; to: string; toPort: number; }
interface Graph { nodes: GNode[]; wires: GWire[]; }
// ── Geometry (rem-based, like the logic editor) ─────────────────────────────
@@ -69,23 +98,75 @@ const REM = (() => {
return parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
})();
const NODE_W = 10 * REM;
const PORT_TOP = 1.4 * REM;
const PORT_TOP = 1.7 * REM; // y of the first input/output port row
const PORT_GAP = 1.25 * REM; // vertical spacing between stacked input ports
const PORT_R = 0.375 * REM;
// Ports are absolutely positioned inside the node's padding box, which is offset
// from the node's border-box origin (node.x/node.y) by the node borders: a 3px
// colored accent on top and 1px on the sides. Wire anchors must add the same
// offsets or they land at the top edge of the port circle instead of its center.
const BORDER = 1;
const BORDER_TOP = 3;
function genId(): string { return 'g_' + Math.random().toString(36).slice(2, 9); }
function hasInput(k: NodeKind): boolean { return k !== 'source'; }
function hasOutput(k: NodeKind): boolean { return k !== 'output'; }
function inAnchor(n: GNode) { return { x: n.x, y: n.y + PORT_TOP }; }
function outAnchor(n: GNode) { return { x: n.x + NODE_W, y: n.y + PORT_TOP }; }
// Number of input ports a node currently shows.
function inPortCount(n: GNode, wires: GWire[]): number {
if (n.kind === 'source') return 0;
if (n.kind === 'output') return 1;
const ar = opArity(n.op);
if (ar.kind === 'fixed') return ar.n;
if (ar.kind === 'named') return Math.max(1, (n.params?.vars as string[] | undefined)?.length ?? 0);
// min: expose one spare port beyond the highest wired index so the user can add more.
const wired = wires.filter(w => w.to === n.id).length;
return Math.max(ar.n, wired + 1);
}
function inAnchor(n: GNode, port: number) {
return { x: n.x + BORDER, y: n.y + BORDER_TOP + PORT_TOP + port * PORT_GAP };
}
function outAnchor(n: GNode) { return { x: n.x + NODE_W - BORDER, y: n.y + BORDER_TOP + PORT_TOP }; }
function nodeMinHeight(n: GNode, wires: GWire[]): number {
const nIn = Math.max(1, inPortCount(n, wires));
return BORDER_TOP + PORT_TOP + nIn * 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}`;
}
// Build an initial graph by laying out an existing SignalDef: sources stacked on
// the left, the linear pipeline as a row of op nodes, output on the right.
// Label shown beside an input port (named ops show their var name).
function portLabel(n: GNode, port: number): string {
if (opArity(n.op).kind === 'named') {
const vars = (n.params?.vars as string[] | undefined) ?? [];
return vars[port] ?? '';
}
return '';
}
// Build an initial graph from an existing SignalDef. Prefer the stored DAG; fall
// back to laying out the legacy linear Inputs+Pipeline form.
function buildInitial(def: SignalDef): Graph {
const inputs: InputRef[] = def.inputs && def.inputs.length > 0
if (def.graph && def.graph.nodes.length > 0) {
const nodes: GNode[] = def.graph.nodes.map((n, i) => ({
id: n.id,
kind: n.kind,
x: n.x ?? (2 + (i % 3) * 12) * REM,
y: n.y ?? (2 + Math.floor(i / 3) * 5) * REM,
ds: n.ds,
signal: n.signal,
op: n.op,
params: n.params ? { ...n.params } : undefined,
}));
const wires: GWire[] = [];
for (const n of def.graph.nodes) {
(n.inputs ?? []).forEach((from, port) => wires.push({ from, to: n.id, toPort: port }));
}
return { nodes, wires };
}
// Legacy linear layout: sources stacked left, pipeline a row of op nodes, output right.
const inputs = def.inputs && def.inputs.length > 0
? def.inputs
: (def.ds && def.signal ? [{ ds: def.ds, signal: def.signal }] : []);
const pipeline = def.pipeline ?? [];
@@ -99,57 +180,108 @@ function buildInitial(def: SignalDef): Graph {
});
const opIds = pipeline.map((nd, i) => {
const id = genId();
nodes.push({ id, kind: 'op', x: (15 + i * 12) * REM, y: 3 * REM, op: nd.type, params: { ...nd.params } });
const params: Record<string, any> = { ...nd.params };
// The head op received every source as a,b,c,…; later ops a single input.
if (opArity(nd.type).kind === 'named') params.vars = letters(i === 0 ? srcIds.length : 1);
nodes.push({ id, kind: 'op', x: (15 + i * 12) * REM, y: 3 * REM, op: nd.type, params });
return id;
});
const outId = genId();
nodes.push({ id: outId, kind: 'output', x: (15 + pipeline.length * 12) * REM, y: 3 * REM });
const headId = opIds[0] ?? outId;
srcIds.forEach(s => wires.push({ from: s, to: headId }));
for (let i = 0; i < opIds.length - 1; i++) wires.push({ from: opIds[i], to: opIds[i + 1] });
if (opIds.length > 0) wires.push({ from: opIds[opIds.length - 1], to: outId });
srcIds.forEach((s, i) => wires.push({ from: s, to: headId, toPort: i }));
for (let i = 0; i < opIds.length - 1; i++) wires.push({ from: opIds[i], to: opIds[i + 1], toPort: 0 });
if (opIds.length > 0) wires.push({ from: opIds[opIds.length - 1], to: outId, toPort: 0 });
return { nodes, wires };
}
// Compile the visual graph back into the backend's linear form. The pipeline is
// a single chain ending at the output; the head node receives every source
// signal (inputs[0]=a, inputs[1]=b, …), every later node the previous output.
function compile(g: Graph): { inputs?: InputRef[]; pipeline?: PipelineNode[]; error?: string } {
// Compile the visual graph into the backend DAG form. Each op/output node's
// inputs are its wires ordered by target port index.
function compile(g: Graph): SynthGraph {
const output = g.nodes.find(n => n.kind === 'output');
if (!output) return { error: 'missing output node' };
const byId = new Map(g.nodes.map(n => [n.id, n]));
const upstream = (id: string) => g.wires.filter(w => w.to === id).map(w => byId.get(w.from)).filter(Boolean) as GNode[];
const nodes: SynthGraphNode[] = g.nodes.map(n => {
const inputs = g.wires
.filter(w => w.to === n.id)
.slice()
.sort((a, b) => a.toPort - b.toPort)
.map(w => w.from);
if (n.kind === 'source') return { id: n.id, kind: 'source', ds: n.ds, signal: n.signal, x: n.x, y: n.y };
if (n.kind === 'output') return { id: n.id, kind: 'output', inputs, x: n.x, y: n.y };
return { id: n.id, kind: 'op', op: n.op, params: n.params, inputs, x: n.x, y: n.y };
});
return { nodes, output: output?.id ?? '' };
}
const chain: GNode[] = [];
const seen = new Set<string>();
let cur: GNode = output;
for (;;) {
if (seen.has(cur.id)) return { error: 'the graph contains a cycle' };
seen.add(cur.id);
const ups = upstream(cur.id);
const opUps = ups.filter(n => n.kind === 'op');
const srcUps = ups.filter(n => n.kind === 'source');
if (opUps.length > 1) return { error: 'a node may have only one upstream node — the pipeline must be a single chain' };
if (opUps.length === 1) {
if (srcUps.length > 0) return { error: 'only the first processing node may take input signals directly' };
chain.unshift(opUps[0]);
cur = opUps[0];
// Detect a cycle in the graph (Kahn count over input edges).
function hasCycle(g: Graph): boolean {
const indeg = new Map<string, number>();
const succ = new Map<string, string[]>();
for (const n of g.nodes) indeg.set(n.id, 0);
for (const w of g.wires) {
if (!indeg.has(w.to) || !indeg.has(w.from)) continue;
indeg.set(w.to, (indeg.get(w.to) ?? 0) + 1);
succ.set(w.from, [...(succ.get(w.from) ?? []), w.to]);
}
const queue = g.nodes.filter(n => (indeg.get(n.id) ?? 0) === 0).map(n => n.id);
let visited = 0;
while (queue.length) {
const id = queue.shift()!;
visited++;
for (const s of succ.get(id) ?? []) {
indeg.set(s, (indeg.get(s) ?? 0) - 1);
if ((indeg.get(s) ?? 0) === 0) queue.push(s);
}
}
return visited !== g.nodes.length;
}
// Validate the graph; return a per-node error map plus the first message found.
function validate(g: Graph): { errors: Map<string, string>; first?: string } {
const errors = new Map<string, string>();
const set = (id: string, msg: string) => { if (!errors.has(id)) errors.set(id, msg); };
if (!g.nodes.some(n => n.kind === 'output')) {
return { errors, first: 'missing output node' };
}
for (const n of g.nodes) {
const ports = new Set(g.wires.filter(w => w.to === n.id).map(w => w.toPort));
if (n.kind === 'source') {
if (!n.ds || !n.signal) set(n.id, 'pick a data source and signal');
continue;
}
// Reached the head of the chain: its source upstreams become the inputs.
if (srcUps.length === 0) {
return { error: cur.kind === 'output' ? 'connect at least one signal to the output' : 'the first node has no input signals' };
if (n.kind === 'output') {
if (!ports.has(0)) set(n.id, 'connect a value to the output');
continue;
}
const ar = opArity(n.op);
if (ar.kind === 'fixed' || ar.kind === 'named') {
const cnt = ar.kind === 'fixed' ? ar.n : ((n.params?.vars as string[] | undefined)?.length ?? 0);
if (cnt === 0) { set(n.id, 'add at least one named input'); continue; }
for (let i = 0; i < cnt; i++) {
if (!ports.has(i)) { set(n.id, 'all inputs must be connected'); break; }
}
} else {
// min: needs ≥ ar.n contiguous inputs starting at port 0.
const cnt = ports.size;
if (cnt < ar.n) { set(n.id, `needs at least ${ar.n} inputs`); continue; }
for (let i = 0; i < cnt; i++) {
if (!ports.has(i)) { set(n.id, 'inputs have a gap — reconnect them'); break; }
}
}
const inputs = srcUps
.slice()
.sort((a, b) => a.y - b.y)
.map(n => ({ ds: n.ds || '', signal: n.signal || '' }));
if (inputs.some(i => !i.ds || !i.signal)) return { error: 'every input signal needs a data source and a signal' };
const pipeline = chain.map(n => ({ type: n.op!, params: n.params ?? {} }));
return { inputs, pipeline };
}
let first: string | undefined;
if (hasCycle(g)) first = 'the graph contains a cycle';
if (!first) {
for (const n of g.nodes) {
const m = errors.get(n.id);
if (m) { first = `${n.kind === 'op' ? opLabel(n.op ?? '') : n.kind}: ${m}`; break; }
}
}
return { errors, first };
}
function nodeSummary(n: GNode): string {
@@ -170,7 +302,7 @@ function nodeSummary(n: GNode): string {
}
}
export default function SyntheticGraphEditor({ name, onClose, onSaved }: Props) {
export default function SyntheticGraphEditor({ name, create, panelId, onClose, onSaved }: Props) {
const [def, setDef] = useState<SignalDef | null>(null);
const [graph, setGraph] = useState<Graph>({ nodes: [], wires: [] });
const [selected, setSelected] = useState<string | null>(null);
@@ -178,9 +310,21 @@ export default function SyntheticGraphEditor({ name, onClose, onSaved }: Props)
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
// Quick-add HUD (S = signal, N = node); null when closed.
const [hud, setHud] = useState<null | 'signal' | 'node'>(null);
const [hudFilter, setHudFilter] = useState('');
// Identity fields — editable only when creating a new signal.
const [newName, setNewName] = useState('');
const [visibility, setVisibility] = useState<'panel' | 'user' | 'global'>(panelId ? 'panel' : 'user');
const [unit, setUnit] = useState('');
const [desc, setDesc] = useState('');
const [dispLow, setDispLow] = useState('0');
const [dispHigh, setDispHigh] = useState('100');
const sigName = create ? newName.trim() : (name ?? '');
const [dataSources, setDataSources] = useState<string[]>([]);
const [dsSignals, setDsSignals] = useState<Record<string, string[]>>({});
const [dsSignals, setDsSignals] = useState<Record<string, SignalInfo[]>>({});
const canvasRef = useRef<HTMLDivElement>(null);
const dragNode = useRef<{ id: string; dx: number; dy: number; pushed: boolean } | null>(null);
@@ -210,22 +354,67 @@ export default function SyntheticGraphEditor({ name, onClose, onSaved }: Props)
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) }));
setDsSignals(prev => ({ ...prev, [ds]: sigs }));
} catch {}
}
// Flatten every known ds:name into a single list for the fuzzy SignalPicker,
// and a hook to lazily load all data sources' signals when a picker opens.
function allSignalOptions(): SignalOption[] {
const out: SignalOption[] = [];
for (const ds of dataSources) for (const s of (dsSignals[ds] ?? [])) out.push({ ds, name: s.name });
return out;
}
function openAllSignals() { dataSources.forEach(ds => loadSignals(ds)); }
// Resolve a source node's data type from the upstream signal's metadata.
// 'float64[]' is the only array type today; anything else is scalar, and an
// unresolved/unloaded source is 'unknown' (no error, runtime typing wins).
function sourceSynthType(n: SynthGraphNode): SynthType {
if (!n.ds || !n.signal) return 'unknown';
const sigs = dsSignals[n.ds];
if (!sigs) return 'unknown';
const info = sigs.find(s => s.name === n.signal);
if (!info || !info.type) return 'unknown';
return info.type === 'float64[]' ? 'array' : 'scalar';
}
// HUD filtering: case-insensitive substring over signal name/ds and op label/type.
function filteredSignalOptions(): SignalOption[] {
const q = hudFilter.trim().toLowerCase();
const all = allSignalOptions();
if (!q) return all;
return all.filter(o => o.name.toLowerCase().includes(q) || o.ds.toLowerCase().includes(q));
}
function filteredOps(): OpDef[] {
const q = hudFilter.trim().toLowerCase();
if (!q) return OPS;
return OPS.filter(o => o.label.toLowerCase().includes(q) || o.type.toLowerCase().includes(q));
}
useEffect(() => {
fetch(`/api/v1/synthetic/${encodeURIComponent(name)}`)
if (create) {
const blank: SignalDef = { name: '', inputs: [], pipeline: [], meta: {} };
setDef(blank);
setGraph(buildInitial(blank));
setLoading(false);
return;
}
fetch(`/api/v1/synthetic/${encodeURIComponent(name ?? '')}`)
.then(r => r.ok ? r.json() : Promise.reject(r.statusText))
.then((d: SignalDef) => {
setDef(d);
setUnit(d.meta?.unit ?? '');
setDesc(d.meta?.description ?? '');
setDispLow(String(d.meta?.displayLow ?? '0'));
setDispHigh(String(d.meta?.displayHigh ?? '100'));
const g = buildInitial(d);
setGraph(g);
g.nodes.forEach(n => { if (n.kind === 'source' && n.ds) loadSignals(n.ds); });
})
.catch(e => setError(String(e)))
.finally(() => setLoading(false));
}, [name]);
}, [name, create]);
// ── History ────────────────────────────────────────────────────────────────
function pushUndo() {
@@ -260,9 +449,24 @@ export default function SyntheticGraphEditor({ name, onClose, onSaved }: Props)
commit({ nodes: [...graph.nodes, node], wires: graph.wires });
setSelected(node.id); setSelectedWire(null);
}
// Add a source node pre-filled with a chosen ds:signal (used by the quick-add HUD).
function addSourceWith(ds: string, signal: string) {
const node: GNode = { id: genId(), kind: 'source', x: 2 * REM, y: (2 + graph.nodes.filter(n => n.kind === 'source').length * 5) * REM, ds, signal };
if (ds) loadSignals(ds);
commit({ nodes: [...graph.nodes, node], wires: graph.wires });
setSelected(node.id); setSelectedWire(null);
}
// Open the quick-add HUD; for signals, lazily load every source's signal list.
function openHud(kind: 'signal' | 'node') {
if (kind === 'signal') openAllSignals();
setHudFilter('');
setHud(kind);
}
function closeHud() { setHud(null); setHudFilter(''); }
function addOp(op: OpDef, x?: number, y?: number) {
const params: Record<string, any> = {};
for (const p of op.params) params[p.key] = p.type === 'number' ? parseFloat(p.default) : p.default;
if (op.arity.kind === 'named') params.vars = ['a', 'b'];
const node: GNode = { id: genId(), kind: 'op', op: op.type, params, x: x ?? (14 + (graph.nodes.length % 4) * 3) * REM, y: y ?? (3 + (graph.nodes.length % 4) * 2) * REM };
commit({ nodes: [...graph.nodes, node], wires: graph.wires });
setSelected(node.id); setSelectedWire(null);
@@ -281,8 +485,45 @@ export default function SyntheticGraphEditor({ name, onClose, onSaved }: Props)
wires: graph.wires,
});
}
// Named-input (expr/lua) variable list editing.
function addNamedVar(id: string) {
commit({
nodes: graph.nodes.map(n => {
if (n.id !== id) return n;
const vars = [...((n.params?.vars as string[] | undefined) ?? [])];
vars.push(letters(vars.length + 1)[vars.length]);
return { ...n, params: { ...n.params, vars } };
}),
wires: graph.wires,
});
}
function renameNamedVar(id: string, idx: number, value: string) {
commit({
nodes: graph.nodes.map(n => {
if (n.id !== id) return n;
const vars = [...((n.params?.vars as string[] | undefined) ?? [])];
vars[idx] = value;
return { ...n, params: { ...n.params, vars } };
}),
wires: graph.wires,
});
}
function removeNamedVar(id: string, idx: number) {
const nodes = graph.nodes.map(n => {
if (n.id !== id) return n;
const vars = [...((n.params?.vars as string[] | undefined) ?? [])];
vars.splice(idx, 1);
return { ...n, params: { ...n.params, vars } };
});
// Drop the wire at the removed port and shift higher ports down by one.
const wires = graph.wires
.filter(w => !(w.to === id && w.toPort === idx))
.map(w => (w.to === id && w.toPort > idx ? { ...w, toPort: w.toPort - 1 } : w));
commit({ nodes, wires });
}
function moveNode(id: string, x: number, y: number, record: boolean) {
commit({ nodes: graph.nodes.map(n => (n.id === id ? { ...n, x, y } : n)), wires: graph.wires }, record);
const g = graphRef.current;
commit({ nodes: g.nodes.map(n => (n.id === id ? { ...n, x, y } : n)), wires: g.wires }, record);
}
function deleteNode(id: string) {
const n = graph.nodes.find(x => x.id === id);
@@ -290,19 +531,29 @@ export default function SyntheticGraphEditor({ name, onClose, onSaved }: Props)
commit({ nodes: graph.nodes.filter(x => x.id !== id), wires: graph.wires.filter(w => w.from !== id && w.to !== id) });
if (selected === id) setSelected(null);
}
function addWire(from: string, to: string) {
function addWire(from: string, to: string, toPort: number) {
if (from === to) return;
const fn = graph.nodes.find(n => n.id === from);
const tn = graph.nodes.find(n => n.id === to);
if (!fn || !tn || !hasOutput(fn.kind) || !hasInput(tn.kind)) return;
if (graph.wires.some(w => w.from === from && w.to === to)) return;
commit({ nodes: graph.nodes, wires: [...graph.wires, { from, to }] });
if (!fn || !tn || !hasOutput(fn.kind) || tn.kind === 'source') return;
// One wire per input port: replace any existing wire on that port.
const wires = graph.wires.filter(w => !(w.to === to && w.toPort === toPort));
if (wires.some(w => w.from === from && w.to === to && w.toPort === toPort)) return;
commit({ nodes: graph.nodes, wires: [...wires, { from, to, toPort }] });
}
function deleteWire(idx: number) {
commit({ nodes: graph.nodes, wires: graph.wires.filter((_, i) => i !== idx) });
if (selectedWire === idx) setSelectedWire(null);
}
// The lowest input port of `target` not yet wired (for node-body drops).
function firstFreePort(target: GNode): number {
const used = new Set(graphRef.current.wires.filter(w => w.to === target.id).map(w => w.toPort));
const cnt = inPortCount(target, graphRef.current.wires);
for (let i = 0; i < cnt; i++) if (!used.has(i)) return i;
return cnt; // all full — append (min-arity nodes grow)
}
// ── Pointer / drag / wire ──────────────────────────────────────────────────
function toCanvas(e: MouseEvent) {
const el = canvasRef.current!;
@@ -314,48 +565,52 @@ export default function SyntheticGraphEditor({ name, onClose, onSaved }: Props)
const p = toCanvas(e);
dragNode.current = { id: node.id, dx: p.x - node.x, dy: p.y - node.y, pushed: false };
setSelected(node.id); setSelectedWire(null);
window.addEventListener('mousemove', onNodeDragMove);
window.addEventListener('mouseup', onNodeDragUp);
}
function onNodeDragMove(e: MouseEvent) {
const d = dragNode.current;
if (!d) return;
const p = toCanvas(e);
const record = !d.pushed;
d.pushed = true;
moveNode(d.id, Math.max(0, p.x - d.dx), Math.max(0, p.y - d.dy), record);
}
function onNodeDragUp() {
dragNode.current = null;
window.removeEventListener('mousemove', onNodeDragMove);
window.removeEventListener('mouseup', onNodeDragUp);
}
function startWire(e: MouseEvent, node: GNode) {
e.stopPropagation();
const p = toCanvas(e);
setPendingWire({ from: node.id, x: p.x, y: p.y });
window.addEventListener('mousemove', onWireMove);
window.addEventListener('mouseup', onWireUp);
}
function onWireMove(e: MouseEvent) {
const cur = pendingRef.current;
if (!cur) return;
const p = toCanvas(e);
setPendingWire({ ...cur, x: p.x, y: p.y });
}
function endWire() {
pendingRef.current = null;
window.removeEventListener('mousemove', onWireMove);
window.removeEventListener('mouseup', onWireUp);
setPendingWire(null);
}
function onWireUp() { endWire(); }
function finishWire(target: GNode) {
function finishWire(target: GNode, port: number) {
const cur = pendingRef.current;
if (cur && hasInput(target.kind)) addWire(cur.from, target.id);
if (cur && target.kind !== 'source') addWire(cur.from, target.id, port);
endWire();
}
// Single mount-time pointer handler. Reads live drag/wire state from refs so it
// never goes stale across re-renders — fixes the "can't drop a dragged node" bug.
useEffect(() => {
function onMove(e: MouseEvent) {
const d = dragNode.current;
if (d) {
const p = toCanvas(e);
const record = !d.pushed;
d.pushed = true;
moveNode(d.id, Math.max(0, p.x - d.dx), Math.max(0, p.y - d.dy), record);
return;
}
const cur = pendingRef.current;
if (cur) {
const p = toCanvas(e);
setPendingWire({ ...cur, x: p.x, y: p.y });
}
}
function onUp() {
dragNode.current = null;
if (pendingRef.current) endWire();
}
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
return () => {
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
};
}, []);
// ── Palette drag-and-drop ──────────────────────────────────────────────────
const DRAG_MIME = 'application/x-uopi-synth-op';
function onCanvasDragOver(e: DragEvent) {
@@ -382,30 +637,66 @@ export default function SyntheticGraphEditor({ name, onClose, onSaved }: Props)
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) return;
if (e.key === 'Escape') { if (hud) closeHud(); return; }
if (e.key.toLowerCase() === 's') { e.preventDefault(); openHud('signal'); return; }
if (e.key.toLowerCase() === 'n') { e.preventDefault(); openHud('node'); return; }
if (e.key !== 'Delete' && e.key !== 'Backspace') return;
if (selectedWire !== null) deleteWire(selectedWire);
else if (selected) deleteNode(selected);
}
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [selected, selectedWire, graph]);
}, [selected, selectedWire, graph, hud]);
const byId = new Map(graph.nodes.map(n => [n.id, n]));
const sel = graph.nodes.find(n => n.id === selected) ?? null;
const compiled = compile(graph);
const { errors: nodeErrors, first: validationError } = validate(graph);
// Static type propagation: infer each node's output type (scalar/array) to
// colour wires and surface type-incompatible wirings as node errors. Mirrors
// the backend dsp.OpOutputType rules (see lib/synthTypes.ts).
const { types: nodeTypes, errors: typeErrors } = inferNodeTypes(compile(graph), sourceSynthType);
for (const [id, msg] of typeErrors) if (!nodeErrors.has(id)) nodeErrors.set(id, msg);
const typeError = !validationError ? [...typeErrors.values()][0] : undefined;
const firstError = validationError ?? typeError;
async function handleSave() {
if (!def) return;
if (compiled.error) { setError(compiled.error); return; }
if (create && !sigName) { setError('Enter a name for the new signal.'); return; }
if (firstError) { setError(firstError); return; }
setSaving(true);
setError('');
try {
const body = { ...def, inputs: compiled.inputs, pipeline: compiled.pipeline, ds: undefined, signal: undefined };
const res = await fetch(`/api/v1/synthetic/${encodeURIComponent(name)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const meta = {
...def.meta,
unit: unit || undefined,
description: desc || undefined,
displayLow: parseFloat(dispLow),
displayHigh: parseFloat(dispHigh),
};
const body: any = {
...def,
name: sigName,
graph: compile(graph),
meta,
inputs: undefined,
pipeline: undefined,
ds: undefined,
signal: undefined,
};
if (create) {
body.visibility = visibility;
if (visibility === 'panel' && panelId) body.panel = panelId;
}
const res = await fetch(
create ? '/api/v1/synthetic' : `/api/v1/synthetic/${encodeURIComponent(sigName)}`,
{
method: create ? 'POST' : 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
},
);
if (!res.ok) {
const j = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(j.error ?? res.statusText);
@@ -421,14 +712,15 @@ export default function SyntheticGraphEditor({ name, onClose, onSaved }: Props)
function nodeClass(n: GNode): string {
const cat = n.kind === 'source' ? 'trigger' : n.kind === 'output' ? 'action' : 'flow';
return `flow-node flow-node-${cat}${selected === n.id ? ' flow-node-selected' : ''}`;
const err = nodeErrors.has(n.id) ? ' flow-node-error' : '';
return `flow-node flow-node-${cat}${selected === n.id ? ' flow-node-selected' : ''}${err}`;
}
return (
<div class="wizard-backdrop" onClick={onClose}>
<div class="synth-graph" onClick={(e) => e.stopPropagation()}>
<div class="wizard-header">
<span>Synthetic Signal {name}</span>
<span>Synthetic Signal{create ? ' — new' : `${name}`}</span>
<button class="icon-btn" onClick={onClose}></button>
</div>
@@ -452,8 +744,8 @@ export default function SyntheticGraphEditor({ name, onClose, onSaved }: Props)
onClick={() => addOp(op)}>{op.label}</button>
))}
<div class="flow-palette-hint hint">
Wire input signals into the first operation, chain operations, and connect the
last one to <b>Output</b>. The first node receives every input as <code>a,b,c,d</code>.
Wire signals into each operation's input ports, then connect the result
to <b>Output</b>. Formula / Lua nodes name their own inputs.
</div>
</div>
@@ -466,10 +758,11 @@ export default function SyntheticGraphEditor({ name, onClose, onSaved }: Props)
{graph.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); const p2 = inAnchor(b);
const p1 = outAnchor(a); const p2 = inAnchor(b, w.toPort);
const tClass = nodeTypes.get(w.from) === 'array' ? ' synth-wire-array' : ' synth-wire-scalar';
return (
<path key={idx}
class={`flow-wire${selectedWire === idx ? ' flow-wire-selected' : ''}`}
class={`flow-wire${tClass}${selectedWire === idx ? ' flow-wire-selected' : ''}`}
d={wirePathStr(p1.x, p1.y, p2.x, p2.y)}
onClick={(e) => { e.stopPropagation(); setSelectedWire(idx); setSelected(null); }} />
);
@@ -482,54 +775,114 @@ export default function SyntheticGraphEditor({ name, onClose, onSaved }: Props)
})()}
</svg>
{graph.nodes.map(node => (
<div key={node.id}
class={nodeClass(node)}
style={`left:${node.x}px; top:${node.y}px; width:${NODE_W}px;`}
onMouseDown={(e) => startNodeDrag(e, node)}
onMouseUp={() => { if (pendingRef.current) finishWire(node); }}>
<div class="flow-node-header">
<span class="flow-node-title">{node.kind === 'source' ? 'Signal' : node.kind === 'output' ? 'Output' : opLabel(node.op ?? '')}</span>
{node.kind !== 'output' && (
<button class="flow-node-del" title="Delete node"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); deleteNode(node.id); }}></button>
{graph.nodes.map(node => {
const nIn = inPortCount(node, graph.wires);
return (
<div key={node.id}
class={nodeClass(node)}
title={nodeErrors.get(node.id) || undefined}
style={`left:${node.x}px; top:${node.y}px; width:${NODE_W}px; min-height:${nodeMinHeight(node, graph.wires)}px;`}
onMouseDown={(e) => startNodeDrag(e, node)}
onMouseUp={() => { if (pendingRef.current) finishWire(node, firstFreePort(node)); }}>
<div class="flow-node-header">
<span class="flow-node-title">{node.kind === 'source' ? 'Signal' : node.kind === 'output' ? 'Output' : opLabel(node.op ?? '')}</span>
{node.kind !== 'output' && (
<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>
{Array.from({ length: nIn }, (_, port) => {
const label = portLabel(node, port);
return (
<Fragment key={port}>
<div class="flow-port flow-port-in" title={label || `Input ${port + 1}`}
style={`top:${PORT_TOP + port * PORT_GAP - PORT_R}px; left:${-PORT_R}px;`}
onMouseDown={(e) => e.stopPropagation()}
onMouseUp={(e) => { e.stopPropagation(); finishWire(node, port); }} />
{label && (
<span class="flow-port-label-in"
style={`top:${PORT_TOP + port * PORT_GAP - 0.6 * REM}px;`}>{label}</span>
)}
</Fragment>
);
})}
{hasOutput(node.kind) && (
<div class="flow-port flow-port-out" title="Output"
style={`top:${PORT_TOP - PORT_R}px; right:${-PORT_R}px;`}
onMouseDown={(e) => startWire(e, node)} />
)}
</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); }} />
)}
{hasOutput(node.kind) && (
<div class="flow-port flow-port-out" title="Output"
style={`top:${PORT_TOP - PORT_R}px; right:${-PORT_R}px;`}
onMouseDown={(e) => startWire(e, node)} />
)}
</div>
))}
);
})}
</div>
</div>
<div class="flow-inspector">
{!sel && <div class="hint">Select a node to edit it, or add one from the palette.</div>}
{!sel && (
<Fragment>
<div class="wizard-section-title">Signal</div>
{create ? (
<Fragment>
<div class="wizard-field">
<label>Name</label>
<input class="prop-input" type="text" value={newName}
placeholder="my_signal"
onInput={(e) => setNewName((e.target as HTMLInputElement).value)} />
</div>
<div class="wizard-field">
<label>Visibility</label>
<select class="prop-input" value={visibility}
onChange={(e) => setVisibility((e.target as HTMLSelectElement).value as any)}>
{panelId && <option value="panel">This panel only</option>}
<option value="user">My signals</option>
<option value="global">Global (all users)</option>
</select>
</div>
</Fragment>
) : (
<p class="hint">Editing <b>{name}</b>.</p>
)}
<div class="wizard-field">
<label>Unit</label>
<input class="prop-input" type="text" value={unit}
onInput={(e) => setUnit((e.target as HTMLInputElement).value)} />
</div>
<div class="wizard-field">
<label>Description</label>
<input class="prop-input" type="text" value={desc}
onInput={(e) => setDesc((e.target as HTMLInputElement).value)} />
</div>
<div class="wizard-field">
<label>Display low</label>
<input class="prop-input" type="number" value={dispLow}
onInput={(e) => setDispLow((e.target as HTMLInputElement).value)} />
</div>
<div class="wizard-field">
<label>Display high</label>
<input class="prop-input" type="number" value={dispHigh}
onInput={(e) => setDispHigh((e.target as HTMLInputElement).value)} />
</div>
<div class="flow-palette-hint hint">Select a node to edit it, or add one from the palette.</div>
</Fragment>
)}
{sel?.kind === 'source' && (
<Fragment>
<div class="wizard-section-title">Input signal</div>
<div class="wizard-field">
<label>Data source</label>
<SearchableSelect value={sel.ds ?? ''} options={dataSources}
onSelect={(ds) => { loadSignals(ds); patchNode(sel.id, { ds, signal: '' }); }} />
</div>
<div class="wizard-field">
<label>Signal</label>
<SearchableSelect value={sel.signal ?? ''} options={dsSignals[sel.ds ?? ''] ?? []}
onSelect={(signal) => patchNode(sel.id, { signal })}
placeholder={sel.ds ? 'Search signals…' : 'Select data source first'} />
<SignalPicker
value={sel.signal ? `${sel.ds ?? ''}:${sel.signal}` : ''}
options={allSignalOptions()}
onOpen={openAllSignals}
onChange={(ref) => {
const i = ref.indexOf(':');
if (i < 0) patchNode(sel.id, { ds: ref, signal: '' });
else patchNode(sel.id, { ds: ref.slice(0, i), signal: ref.slice(i + 1) });
}} />
</div>
<button class="panel-btn" style="margin-top:1rem;" onClick={() => deleteNode(sel.id)}>Delete node</button>
</Fragment>
@@ -538,8 +891,23 @@ export default function SyntheticGraphEditor({ name, onClose, onSaved }: Props)
{sel?.kind === 'op' && (
<Fragment>
<div class="wizard-section-title">{opLabel(sel.op ?? '')}</div>
{opParamDefs(sel.op ?? '').length === 0 && (
<p class="hint">No parameters this operation transforms its input directly.</p>
{opArity(sel.op).kind === 'named' && (
<div class="wizard-field">
<label>Inputs</label>
{((sel.params?.vars as string[] | undefined) ?? []).map((v, i) => (
<div key={i} class="synth-var-row">
<input class="prop-input" type="text" value={v}
onInput={(e) => renameNamedVar(sel.id, i, (e.target as HTMLInputElement).value)} />
<button class="icon-btn" title="Remove input"
onClick={() => removeNamedVar(sel.id, i)}></button>
</div>
))}
<button class="panel-btn" style="margin-top:0.4rem;" onClick={() => addNamedVar(sel.id)}>+ Input</button>
<p class="hint" style="margin-top:0.4rem;">Use these names in the {sel.op === 'lua' ? 'script' : 'formula'} below.</p>
</div>
)}
{opParamDefs(sel.op ?? '').length === 0 && opArity(sel.op).kind !== 'named' && (
<p class="hint">No parameters this operation transforms its inputs directly.</p>
)}
{opParamDefs(sel.op ?? '').map(pd => (
<div key={pd.key} class="wizard-field">
@@ -568,11 +936,57 @@ export default function SyntheticGraphEditor({ name, onClose, onSaved }: Props)
</div>
)}
{hud && (
<div class="synth-hud-backdrop" onMouseDown={closeHud}>
<div class="synth-hud" onMouseDown={(e) => e.stopPropagation()}>
<input
class="synth-hud-input"
autoFocus
placeholder={hud === 'signal' ? 'Add signal — filter by name…' : 'Add node — filter operations…'}
value={hudFilter}
onInput={(e) => setHudFilter((e.target as HTMLInputElement).value)}
onKeyDown={(e) => {
if (e.key === 'Escape') { e.preventDefault(); closeHud(); return; }
if (e.key === 'Enter') {
e.preventDefault();
if (hud === 'signal') {
const opt = filteredSignalOptions()[0];
if (opt) { addSourceWith(opt.ds, opt.name); closeHud(); }
} else {
const op = filteredOps()[0];
if (op) { addOp(op); closeHud(); }
}
}
}} />
<div class="synth-hud-list">
{hud === 'signal'
? filteredSignalOptions().slice(0, 50).map(opt => (
<button key={`${opt.ds}:${opt.name}`} class="synth-hud-item"
onClick={() => { addSourceWith(opt.ds, opt.name); closeHud(); }}>
<span class="synth-hud-item-name">{opt.name}</span>
<span class="synth-hud-item-meta">{opt.ds}</span>
</button>
))
: filteredOps().map(op => (
<button key={op.type} class="synth-hud-item"
onClick={() => { addOp(op); closeHud(); }}>
<span class="synth-hud-item-name">{op.label}</span>
<span class="synth-hud-item-meta">{op.type}</span>
</button>
))}
{hud === 'signal' && filteredSignalOptions().length === 0 && (
<div class="hint synth-hud-empty">No matching signals.</div>
)}
</div>
</div>
</div>
)}
<div class="wizard-footer">
{error && <span class="wizard-error" style="flex:1;">{error}</span>}
{!error && compiled.error && <span class="hint" style="flex:1;">{compiled.error}</span>}
{!error && firstError && <span class="hint" style="flex:1;">{firstError}</span>}
<button class="toolbar-btn" onClick={onClose}>Cancel</button>
<button class="toolbar-btn toolbar-btn-primary" onClick={handleSave} disabled={saving || loading || !!compiled.error}>
<button class="toolbar-btn toolbar-btn-primary" onClick={handleSave} disabled={saving || loading || !!firstError}>
{saving ? 'Saving…' : 'Save Signal'}
</button>
</div>
-279
View File
@@ -1,279 +0,0 @@
import { h } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import LuaEditor from './LuaEditor';
import SearchableSelect from './SearchableSelect';
import type { InputRef, SignalDef } from './lib/types';
interface Props {
onClose: () => void;
onCreated: () => void;
// Interface id the wizard was opened from — used to bind panel-scoped signals.
currentIfaceId?: string;
}
interface NodeParam {
label: string;
key: string;
type: 'number' | 'text' | 'lua';
default: string;
}
const NODE_TYPES: Array<{ type: string; label: string; params: NodeParam[] }> = [
{ type: 'gain', label: 'Gain', params: [{ label: 'Factor', key: 'gain', type: 'number', default: '1' }] },
{ type: 'offset', label: 'Offset', params: [{ label: 'Offset', key: 'offset', type: 'number', default: '0' }] },
{ type: 'moving_average', label: 'Moving Average', params: [{ label: 'Window (n)', key: 'window', type: 'number', default: '10' }] },
{ type: 'rms', label: 'RMS', params: [{ label: 'Window (n)', key: 'window', type: 'number', default: '10' }] },
{ type: 'lowpass', label: 'Low-pass Filter', params: [
{ label: 'Cutoff freq (Hz)', key: 'freq', type: 'number', default: '1' },
{ label: 'Order (18)', key: 'order', type: 'number', default: '1' },
]},
{ type: 'derivative', label: 'Derivative', params: [] },
{ type: 'integrate', label: 'Integrate', params: [] },
{ type: 'clamp', label: 'Clamp', params: [{ label: 'Min', key: 'min', type: 'number', default: '0' }, { label: 'Max', key: 'max', type: 'number', default: '100' }] },
{ type: 'expr', label: 'Formula', params: [{ label: 'Expression (a,b,c,d = inputs)', key: 'expr', type: 'text', default: 'a * 1.0' }] },
{ type: 'lua', label: 'Lua Script', params: [{ label: 'Script (a,b,c,d = inputs)', key: 'script', type: 'lua', default: 'return a' }] },
];
interface DataSource {
name: string;
}
interface SignalInfo {
name: string;
description?: string;
}
// ── Main Wizard ──────────────────────────────────────────────────────────────
export default function SyntheticWizard({ onClose, onCreated, currentIfaceId }: Props) {
const [name, setName] = useState('');
const [inputs, setInputs] = useState<InputRef[]>([{ ds: 'stub', signal: 'sine_1hz' }]);
const [nodeType, setNodeType] = useState('gain');
const [params, setParams] = useState<Record<string, string>>({});
// Visibility scope. Panel scope requires an interface to bind to, so when the
// wizard is opened outside a saved panel it falls back to 'user'.
const [visibility, setVisibility] = useState<'panel' | 'user' | 'global'>(currentIfaceId ? 'panel' : 'user');
const [unit, setUnit] = useState('');
const [desc, setDesc] = useState('');
const [dispLow, setDispLow] = useState('0');
const [dispHigh, setDispHigh] = useState('100');
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
// Loaded data
const [dataSources, setDataSources] = useState<string[]>([]);
const [dsSignals, setDsSignals] = useState<Record<string, string[]>>({});
useEffect(() => {
fetch('/api/v1/datasources')
.then(r => r.ok ? r.json() : [])
.then((ds: DataSource[]) => setDataSources(ds.map(d => d.name)))
.catch(() => {});
}, []);
async function loadSignals(ds: string) {
if (dsSignals[ds]) return;
try {
const res = await fetch(`/api/v1/signals?ds=${encodeURIComponent(ds)}`);
if (!res.ok) return;
const sigs: SignalInfo[] = await res.json();
setDsSignals(prev => ({ ...prev, [ds]: sigs.map(s => s.name) }));
} catch {}
}
const nodeDef = NODE_TYPES.find(n => n.type === nodeType)!;
function getParam(key: string, def: string): string {
return params[key] ?? def;
}
function setParam(key: string, val: string) {
setParams(p => ({ ...p, [key]: val }));
}
function updateInput(idx: number, patch: Partial<InputRef>) {
setInputs(prev => prev.map((inp, i) => {
if (i !== idx) return inp;
const next = { ...inp, ...patch };
if (patch.ds) {
next.signal = '';
loadSignals(patch.ds);
}
return next;
}));
}
function addInput() {
setInputs(prev => [...prev, { ds: dataSources[0] || '', signal: '' }]);
}
function removeInput(idx: number) {
setInputs(prev => prev.filter((_, i) => i !== idx));
}
async function handleCreate() {
if (!name.trim()) { setError('Name is required'); return; }
if (inputs.some(i => !i.ds || !i.signal)) { setError('All inputs must have a data source and signal'); return; }
const nodeParams: Record<string, any> = {};
for (const p of nodeDef.params) {
const raw = getParam(p.key, p.default);
nodeParams[p.key] = p.type === 'number' ? parseFloat(raw) : raw;
}
const def: SignalDef = {
name: name.trim(),
inputs,
pipeline: [{ type: nodeType, params: nodeParams }],
meta: {
unit: unit || undefined,
description: desc || undefined,
displayLow: parseFloat(dispLow) || 0,
displayHigh: parseFloat(dispHigh) || 100,
},
visibility,
...(visibility === 'panel' && currentIfaceId ? { panel: currentIfaceId } : {}),
};
setSaving(true);
setError('');
try {
const res = await fetch('/api/v1/synthetic', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(def),
});
if (!res.ok) {
const j = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(j.error ?? res.statusText);
}
onCreated();
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setSaving(false);
}
}
return (
<div class="wizard-backdrop" onClick={onClose}>
<div class="wizard" onClick={(e) => e.stopPropagation()}>
<div class="wizard-header">
<span>New Synthetic Signal</span>
<button class="icon-btn" onClick={onClose}></button>
</div>
<div class="wizard-body">
{error && <p class="wizard-error">{error}</p>}
<div class="wizard-section-title">Signal identity</div>
<div class="wizard-field">
<label>Name</label>
<input class="prop-input" value={name}
placeholder="e.g. smoothed_pressure"
onInput={(e) => setName((e.target as HTMLInputElement).value)} />
</div>
<div class="wizard-field">
<label>Visibility</label>
<select class="prop-select" value={visibility}
onChange={(e) => setVisibility((e.target as HTMLSelectElement).value as any)}>
<option value="panel" disabled={!currentIfaceId}>
This panel only{currentIfaceId ? '' : ' (save panel first)'}
</option>
<option value="user">All my panels</option>
<option value="global">All panels (global)</option>
</select>
</div>
<div class="wizard-section-title">Input signals</div>
{inputs.map((inp, idx) => (
<div key={idx} class="wizard-field wizard-field-row" style="align-items: flex-end; gap: 0.5rem; margin-bottom: 0.5rem;">
<div class="wizard-field" style="flex:1;">
<label>Data source</label>
<SearchableSelect
value={inp.ds}
options={dataSources}
onSelect={(ds) => updateInput(idx, { ds })}
/>
</div>
<div class="wizard-field" style="flex:2;">
<label>Signal</label>
<SearchableSelect
value={inp.signal}
options={dsSignals[inp.ds] || []}
onSelect={(signal) => updateInput(idx, { signal })}
placeholder={inp.ds ? 'Search signals…' : 'Select data source first'}
/>
</div>
<button
class="icon-btn"
style="margin-bottom: 0.25rem;"
title="Remove input"
onClick={() => removeInput(idx)}
disabled={inputs.length === 1}
></button>
</div>
))}
<button class="panel-btn" style="margin-top: 0.5rem;" onClick={addInput}>+ Add input</button>
<div class="wizard-section-title" style="margin-top: 1.5rem;">Processing</div>
<div class="wizard-field">
<label>Node type</label>
<select class="prop-select" value={nodeType}
onChange={(e) => { setNodeType((e.target as HTMLSelectElement).value); setParams({}); }}>
{NODE_TYPES.map(n => <option key={n.type} value={n.type}>{n.label}</option>)}
</select>
</div>
{nodeDef.params.map(p => (
<div key={p.key} class="wizard-field">
<label>{p.label}</label>
{p.type === 'lua' ? (
<LuaEditor
value={getParam(p.key, p.default)}
onChange={(v) => setParam(p.key, v)}
/>
) : (
<input class="prop-input" type={p.type === 'number' ? 'number' : 'text'}
value={getParam(p.key, p.default)}
onInput={(e) => setParam(p.key, (e.target as HTMLInputElement).value)} />
)}
</div>
))}
<div class="wizard-section-title">Metadata (optional)</div>
<div class="wizard-field wizard-field-row">
<div class="wizard-field">
<label>Unit</label>
<input class="prop-input" value={unit} placeholder="m/s"
onInput={(e) => setUnit((e.target as HTMLInputElement).value)} />
</div>
<div class="wizard-field">
<label>Display low</label>
<input class="prop-input" type="number" value={dispLow}
onInput={(e) => setDispLow((e.target as HTMLInputElement).value)} />
</div>
<div class="wizard-field">
<label>Display high</label>
<input class="prop-input" type="number" value={dispHigh}
onInput={(e) => setDispHigh((e.target as HTMLInputElement).value)} />
</div>
</div>
<div class="wizard-field">
<label>Description</label>
<input class="prop-input" value={desc} placeholder="Optional description"
onInput={(e) => setDesc((e.target as HTMLInputElement).value)} />
</div>
</div>
<div class="wizard-footer">
<button class="toolbar-btn" onClick={onClose}>Cancel</button>
<button class="toolbar-btn toolbar-btn-primary" onClick={handleCreate} disabled={saving}>
{saving ? 'Creating…' : 'Create Signal'}
</button>
</div>
</div>
</div>
);
}
+34 -8
View File
@@ -7,10 +7,11 @@ import { parseInterface } from './lib/xml';
import { newPlotPanel } from './lib/templates';
import { useAuth, canWrite } from './lib/auth';
import type { Interface } from './lib/types';
import ContextualHelp from './ContextualHelp';
import HelpModal from './HelpModal';
import ZoomControl from './ZoomControl';
import ControlLogicEditor from './ControlLogicEditor';
import AuditViewer from './AuditViewer';
import ConfigManager from './ConfigManager';
interface Props {
onEdit?: (iface?: Interface) => void;
@@ -33,6 +34,8 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) {
const [helpSection, setHelpSection] = useState('start');
const [showTimeNav, setShowTimeNav] = useState(false);
const [showControlLogic, setShowControlLogic] = useState(false);
const [showAudit, setShowAudit] = useState(false);
const [showConfig, setShowConfig] = useState(false);
const [leftW, setLeftW] = useState(220);
const [listCollapsed, setListCollapsed] = useState(false);
const me = useAuth();
@@ -137,9 +140,13 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) {
)}
</div>
<div class="toolbar-right">
<div class={`status-chip ${wsStatus}`}>
<div
class={`status-chip ${wsStatus}`}
title={me.user ? `Signed in as ${me.user}${writable ? '' : ' (read-only)'}` : undefined}
>
<span class="status-dot"></span>
{wsStatus === 'connected' && me.user ? `connected as ${me.user}` : wsStatus}
{me.user && !writable && ' (read-only)'}
</div>
<button
class={`toolbar-btn${showTimeNav ? ' toolbar-btn-active' : ''}`}
@@ -148,7 +155,6 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) {
>
History
</button>
<ContextualHelp mode="view" onOpenManual={openHelp} />
<button
class="icon-btn help-manual-btn"
onClick={() => openHelp('start')}
@@ -166,6 +172,24 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) {
Control logic
</button>
)}
{me.canViewAudit && (
<button
class="toolbar-btn"
onClick={() => setShowAudit(true)}
title="View the audit log"
>
🛡 Audit
</button>
)}
{writable && (
<button
class="toolbar-btn"
onClick={() => setShowConfig(true)}
title="Open configuration manager"
>
🗂 Config
</button>
)}
{writable && (
<button
class="btn-edit"
@@ -175,11 +199,6 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) {
Edit
</button>
)}
{me.user && (
<span class="user-chip" title={`Signed in as ${me.user}${writable ? '' : ' (read-only)'}`}>
{me.user}{!writable && ' (read-only)'}
</span>
)}
</div>
</header>
@@ -257,6 +276,13 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) {
{showControlLogic && (
<ControlLogicEditor onClose={() => setShowControlLogic(false)} />
)}
{showAudit && (
<AuditViewer onClose={() => setShowAudit(false)} />
)}
{showConfig && (
<ConfigManager onClose={() => setShowConfig(false)} />
)}
</div>
);
}
+2 -1
View File
@@ -4,7 +4,7 @@ import type { Me, AccessLevel } from './types';
// Default identity used before /api/v1/me resolves (or if it fails): assume a
// trusted LAN user with full access, matching the backend default.
export const DEFAULT_ME: Me = { user: '', level: 'write', groups: [], canEditLogic: true };
export const DEFAULT_ME: Me = { user: '', level: 'write', groups: [], canEditLogic: true, canViewAudit: true };
// AuthContext carries the resolved identity + global access level for the
// current user throughout the app.
@@ -37,6 +37,7 @@ export async function fetchMe(): Promise<Me> {
level: (data.level as AccessLevel) ?? 'write',
groups: Array.isArray(data.groups) ? data.groups : [],
canEditLogic: data.canEditLogic !== false,
canViewAudit: data.canViewAudit !== false,
};
} catch {
return DEFAULT_ME;
+24
View File
@@ -0,0 +1,24 @@
import { writable } from './store';
// A dialog request pushed by a server-side control-logic action.dialog node and
// delivered over the WebSocket. Unlike panel logic dialogs (lib/logic.ts) these
// originate on the server and are addressed to the connected user by identity.
export interface ControlDialog {
id: string;
kind: 'info' | 'error' | 'input';
title?: string;
message?: string;
}
// Active control-logic dialogs awaiting the user's acknowledgement / input.
export const controlDialogs = writable<ControlDialog[]>([]);
// pushControlDialog is invoked by the WS client on a "dialog" message.
export function pushControlDialog(d: ControlDialog): void {
controlDialogs.update(list => (list.some(x => x.id === d.id) ? list : [...list, d]));
}
// dismissControlDialog removes a dialog once answered or cancelled.
export function dismissControlDialog(id: string): void {
controlDialogs.update(list => list.filter(d => d.id !== id));
}
+86
View File
@@ -0,0 +1,86 @@
// Static type propagation for the synthetic node-graph editor. This mirrors the
// Go runtime/compile rules in internal/dsp/types.go (OpOutputType) so the editor
// can colour wires by data type and flag type-incompatible wirings before save.
// Keep the two in sync — a parity test guards the pair.
import type { SynthGraph, SynthGraphNode } from './types';
export type SynthType = 'scalar' | 'array' | 'unknown';
// reductionOps collapse an array (or scalar) to a single scalar.
const REDUCTION_OPS = new Set(['index', 'length', 'sum', 'mean', 'min', 'max']);
// arrayProducerOps require an array input and yield an array.
const ARRAY_PRODUCER_OPS = new Set(['fft', 'slice']);
// scalarOnlyOps reject array inputs and yield a scalar (stateful filters + lua).
const SCALAR_ONLY_OPS = new Set(['moving_average', 'rms', 'lowpass', 'derivative', 'integrate', 'lua']);
// opOutputType reports an op's output type given its input types, plus an error
// message when the inputs are definitely incompatible with the op. 'unknown'
// inputs (a source whose real type isn't known yet) never trigger an error —
// runtime typing is authoritative.
export function opOutputType(op: string, inputs: SynthType[]): { type: SynthType; error?: string } {
if (REDUCTION_OPS.has(op)) return { type: 'scalar' };
if (ARRAY_PRODUCER_OPS.has(op)) {
if (inputs.some(t => t === 'scalar')) return { type: 'unknown', error: `${op} requires an array input` };
return { type: 'array' };
}
if (SCALAR_ONLY_OPS.has(op)) {
if (inputs.some(t => t === 'array')) return { type: 'unknown', error: `${op} does not accept an array input` };
return { type: 'scalar' };
}
// Elementwise stateless ops (gain, offset, add, subtract, multiply, divide,
// clamp, threshold, expr): array if any input is an array, scalar if all
// inputs are definitely scalar, otherwise unknown.
if (inputs.some(t => t === 'array')) return { type: 'array' };
if (inputs.some(t => t === 'unknown')) return { type: 'unknown' };
return { type: 'scalar' };
}
// inferNodeTypes walks the DAG in dependency order and assigns each node an
// output type. Source node types come from `sourceType` (resolved from upstream
// signal metadata); unresolved sources default to 'unknown'. `errors` collects
// per-node type-conflict messages for the editor's validation.
export function inferNodeTypes(
g: SynthGraph,
sourceType: (n: SynthGraphNode) => SynthType,
): { types: Map<string, SynthType>; errors: Map<string, string> } {
const types = new Map<string, SynthType>();
const errors = new Map<string, string>();
const byId = new Map(g.nodes.map(n => [n.id, n]));
// Kahn topological order over node inputs.
const indeg = new Map<string, number>();
const succ = new Map<string, string[]>();
for (const n of g.nodes) indeg.set(n.id, 0);
for (const n of g.nodes) {
for (const from of n.inputs ?? []) {
if (!byId.has(from)) continue;
indeg.set(n.id, (indeg.get(n.id) ?? 0) + 1);
succ.set(from, [...(succ.get(from) ?? []), n.id]);
}
}
const queue = g.nodes.filter(n => (indeg.get(n.id) ?? 0) === 0).map(n => n.id);
while (queue.length) {
const id = queue.shift()!;
const n = byId.get(id)!;
if (n.kind === 'source') {
types.set(id, sourceType(n));
} else if (n.kind === 'output') {
const from = (n.inputs ?? [])[0];
types.set(id, (from && types.get(from)) || 'unknown');
} else {
const inTypes = (n.inputs ?? []).map(f => types.get(f) ?? 'unknown');
const { type, error } = opOutputType(n.op ?? '', inTypes);
types.set(id, type);
if (error) errors.set(id, error);
}
for (const s of succ.get(id) ?? []) {
indeg.set(s, (indeg.get(s) ?? 0) - 1);
if ((indeg.get(s) ?? 0) === 0) queue.push(s);
}
}
return { types, errors };
}
+28 -1
View File
@@ -9,6 +9,9 @@ export interface SignalValue {
value: any;
quality: 'good' | 'uncertain' | 'bad' | 'unknown';
ts: string | null;
// Raw EPICS alarm fields (absent/0 = NO_ALARM). severity: 1=MINOR,2=MAJOR,3=INVALID.
severity?: number;
status?: number;
}
// Metadata for a signal (received once on subscribe)
@@ -209,6 +212,9 @@ export interface Me {
// Whether the user may add/edit panel logic and server-side control logic.
// False only when a logic-editors allowlist is configured and excludes them.
canEditLogic: boolean;
// Whether the user may view the audit log. False only when an audit-readers
// allowlist is configured and excludes them, or auditing is disabled.
canViewAudit: boolean;
}
// Effective permission a user holds on a single panel or folder.
@@ -269,12 +275,33 @@ export interface PipelineNode {
params: Record<string, any>;
}
// DAG form of a synthetic signal (mirrors backend synthetic.Graph). A node is a
// source (ds:signal root), an op (DSP node with ordered `inputs`), or the single
// output. When present it supersedes the legacy inputs+pipeline linear form.
export type SynthNodeKind = 'source' | 'op' | 'output';
export interface SynthGraphNode {
id: string;
kind: SynthNodeKind;
op?: string;
params?: Record<string, any>;
ds?: string;
signal?: string;
inputs?: string[]; // upstream node ids, in input order
x?: number;
y?: number;
}
export interface SynthGraph {
nodes: SynthGraphNode[];
output: string;
}
export interface SignalDef {
name: string;
ds?: string; // legacy single input
signal?: string; // legacy single input
inputs?: InputRef[];
pipeline: PipelineNode[];
pipeline?: PipelineNode[];
graph?: SynthGraph; // DAG form (preferred when present)
meta: {
unit?: string;
description?: string;
+21
View File
@@ -1,5 +1,6 @@
import { writable, type Readable } from './store';
import { writeLocalState } from './localstate';
import { pushControlDialog } from './controldialogs';
import type { SignalRef, SignalValue, SignalMeta, HistoryPoint } from './types';
// ── Types ────────────────────────────────────────────────────────────────────
@@ -130,6 +131,8 @@ class WsClient {
value: msg.value,
quality: msg.quality ?? 'unknown',
ts: msg.ts ?? null,
severity: msg.severity ?? 0,
status: msg.status ?? 0,
};
for (const s of subs) s.onUpdate(val);
break;
@@ -161,6 +164,16 @@ class WsClient {
}
break;
}
case 'dialog': {
// Server-side control logic requesting a user notification / input.
pushControlDialog({
id: String(msg.id),
kind: msg.kind === 'error' || msg.kind === 'input' ? msg.kind : 'info',
title: msg.title,
message: msg.message,
});
break;
}
case 'error':
// Resolve any pending history callbacks with empty data on error
if (key && this.histCallbacks.has(key)) {
@@ -268,6 +281,14 @@ class WsClient {
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 });
}
/**
* Answer a control-logic input dialog. `value === null` cancels (dismisses)
* the dialog without writing its target server variable.
*/
sendDialogResponse(id: string, value: number | null): void {
this._send({ type: 'dialogResponse', id, value });
}
}
// Singleton instance
+564 -81
View File
@@ -123,6 +123,8 @@ body {
color: #94a3b8;
border: 1px solid #334155;
text-transform: capitalize;
white-space: nowrap;
flex-shrink: 0;
}
.status-chip.connected {
@@ -165,18 +167,6 @@ body {
}
/* ── User identity chip ──────────────────────────────────────────────────── */
.user-chip {
display: flex;
align-items: center;
font-size: 0.75rem;
padding: 0.2rem 0.6rem;
border-radius: 9999px;
background: #1e293b;
color: #cbd5e1;
border: 1px solid #334155;
white-space: nowrap;
}
/* ── Access denied screen ────────────────────────────────────────────────── */
.access-denied {
display: flex;
@@ -385,6 +375,23 @@ body {
/* width/height set inline from interface dimensions */
}
/* View mode: widgets shed their card chrome (border/background/shadow) so they
blend with the canvas background. Edit mode (EditCanvas) keeps the chrome so
widgets stay visible and selectable. Internal elements (gauge arc, bar track,
plot chart, inputs) keep their own styling. */
.canvas-view-bare .textview,
.canvas-view-bare .gauge,
.canvas-view-bare .barh,
.canvas-view-bare .barv,
.canvas-view-bare .led-widget,
.canvas-view-bare .multiled-widget,
.canvas-view-bare .setvalue,
.canvas-view-bare .plot-widget {
background: transparent;
border-color: transparent;
box-shadow: none;
}
/* ── TextLabel widget ──────────────────────────────────────────────────────── */
.textlabel {
@@ -1258,6 +1265,12 @@ body {
.flow-wire-selected { stroke: #ef4444; stroke-width: 2.5; }
.flow-wire-pending { stroke: #3b82f6; stroke-dasharray: 5 4; }
/* Synthetic-editor data-type wire colouring (scoped to .synth-graph so the
Logic / ControlLogic editors that share .flow-wire are unaffected). Scalar
keeps the default slate; array (waveform) signals are purple. */
.synth-graph .flow-wire.synth-wire-array { stroke: #a855f7; }
.synth-graph .flow-wire.synth-wire-array:hover { stroke: #c084fc; }
/* Node block */
.flow-node {
position: absolute;
@@ -1273,6 +1286,8 @@ body {
.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-error { border-color: #ef4444; box-shadow: 0 0 0 2px #ef444455; }
.flow-node-error.flow-node-selected { box-shadow: 0 0 0 2px #ef4444aa; }
.flow-node-header {
display: flex;
@@ -1319,6 +1334,24 @@ body {
pointer-events: none;
}
/* Named input ports (Formula/Lua) show their variable name beside the port. */
.flow-port-label-in {
position: absolute;
left: 0.55rem;
font-size: 0.6rem;
color: #94a3b8;
pointer-events: none;
}
/* Named-input editor row in the inspector */
.synth-var-row {
display: flex;
gap: 0.35rem;
align-items: center;
margin-bottom: 0.3rem;
}
.synth-var-row > input { flex: 1; min-width: 0; }
/* Expression field: signal-insert helper row */
.flow-expr-insert {
display: flex;
@@ -1992,8 +2025,64 @@ body {
display: flex;
flex-direction: column;
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
position: relative;
}
/* Quick-add HUD (S = signal, N = node) */
.synth-hud-backdrop {
position: absolute;
inset: 0;
background: rgba(10, 14, 22, 0.45);
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 12vh;
z-index: 20;
}
.synth-hud {
width: 26rem;
max-width: 80%;
background: #1a1f2e;
border: 1px solid #3a4660;
border-radius: 8px;
box-shadow: 0 12px 40px rgba(0,0,0,0.55);
display: flex;
flex-direction: column;
overflow: hidden;
}
.synth-hud-input {
border: none;
border-bottom: 1px solid #2d3748;
background: #0f1117;
color: #e2e8f0;
font-size: 0.9rem;
padding: 0.6rem 0.75rem;
outline: none;
}
.synth-hud-list {
max-height: 18rem;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.synth-hud-item {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.5rem;
padding: 0.4rem 0.75rem;
background: none;
border: none;
color: #e2e8f0;
font-size: 0.85rem;
text-align: left;
cursor: pointer;
}
.synth-hud-item:hover { background: #243049; }
.synth-hud-item-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.synth-hud-item-meta { color: #64748b; font-size: 0.72rem; flex-shrink: 0; }
.synth-hud-empty { padding: 0.6rem 0.75rem; }
.wizard-header {
display: flex;
align-items: center;
@@ -2746,90 +2835,50 @@ kbd {
color: #e2e8f0;
}
/* ── Contextual Help ─────────────────────────────────────────────────────── */
/* ── Manual: Quick Tips list ─────────────────────────────────────────────── */
.ctx-help {
position: relative;
display: flex;
align-items: center;
}
.ctx-help-btn {
width: 22px;
height: 22px;
border-radius: 50%;
border: 1.5px solid #4a5568;
background: #1a2236;
color: #94a3b8;
font-size: 0.78rem;
font-weight: 700;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: border-color 0.15s, color 0.15s;
flex-shrink: 0;
line-height: 1;
.help-tips-list {
list-style: none;
margin: 0 0 1rem;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.ctx-help-btn:hover { border-color: #4a9eff; color: #4a9eff; }
.ctx-help-popover {
position: absolute;
top: calc(100% + 8px);
right: 0;
z-index: 400;
.help-tip-item {
display: flex;
align-items: center;
gap: 0.6rem;
width: 100%;
text-align: left;
background: #1a1f2e;
border: 1px solid #2d3748;
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
width: 280px;
padding: 0.75rem;
}
.ctx-help-tip {
display: flex;
gap: 0.5rem;
font-size: 0.82rem;
border-radius: 6px;
padding: 0.55rem 0.7rem;
color: #cbd5e1;
line-height: 1.5;
margin-bottom: 0.6rem;
font-size: 0.85rem;
line-height: 1.45;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
}
.help-tip-item:hover {
border-color: #4a9eff;
background: #1e2540;
}
.ctx-help-icon {
font-size: 1rem;
.help-tip-icon {
flex-shrink: 0;
margin-top: 0.05rem;
font-size: 1rem;
}
.ctx-help-footer {
display: flex;
justify-content: space-between;
align-items: center;
border-top: 1px solid #2d3748;
padding-top: 0.5rem;
gap: 0.5rem;
}
.help-tip-text { flex: 1; }
.ctx-help-nav {
font-size: 0.75rem;
color: #64748b;
background: none;
border: none;
cursor: pointer;
padding: 0;
}
.ctx-help-nav:hover { color: #94a3b8; }
.ctx-help-link {
font-size: 0.75rem;
.help-tip-go {
flex-shrink: 0;
color: #4a9eff;
background: none;
border: none;
cursor: pointer;
padding: 0;
font-weight: 700;
}
.ctx-help-link:hover { text-decoration: underline; }
/* ── Historical time-nav bar (below main toolbar, hidden by default) ──────── */
@@ -3446,6 +3495,37 @@ kbd {
overflow: hidden;
}
.cl-confirm-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
display: flex;
align-items: center;
justify-content: center;
z-index: 650;
}
.cl-confirm {
background: #1a1f2e;
border: 1px solid #2d3748;
border-radius: 10px;
padding: 1.25rem 1.5rem;
width: min(440px, 92vw);
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.6);
}
.cl-confirm-title {
font-weight: 600;
margin-bottom: 0.5rem;
}
.cl-confirm-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 1.25rem;
}
.cl-header {
display: flex;
align-items: center;
@@ -3455,6 +3535,127 @@ kbd {
flex-shrink: 0;
}
/* ── Audit log viewer ──────────────────────────────────────────────────── */
.audit-modal {
width: min(1300px, 98vw);
}
.audit-filters {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.6rem 1.25rem;
border-bottom: 1px solid #2d3748;
flex-shrink: 0;
}
.audit-filter {
background: #0f1420;
border: 1px solid #2d3748;
border-radius: 5px;
color: #e2e8f0;
padding: 0.3rem 0.5rem;
font-size: 0.8rem;
}
.audit-filter-label {
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.75rem;
color: #94a3b8;
}
.audit-body {
flex: 1;
overflow: auto;
padding: 0 0 0.5rem;
}
.audit-empty {
padding: 1.5rem 1.25rem;
}
.audit-table {
width: 100%;
border-collapse: collapse;
font-size: 0.8rem;
}
.audit-table th,
.audit-table td {
text-align: left;
padding: 0.35rem 0.6rem;
border-bottom: 1px solid #232b3a;
white-space: nowrap;
}
.audit-table th {
position: sticky;
top: 0;
background: #161c2a;
color: #94a3b8;
font-weight: 600;
z-index: 1;
}
.audit-table tbody tr:hover {
background: #1d2433;
}
.audit-row-error td {
background: rgba(220, 38, 38, 0.08);
}
.audit-time {
color: #94a3b8;
font-variant-numeric: tabular-nums;
}
.audit-signal,
.audit-detail {
max-width: 18rem;
overflow: hidden;
text-overflow: ellipsis;
}
.audit-value {
max-width: 10rem;
overflow: hidden;
text-overflow: ellipsis;
font-family: ui-monospace, monospace;
}
.audit-actor-tag {
display: inline-block;
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.03em;
padding: 0.05rem 0.35rem;
border-radius: 3px;
vertical-align: middle;
}
.audit-actor-user {
background: #1e3a5f;
color: #93c5fd;
}
.audit-actor-system {
background: #4a2d6b;
color: #d8b4fe;
}
.audit-result-ok {
color: #4ade80;
}
.audit-result-err {
color: #f87171;
font-weight: 600;
}
.cl-title {
font-size: 1rem;
font-weight: 700;
@@ -3657,3 +3858,285 @@ kbd {
background: #2563eb;
}
.panel-btn-primary:hover { background: #3b82f6; }
/* ── Configuration manager ─────────────────────────────────────────────── */
.cfg-tabs {
display: flex;
gap: 0.25rem;
margin-left: 0.5rem;
}
.cfg-tab {
background: transparent;
border: 1px solid transparent;
color: #94a3b8;
font-size: 0.82rem;
padding: 0.25rem 0.75rem;
border-radius: 6px;
cursor: pointer;
}
.cfg-tab:hover { color: #e2e8f0; background: #232a3a; }
.cfg-tab-active {
color: #e2e8f0;
background: #2d3748;
border-color: #3d4a63;
}
.cfg-badge {
display: inline-block;
min-width: 1.1rem;
text-align: center;
font-size: 0.7rem;
color: #94a3b8;
background: #232a3a;
border-radius: 8px;
padding: 0 0.35rem;
margin-left: 0.4rem;
}
.cfg-editor {
display: flex;
flex-direction: column;
gap: 0.6rem;
padding: 0.75rem 1rem;
overflow-y: auto;
height: 100%;
}
.cfg-edit-bar {
display: flex;
align-items: center;
gap: 0.5rem;
}
.cfg-edit-bar .panel-btn { flex: 0 0 auto; }
.cfg-section-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 0.5rem;
padding-bottom: 0.25rem;
border-bottom: 1px solid #2d3748;
font-size: 0.82rem;
font-weight: 600;
color: #cbd5e1;
}
.cfg-section-head .panel-btn { flex: 0 0 auto; }
.cfg-params {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.cfg-param {
background: #151a26;
border: 1px solid #232a3a;
border-radius: 6px;
padding: 0.5rem;
position: relative;
}
.cfg-param-row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: flex-end;
}
.cfg-field {
display: flex;
flex-direction: column;
gap: 0.15rem;
min-width: 0;
}
.cfg-field > label {
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.03em;
color: #64748b;
}
.cfg-field-key { width: 8rem; }
.cfg-field-label { flex: 1; min-width: 8rem; }
.cfg-field-type { width: 7rem; }
.cfg-field-target { flex: 2; min-width: 12rem; }
.cfg-field-default { width: 8rem; }
.cfg-field-enum { flex: 1; min-width: 10rem; }
.cfg-field-num { width: 6rem; }
.cfg-mandatory {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.3rem;
font-size: 0.75rem;
color: #94a3b8;
white-space: nowrap;
padding-bottom: 0.25rem;
}
.cfg-param-del {
background: transparent;
border: none;
color: #64748b;
cursor: pointer;
font-size: 1rem;
padding: 0 0.25rem;
}
.cfg-param-del:hover { color: #f87171; }
.cfg-unit {
font-size: 0.72rem;
color: #64748b;
margin-left: 0.25rem;
}
/* ── Instance values ───────────────────────────────────────────────────── */
.cfg-values {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.cfg-value-row {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.3rem 0.4rem;
border-radius: 5px;
}
.cfg-value-row:hover { background: #151a26; }
.cfg-value-label {
width: 14rem;
flex: 0 0 auto;
font-size: 0.82rem;
color: #cbd5e1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cfg-req { color: #f87171; margin-left: 0.15rem; }
.cfg-value-input { width: 10rem; flex: 0 0 auto; }
.cfg-value-target {
font-size: 0.72rem;
color: #64748b;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ── Apply result ──────────────────────────────────────────────────────── */
.cfg-apply-result {
border: 1px solid #232a3a;
border-radius: 6px;
margin-top: 0.5rem;
overflow: hidden;
}
.cfg-apply-head {
display: flex;
gap: 1rem;
padding: 0.4rem 0.6rem;
background: #151a26;
font-size: 0.8rem;
color: #cbd5e1;
}
.cfg-apply-table {
width: 100%;
border-collapse: collapse;
font-size: 0.78rem;
}
.cfg-apply-table th,
.cfg-apply-table td {
text-align: left;
padding: 0.25rem 0.6rem;
border-top: 1px solid #1e2433;
}
.cfg-apply-table th { color: #64748b; font-weight: 500; }
.cfg-ok { color: #4ade80; }
.cfg-fail { color: #f87171; }
/* ── Version panel ─────────────────────────────────────────────────────── */
.cfg-versions {
border-top: 1px solid #2d3748;
margin-top: 0.5rem;
padding-top: 0.4rem;
}
.cfg-versions-toggle {
background: transparent;
border: none;
color: #94a3b8;
font-size: 0.8rem;
cursor: pointer;
padding: 0.2rem 0;
}
.cfg-versions-toggle:hover { color: #e2e8f0; }
.cfg-versions-body {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-top: 0.4rem;
}
.cfg-version-row {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.25rem 0.4rem;
border: 1px solid #232a3a;
border-radius: 5px;
font-size: 0.78rem;
}
.cfg-version-current {
border-color: #2563eb;
background: #15203a;
}
.cfg-version-num { font-weight: 600; color: #cbd5e1; min-width: 3rem; }
.cfg-version-tag { color: #94a3b8; flex: 1; }
.cfg-version-time { color: #64748b; font-size: 0.72rem; }
.cfg-compare {
display: flex;
align-items: center;
gap: 0.4rem;
margin-top: 0.4rem;
font-size: 0.78rem;
color: #94a3b8;
}
/* ── Diff modal ────────────────────────────────────────────────────────── */
.cfg-diff-modal {
background: #1a1f2e;
border: 1px solid #2d3748;
border-radius: 10px;
width: min(760px, 92vw);
max-height: 85vh;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.6);
}
.cfg-diff-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.6rem 1rem;
border-bottom: 1px solid #2d3748;
font-size: 0.85rem;
color: #e2e8f0;
}
.cfg-diff-table {
width: 100%;
border-collapse: collapse;
font-size: 0.78rem;
overflow-y: auto;
}
.cfg-diff-table th,
.cfg-diff-table td {
text-align: left;
padding: 0.3rem 0.7rem;
border-top: 1px solid #1e2433;
vertical-align: top;
}
.cfg-diff-table th { color: #64748b; font-weight: 500; }
.cfg-diff-status {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.cfg-diff-added { background: rgba(34, 197, 94, 0.08); }
.cfg-diff-added .cfg-diff-status { color: #4ade80; }
.cfg-diff-removed { background: rgba(239, 68, 68, 0.08); }
.cfg-diff-removed .cfg-diff-status { color: #f87171; }
.cfg-diff-changed { background: rgba(234, 179, 8, 0.08); }
.cfg-diff-changed .cfg-diff-status { color: #facc15; }
.cfg-diff-unchanged .cfg-diff-status { color: #64748b; }
+57 -2
View File
@@ -84,12 +84,25 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
const showLegend = legendPos !== 'none';
const buffers: SeriesBuffer[] = signals.map(() => ({ timestamps: [], values: [] }));
// Latest waveform (array) value per signal — used only by plotType 'waveform'.
const waveforms: number[][] = signals.map(() => []);
const unsubs: (() => void)[] = [];
const fmt = widget.options['format'] ?? '';
// ── uPlot (timeseries) ──────────────────────────────────────────────────
let uplot: uPlot | null = null;
// Newest sample timestamp across all series (seconds). Anchoring the live
// x-axis to this — rather than wall-clock Date.now() — avoids client/server
// clock skew clipping the latest point, and keeps the right edge on real data.
function latestSampleTs(): number {
let m = -Infinity;
for (const b of buffers) {
if (b.timestamps.length) m = Math.max(m, b.timestamps[b.timestamps.length - 1]);
}
return isFinite(m) ? m : Date.now() / 1000;
}
// windowSec: apply rolling window for live mode; 0 = use full buffer (historical)
function uplotData(windowSec = 0): uPlot.AlignedData {
if (signals.length === 0) return [[]];
@@ -165,7 +178,22 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
{ stroke: '#94a3b8', grid: { stroke: '#2d3748' }, ticks: { stroke: '#475569' } },
yAxis,
],
scales: { x: { time: true }, y: { ...scaleY, range: scaleYRange } },
scales: {
x: {
time: true,
// Live mode with a finite window: pin the x-axis to a rolling
// [newest window, newest] span so a single old/outlier sample
// can't stretch the axis and compress real points to the right.
// Historical mode (timeRange) keeps uPlot's data-driven auto-range.
...(!timeRange && timeWindow > 0
? { range: (): [number, number] => {
const r = latestSampleTs();
return [r - timeWindow, r];
} }
: {}),
},
y: { ...scaleY, range: scaleYRange },
},
};
}
@@ -243,6 +271,24 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
}),
};
}
case 'waveform': {
// Latest waveform (array) sample per signal, drawn as an x-vs-index
// trace. Each update replaces the trace rather than appending.
return {
backgroundColor: ECHARTS_DARK.backgroundColor,
legend: legendOpt,
grid: { left: 60, right: 16, top: showLegend ? 28 : 12, bottom: 40 },
xAxis: { type: 'value', name: 'Index', nameLocation: 'middle', nameGap: 24, min: 0, axisLine, axisLabel, splitLine },
yAxis: { type: 'value', name: 'Value', axisLine, axisLabel, splitLine },
series: signals.map((s, i) => ({
name: s.name,
type: 'line',
data: (waveforms[i] ?? []).map((v, k) => [k, v]),
lineStyle: { color: s.color ?? COLORS[i % COLORS.length] },
showSymbol: false,
})),
};
}
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).
@@ -397,7 +443,16 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
if (sv.value === null || sv.value === undefined || sv.ts === null) return;
const ts = new Date(sv.ts).getTime() / 1000;
if (plotType === 'timeseries') {
if (plotType === 'waveform') {
// Array-valued sample: keep only the latest waveform and redraw.
if (Array.isArray(sv.value)) {
waveforms[i] = sv.value.map((x: any) => typeof x === 'number' ? x : parseFloat(String(x)));
} else {
const v = typeof sv.value === 'number' ? sv.value : parseFloat(String(sv.value));
waveforms[i] = isNaN(v) ? [] : [v];
}
if (echart) echart.setOption(echartsOption(), { notMerge: true });
} else if (plotType === 'timeseries') {
const v = typeof sv.value === 'number' ? sv.value : parseFloat(String(sv.value));
if (isNaN(v)) return;
pushSample(buffers[i], ts, v);
+22 -1
View File
@@ -1,10 +1,31 @@
{
"panels": {},
"panels": {
"epics_test": {
"owner": ""
},
"new-plot-panel": {
"owner": "",
"folder": "fld-c5396468072d4f69"
},
"test": {
"owner": "martino",
"order": 1
},
"test-1781729251317": {
"owner": "",
"order": 3
}
},
"folders": {
"fld-68b0bb9c23c9fd3b": {
"id": "fld-68b0bb9c23c9fd3b",
"name": "Test",
"owner": ""
},
"fld-c5396468072d4f69": {
"id": "fld-c5396468072d4f69",
"name": "Plots",
"owner": "martino"
}
}
}
Binary file not shown.
Binary file not shown.
View File
+1
View File
@@ -0,0 +1 @@
[]
@@ -0,0 +1,7 @@
{
"id": "cl-0f99598ca4d0ea15",
"name": "New control logic",
"enabled": false,
"nodes": [],
"wires": []
}