Compare commits
7 Commits
main
...
b0ac044035
| Author | SHA1 | Date | |
|---|---|---|---|
| b0ac044035 | |||
| 3fc7c1b546 | |||
| 206d5b541d | |||
| f7f297c3df | |||
| 446de7f1ee | |||
| 901b87d407 | |||
| 8f6dbcba49 |
@@ -0,0 +1,71 @@
|
||||
# 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 from manager
|
||||
- [ ] in logic editor and control loop add nodes to read/write/create/apply config instances
|
||||
- [ ] support for all supported types (number, bools, enums, arrays, string etc)
|
||||
- [ ] ux elements for config set
|
||||
- the configuration editor should automatically get type and info from signal when possible
|
||||
- a slick tree drag and drop editor to order elements and organize in group and sub-groups
|
||||
- possibility to customise unit, max, min etc
|
||||
- [ ] ux elements for config instances:
|
||||
- automatically use the correct setting widget depending on the type (e.g. combo for enum, nubmer input for number):
|
||||
- for arrays: import csv option, display points as mini plot (+ open dialog plot with more info), manual enter points via table like interface
|
||||
- automatically fill with default or existing values (when forking an existing instance)
|
||||
- explicity show errors/missing info and give additional info on hover
|
||||
- [ ] add advanced validation / transformation framework to configurations using custom CUE rules:
|
||||
- backend should run validation / transformation rules
|
||||
- user can create/edit/delete/compare rules from webui:
|
||||
- integrate syntax highlight
|
||||
- integrate autocomplete for signals names and cue grammars
|
||||
- integrate cue lsp
|
||||
- when validation fail error should be propagated to user in the webui
|
||||
- all action should be tracked via history management and audit
|
||||
- [ ] 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
|
||||
- [ ] add container widgets: labelled/title pane, tab panes, collapsable panes etc
|
||||
- [ ] plot pane:
|
||||
- add toolbar
|
||||
- [ ] clean ui:
|
||||
- create small statusbar where connection widget and other status related info will be placed
|
||||
- group advanced items (audit/control loops/config manager etc) in a tool menu or something similar to not fill the toolbar
|
||||
- make the toolbar as clean as possible
|
||||
- [ ] Logic editor:
|
||||
- add full support to local array values: dynamic, dynamic but capped max, fixed size etc:
|
||||
- array functions should work with new local array
|
||||
- [ ] Control loop:
|
||||
- add full support to server side array values
|
||||
- [ ] 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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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` (1–8) | 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` (1–8) | 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.
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
+125
-3
@@ -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
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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{} }
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,10 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/uopi/uopi/internal/audit"
|
||||
"github.com/uopi/uopi/internal/broker"
|
||||
)
|
||||
|
||||
@@ -25,6 +27,7 @@ const (
|
||||
type Engine struct {
|
||||
broker *broker.Broker
|
||||
store *Store
|
||||
audit audit.Recorder
|
||||
log *slog.Logger
|
||||
root context.Context
|
||||
|
||||
@@ -32,16 +35,38 @@ type Engine struct {
|
||||
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 ─────────────────────────────────────────────────────────────
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const definitionsFile = "controllogic.json"
|
||||
@@ -19,6 +20,7 @@ var ErrNotFound = errors.New("control logic graph not found")
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
path string
|
||||
trashDir string
|
||||
items map[string]Graph
|
||||
}
|
||||
|
||||
@@ -26,6 +28,7 @@ type Store struct {
|
||||
func NewStore(storageDir string) (*Store, error) {
|
||||
s := &Store{
|
||||
path: filepath.Join(storageDir, definitionsFile),
|
||||
trashDir: filepath.Join(storageDir, "trash", "controllogic"),
|
||||
items: map[string]Graph{},
|
||||
}
|
||||
if err := s.load(); err != nil {
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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. a−b, 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"`
|
||||
|
||||
@@ -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)
|
||||
// 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
|
||||
}
|
||||
nodes = append(nodes, n)
|
||||
v, ok := params[key]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return nodes, 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)
|
||||
|
||||
@@ -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"}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -21,8 +21,7 @@ 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
|
||||
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
|
||||
// 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
|
||||
}
|
||||
return inputs[0], nil
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 a–d 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]))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
@@ -53,6 +59,8 @@ type outMsg struct {
|
||||
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)
|
||||
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
|
||||
@@ -196,6 +231,8 @@ func (c *wsClient) dispatchLoop(ctx context.Context) {
|
||||
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),
|
||||
|
||||
@@ -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 _, err := os.Stat(s.filePath(id)); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return ErrNotFound
|
||||
}
|
||||
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.
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+127
-47
@@ -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 (
|
||||
{selected.kind === 'action.write' && (
|
||||
<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'} />
|
||||
<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)}
|
||||
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}." />
|
||||
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 '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 <your username>"</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>
|
||||
|
||||
+46
-53
@@ -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 (
|
||||
{selected.kind === 'action.write' && (
|
||||
<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'} />
|
||||
<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)}
|
||||
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals}
|
||||
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)"
|
||||
|
||||
@@ -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' && (
|
||||
|
||||
@@ -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
@@ -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">
|
||||
|
||||
+554
-140
@@ -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 (a−b)', 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 (a−b)', 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 (1–8)', key: 'order', type: 'number', default: '1' },
|
||||
]},
|
||||
{ type: 'derivative', label: 'Derivative', params: [] },
|
||||
{ type: 'integrate', label: 'Integrate', params: [] },
|
||||
{ type: 'clamp', label: 'Clamp', params: [{ label: 'Min', key: 'min', type: 'number', default: '0' }, { label: 'Max', key: 'max', type: 'number', default: '100' }] },
|
||||
{ type: '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 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 };
|
||||
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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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',
|
||||
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,12 +775,15 @@ export default function SyntheticGraphEditor({ name, onClose, onSaved }: Props)
|
||||
})()}
|
||||
</svg>
|
||||
|
||||
{graph.nodes.map(node => (
|
||||
{graph.nodes.map(node => {
|
||||
const nIn = inPortCount(node, graph.wires);
|
||||
return (
|
||||
<div key={node.id}
|
||||
class={nodeClass(node)}
|
||||
style={`left:${node.x}px; top:${node.y}px; width:${NODE_W}px;`}
|
||||
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); }}>
|
||||
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' && (
|
||||
@@ -498,38 +794,95 @@ export default function SyntheticGraphEditor({ name, onClose, onSaved }: Props)
|
||||
</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;`}
|
||||
{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); }} />
|
||||
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>
|
||||
</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>
|
||||
|
||||
@@ -1,279 +0,0 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import LuaEditor from './LuaEditor';
|
||||
import SearchableSelect from './SearchableSelect';
|
||||
import type { InputRef, SignalDef } from './lib/types';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
onCreated: () => void;
|
||||
// Interface id the wizard was opened from — used to bind panel-scoped signals.
|
||||
currentIfaceId?: string;
|
||||
}
|
||||
|
||||
interface NodeParam {
|
||||
label: string;
|
||||
key: string;
|
||||
type: 'number' | 'text' | 'lua';
|
||||
default: string;
|
||||
}
|
||||
|
||||
const NODE_TYPES: Array<{ type: string; label: string; params: NodeParam[] }> = [
|
||||
{ type: 'gain', label: 'Gain', params: [{ label: 'Factor', key: 'gain', type: 'number', default: '1' }] },
|
||||
{ type: 'offset', label: 'Offset', params: [{ label: 'Offset', key: 'offset', type: 'number', default: '0' }] },
|
||||
{ type: 'moving_average', label: 'Moving Average', params: [{ label: 'Window (n)', key: 'window', type: 'number', default: '10' }] },
|
||||
{ type: 'rms', label: 'RMS', params: [{ label: 'Window (n)', key: 'window', type: 'number', default: '10' }] },
|
||||
{ type: 'lowpass', label: 'Low-pass Filter', params: [
|
||||
{ label: 'Cutoff freq (Hz)', key: 'freq', type: 'number', default: '1' },
|
||||
{ label: 'Order (1–8)', key: 'order', type: 'number', default: '1' },
|
||||
]},
|
||||
{ type: 'derivative', label: 'Derivative', params: [] },
|
||||
{ type: 'integrate', label: 'Integrate', params: [] },
|
||||
{ type: 'clamp', label: 'Clamp', params: [{ label: 'Min', key: 'min', type: 'number', default: '0' }, { label: 'Max', key: 'max', type: 'number', default: '100' }] },
|
||||
{ type: 'expr', label: 'Formula', params: [{ label: 'Expression (a,b,c,d = inputs)', key: 'expr', type: 'text', default: 'a * 1.0' }] },
|
||||
{ type: 'lua', label: 'Lua Script', params: [{ label: 'Script (a,b,c,d = inputs)', key: 'script', type: 'lua', default: 'return a' }] },
|
||||
];
|
||||
|
||||
interface DataSource {
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface SignalInfo {
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// ── Main Wizard ──────────────────────────────────────────────────────────────
|
||||
|
||||
export default function SyntheticWizard({ onClose, onCreated, currentIfaceId }: Props) {
|
||||
const [name, setName] = useState('');
|
||||
const [inputs, setInputs] = useState<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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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; }
|
||||
|
||||
@@ -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
@@ -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.
@@ -0,0 +1,9 @@
|
||||
[
|
||||
{
|
||||
"id": "cl-8c72c8bd37407d7f",
|
||||
"name": "New control logic",
|
||||
"enabled": false,
|
||||
"nodes": [],
|
||||
"wires": []
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"id": "new-config-set",
|
||||
"name": "New config set",
|
||||
"owner": "martino",
|
||||
"parameters": [],
|
||||
"version": 1
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"id": "cl-0f99598ca4d0ea15",
|
||||
"name": "New control logic",
|
||||
"enabled": false,
|
||||
"nodes": [],
|
||||
"wires": []
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
Starting iocInit
|
||||
iocRun: All initialization complete
|
||||
CAS: CA beacon send to 192.168.1.255:5065 ERROR: Network is unreachable
|
||||
|
||||
Reference in New Issue
Block a user