Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3fc7c1b546 | |||
| 206d5b541d | |||
| f7f297c3df |
@@ -1,7 +1,6 @@
|
||||
# TODO
|
||||
|
||||
- [ ] Implement git style versioning for: synthetic variable, panels, control logic
|
||||
- [ ] Implement configuration manager:
|
||||
- [ ] **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
|
||||
@@ -12,17 +11,31 @@
|
||||
- user can fork an existing config instance to create a new one
|
||||
- user can compare two instances: show unified diff or side by side diff
|
||||
- etc...
|
||||
- user can apply and save configuration instances
|
||||
- in logic editor and control loop add nodes to read/write/create/apply config instances
|
||||
- [ ] Improve UX:
|
||||
- [x] Synthetic editor:
|
||||
- [x] color code the node link by type
|
||||
- [x] hover on a block in error should show the reason
|
||||
- [x] add proper array functionality
|
||||
- [x] add shortcut to add Signals (S) and nodes (N) with HUD
|
||||
- [ ] Panels:
|
||||
- [x] in view mode the widgets should have no border/bg but blend with background
|
||||
- [ ] add widgets such as toggle switch, table and other industrial hmi widgets
|
||||
- [ ] Implement git style versioning for: synthetic variable, panels, control logic:
|
||||
- possibility to fork any version
|
||||
- click to view the version
|
||||
- possibility to view graphical diff between versions (side by side or unified diff)
|
||||
- simple slick versioning pane:
|
||||
- vertical tree like
|
||||
- each version represented by a circle
|
||||
- active (the one currently view/edited) version has circle bigger then rest
|
||||
- selected (the one that will be executed/showed by user) version has circle full, not active only border
|
||||
- unsaved / new version appear with connection line dashed
|
||||
- [ ] Implement admin pane: create / manage groups, set users permits, manage auditors etc
|
||||
- [ ] Implement new datasources:
|
||||
- [ ] Finalize alarm service
|
||||
- [ ] modbus tcp
|
||||
- [ ] scpi tcp
|
||||
- [ ] Improve UX:
|
||||
- [ ] Synthetic editor:
|
||||
- color code the node link by type
|
||||
- hover on a block in error should show the reason
|
||||
- add proper array functionality
|
||||
- add shortcut to add Signals (S) and nodes (N) with HUD
|
||||
- [ ] Panels:
|
||||
- in view mode the widgets should have no border/bg but blend with background
|
||||
- add widgets such
|
||||
- [ ] 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)
|
||||
- [ ] 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)
|
||||
|
||||
+8
-1
@@ -17,6 +17,7 @@ import (
|
||||
"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"
|
||||
@@ -72,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)
|
||||
@@ -194,7 +201,7 @@ func main() {
|
||||
ctrlEngine.SetNotifier(dialogs)
|
||||
ctrlEngine.Reload()
|
||||
|
||||
srv := server.New(cfg.Server.Listen, webFS, brk, synthDS, store, policy, aclStore, ctrlStore, ctrlEngine, dialogs, recorder, 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.
|
||||
|
||||
|
||||
+28
-1
@@ -18,6 +18,7 @@ import (
|
||||
"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"
|
||||
@@ -30,6 +31,7 @@ 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
|
||||
@@ -42,7 +44,7 @@ type Handler struct {
|
||||
|
||||
// New creates an API Handler. synth may be nil if the synthetic DS is disabled.
|
||||
// rec records system-affecting mutations; pass audit.Nop() to disable auditing.
|
||||
func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, policy *access.Policy, acl *panelacl.Store, ctrlLogic *controllogic.Store, ctrlEngine *controllogic.Engine, rec audit.Recorder, channelFinderURL, archiverURL string, log *slog.Logger) *Handler {
|
||||
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()
|
||||
}
|
||||
@@ -50,6 +52,7 @@ func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, pol
|
||||
broker: b,
|
||||
synthetic: synth,
|
||||
store: store,
|
||||
cfg: cfg,
|
||||
policy: policy,
|
||||
acl: acl,
|
||||
ctrlLogic: ctrlLogic,
|
||||
@@ -109,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 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"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"
|
||||
@@ -53,8 +54,13 @@ func setup(t *testing.T) (*httptest.Server, func()) {
|
||||
}
|
||||
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, nil), acl, clStore, clEngine, audit.Nop(), "", "", 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() {
|
||||
|
||||
@@ -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,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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -126,6 +126,30 @@ func buildNode(d NodeDef) (dsp.Node, error) {
|
||||
case "lua":
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -13,9 +13,10 @@ import (
|
||||
// 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
|
||||
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 {
|
||||
@@ -41,24 +42,30 @@ func (rg *runtimeGraph) sourceRefs() []broker.SignalRef {
|
||||
return refs
|
||||
}
|
||||
|
||||
// eval computes the output value given the latest value for each source node
|
||||
// (keyed by source node id). Nodes are visited in topological order so every
|
||||
// input is already present in vals by the time a node is processed.
|
||||
func (rg *runtimeGraph) eval(sourceVals map[string]float64) (float64, error) {
|
||||
vals := make(map[string]float64, len(rg.order))
|
||||
// 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([]float64, len(n.inputs))
|
||||
in := make([]dsp.Sample, len(n.inputs))
|
||||
for i, id := range n.inputs {
|
||||
in[i] = vals[id]
|
||||
}
|
||||
r, err := n.op.Process(in, n.state)
|
||||
r, err := evalOp(n, in)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("node %s (%s): %w", n.id, n.op.Type(), err)
|
||||
return dsp.Sample{}, fmt.Errorf("node %s (%s): %w", n.id, n.op.Type(), err)
|
||||
}
|
||||
vals[n.id] = r
|
||||
case "output":
|
||||
@@ -70,6 +77,44 @@ func (rg *runtimeGraph) eval(sourceVals map[string]float64) (float64, error) {
|
||||
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).
|
||||
@@ -83,23 +128,42 @@ func compileGraph(def SignalDef) (*runtimeGraph, error) {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
"github.com/uopi/uopi/internal/broker"
|
||||
"github.com/uopi/uopi/internal/datasource"
|
||||
"github.com/uopi/uopi/internal/dsp"
|
||||
)
|
||||
|
||||
const definitionsFile = "synthetic.json"
|
||||
@@ -78,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
|
||||
}
|
||||
@@ -93,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
|
||||
@@ -108,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.
|
||||
@@ -138,7 +139,7 @@ func (s *Synthetic) Subscribe(ctx context.Context, signal string, ch chan<- data
|
||||
defer cancel()
|
||||
|
||||
// Latest value and timestamp per source node id.
|
||||
latest := make(map[string]float64, len(refs))
|
||||
latest := make(map[string]dsp.Sample, len(refs))
|
||||
latestTs := make([]time.Time, len(refs))
|
||||
ready := make([]bool, len(refs))
|
||||
|
||||
@@ -163,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:
|
||||
@@ -224,7 +225,7 @@ func (s *Synthetic) Subscribe(ctx context.Context, signal string, ch chan<- data
|
||||
return
|
||||
}
|
||||
|
||||
result, err := cur.rg.eval(latest)
|
||||
result, err := cur.rg.evalSample(latest)
|
||||
if err != nil {
|
||||
s.log.Warn("synthetic: pipeline error", "signal", signal, "err", err)
|
||||
continue
|
||||
@@ -232,7 +233,7 @@ func (s *Synthetic) Subscribe(ctx context.Context, signal string, ch chan<- data
|
||||
|
||||
v := datasource.Value{
|
||||
Timestamp: outTs,
|
||||
Data: result,
|
||||
Data: result.AsAny(),
|
||||
Quality: datasource.QualityGood,
|
||||
}
|
||||
select {
|
||||
@@ -421,11 +422,17 @@ func (s *Synthetic) startSignal(def SignalDef) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// defToMetadata converts a SignalDef into a datasource.Metadata.
|
||||
func defToMetadata(def SignalDef) datasource.Metadata {
|
||||
// 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 datasource.Metadata{
|
||||
Name: def.Name,
|
||||
Type: datasource.TypeFloat64,
|
||||
Type: dt,
|
||||
Unit: def.Meta.Unit,
|
||||
Description: def.Meta.Description,
|
||||
DisplayLow: def.Meta.DisplayLow,
|
||||
@@ -434,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:
|
||||
@@ -460,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,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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"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"
|
||||
@@ -28,7 +29,7 @@ type Server struct {
|
||||
|
||||
// New creates the HTTP server, registers all routes, and returns a ready-to-start Server.
|
||||
// synth may be nil if the synthetic data source is not enabled.
|
||||
func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, 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 {
|
||||
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()
|
||||
}
|
||||
@@ -49,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, rec, 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)
|
||||
|
||||
+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;
|
||||
}
|
||||
@@ -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' && (
|
||||
|
||||
@@ -3,9 +3,10 @@ import { useState, useEffect, useRef } from 'preact/hooks';
|
||||
import SignalPicker, { SignalOption } from './SignalPicker';
|
||||
import LuaEditor from './LuaEditor';
|
||||
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 {
|
||||
// Existing signal name (edit mode). Omitted/empty in create mode.
|
||||
@@ -52,6 +53,18 @@ const OPS: OpDef[] = [
|
||||
]},
|
||||
{ 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; }
|
||||
@@ -297,6 +310,9 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
|
||||
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('');
|
||||
@@ -308,7 +324,7 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
|
||||
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);
|
||||
@@ -338,7 +354,7 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
|
||||
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 {}
|
||||
}
|
||||
|
||||
@@ -346,11 +362,36 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
|
||||
// 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 name of (dsSignals[ds] ?? [])) out.push({ ds, name });
|
||||
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(() => {
|
||||
if (create) {
|
||||
const blank: SignalDef = { name: '', inputs: [], pipeline: [], meta: {} };
|
||||
@@ -408,6 +449,20 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
|
||||
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;
|
||||
@@ -582,22 +637,34 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
|
||||
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 { 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 (create && !sigName) { setError('Enter a name for the new signal.'); return; }
|
||||
if (validationError) { setError(validationError); return; }
|
||||
if (firstError) { setError(firstError); return; }
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
@@ -692,9 +759,10 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
|
||||
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, 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); }} />
|
||||
);
|
||||
@@ -712,6 +780,7 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
|
||||
return (
|
||||
<div key={node.id}
|
||||
class={nodeClass(node)}
|
||||
title={nodeErrors.get(node.id) || undefined}
|
||||
style={`left:${node.x}px; top:${node.y}px; width:${NODE_W}px; min-height:${nodeMinHeight(node, graph.wires)}px;`}
|
||||
onMouseDown={(e) => startNodeDrag(e, node)}
|
||||
onMouseUp={() => { if (pendingRef.current) finishWire(node, firstFreePort(node)); }}>
|
||||
@@ -867,11 +936,57 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
|
||||
</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 && validationError && <span class="hint" style="flex:1;">{validationError}</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 || !!validationError}>
|
||||
<button class="toolbar-btn toolbar-btn-primary" onClick={handleSave} disabled={saving || loading || !!firstError}>
|
||||
{saving ? 'Saving…' : 'Save Signal'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ 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;
|
||||
@@ -34,6 +35,7 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) {
|
||||
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();
|
||||
@@ -179,6 +181,15 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) {
|
||||
🛡 Audit
|
||||
</button>
|
||||
)}
|
||||
{writable && (
|
||||
<button
|
||||
class="toolbar-btn"
|
||||
onClick={() => setShowConfig(true)}
|
||||
title="Open configuration manager"
|
||||
>
|
||||
🗂 Config
|
||||
</button>
|
||||
)}
|
||||
{writable && (
|
||||
<button
|
||||
class="btn-edit"
|
||||
@@ -269,6 +280,9 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) {
|
||||
{showAudit && (
|
||||
<AuditViewer onClose={() => setShowAudit(false)} />
|
||||
)}
|
||||
{showConfig && (
|
||||
<ConfigManager onClose={() => setShowConfig(false)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -375,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 {
|
||||
@@ -1248,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;
|
||||
@@ -2002,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;
|
||||
@@ -3779,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,6 +84,8 @@ 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'] ?? '';
|
||||
|
||||
@@ -269,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).
|
||||
@@ -423,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);
|
||||
|
||||
Reference in New Issue
Block a user