Compare commits

...

9 Commits

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-20 17:06:55 +02:00
Martino Ferrari 446de7f1ee Improving all side of app 2026-06-20 14:28:28 +02:00
Martino Ferrari 901b87d407 Implementing more advanced feature: audit and more 2026-06-19 14:17:46 +02:00
Martino Ferrari 8f6dbcba49 Working on audit 2026-06-19 09:44:57 +02:00
104 changed files with 13631 additions and 1093 deletions
+11 -1
View File
@@ -17,6 +17,8 @@ uopi runs as a **single portable binary** with no runtime dependencies. Point an
| **Plot panels** | Dedicated chart panels with a recursive split layout (tmux/IDE-style) where plots fill the viewport | | **Plot panels** | Dedicated chart panels with a recursive split layout (tmux/IDE-style) where plots fill the viewport |
| **Panel logic** | In-editor node-graph flow editor (triggers → actions, dialogs) that runs client-side in view mode | | **Panel logic** | In-editor node-graph flow editor (triggers → actions, dialogs) that runs client-side in view mode |
| **Control logic** | Server-side always-on flow graphs with cron/alarm triggers and Lua blocks | | **Control logic** | Server-side always-on flow graphs with cron/alarm triggers and Lua blocks |
| **Configuration manager** | Versioned configuration **sets** (typed signal schemas, grouped) and **instances** (values) with validation, array/CSV editing, apply-to-signals, and JSON import/export |
| **Version history & diff** | Git-style history for panels, synthetic signals, control logic and config sets/instances: view, fork, promote any revision, with unified / side-by-side diff |
| **Local variables** | Panel-scoped state variables for set-points, toggles and counters used by logic | | **Local variables** | Panel-scoped state variables for set-points, toggles and counters used by logic |
| **Historical data** | EPICS Archive Appliance integration via REST; time range picker in UI | | **Historical data** | EPICS Archive Appliance integration via REST; time range picker in UI |
| **Synthetic signals** | Compose, filter, and transform signals via a wizard or visual node-graph editor (gain, offset, moving average, lowpass, highpass, derivative, integral, clamp, formula); panel/user/global visibility scopes | | **Synthetic signals** | Compose, filter, and transform signals via a wizard or visual node-graph editor (gain, offset, moving average, lowpass, highpass, derivative, integral, clamp, formula); panel/user/global visibility scopes |
@@ -138,12 +140,20 @@ The REST API is available under `/api/v1`. Key endpoints:
| `POST` | `/api/v1/interfaces/{id}/clone` | Duplicate an interface | | `POST` | `/api/v1/interfaces/{id}/clone` | Duplicate an interface |
| `POST` | `/api/v1/interfaces/reorder` | Reorder panels / move them between folders | | `POST` | `/api/v1/interfaces/reorder` | Reorder panels / move them between folders |
| `GET/PUT` | `/api/v1/interfaces/{id}/acl` | Read or set a panel's sharing rules | | `GET/PUT` | `/api/v1/interfaces/{id}/acl` | Read or set a panel's sharing rules |
| `GET` | `/api/v1/interfaces/{id}/versions` | List saved versions of a panel |
| `GET/POST` | `/api/v1/folders` | List or create panel folders | | `GET/POST` | `/api/v1/folders` | List or create panel folders |
| `PUT/DELETE` | `/api/v1/folders/{id}` | Rename/reparent or delete a folder | | `PUT/DELETE` | `/api/v1/folders/{id}` | Rename/reparent or delete a folder |
| `GET/POST/DELETE` | `/api/v1/synthetic` | Manage synthetic signal definitions | | `GET/POST/DELETE` | `/api/v1/synthetic` | Manage synthetic signal definitions |
| `GET/POST` | `/api/v1/controllogic` | List or create server-side control-logic graphs | | `GET/POST` | `/api/v1/controllogic` | List or create server-side control-logic graphs |
| `GET/PUT/DELETE` | `/api/v1/controllogic/{id}` | Read, update, or delete a control-logic graph | | `GET/PUT/DELETE` | `/api/v1/controllogic/{id}` | Read, update, or delete a control-logic graph |
| `GET/POST` | `/api/v1/config/sets` | List or create configuration sets (schemas) |
| `GET/PUT/DELETE` | `/api/v1/config/sets/{id}` | Read, update, or delete a config set |
| `GET/POST` | `/api/v1/config/instances` | List or create configuration instances (values) |
| `GET/PUT/DELETE` | `/api/v1/config/instances/{id}` | Read, update, or delete a config instance |
| `POST` | `/api/v1/config/instances/{id}/apply` | Write an instance's values to their target signals |
| `GET` | `/api/v1/config/{sets\|instances}/diff` | Structural diff between two revisions |
| `GET` | `/api/v1/{interfaces\|synthetic\|controllogic\|config/sets\|config/instances}/{id}/versions` | List revisions; `…/{version}` fetches one |
| `POST` | `…/{id}/versions/{v}/promote` | Promote a revision to current |
| `POST` | `…/{id}/versions/{v}/fork` | Fork a revision into a new document |
| `GET` | `/api/v1/me` | Caller identity, access level, groups, logic-edit permission | | `GET` | `/api/v1/me` | Caller identity, access level, groups, logic-edit permission |
| `GET` | `/api/v1/usergroups` | List configured users and groups (for sharing) | | `GET` | `/api/v1/usergroups` | List configured users and groups (for sharing) |
| `GET` | `/metrics` | Prometheus-format server metrics | | `GET` | `/metrics` | Prometheus-format server metrics |
+87
View File
@@ -0,0 +1,87 @@
# TODO
- [ ] **MAJOR** Implement configuration manager:
- configuration is a set of signals (that can be organized in group and subgroup) that are used to configure a system or a sub system
- with configuration manager user should be able to:
- Define and manage configuration sets: delete (keep server side copy of deleted config), create (specifying default to parameters, mandatory, optional etc), edit (with versioning git style) and compare set
- user can fork an existing config instance to create a new one
- user can compare two instances: show unified diff or side by side diff
- etc...
- Create, delete (keep server cache), edit (git style versioning) and compare configuration instances: meaning set actual value to a configuration set
- user can fork an existing config instance to create a new one
- user can compare two instances: show unified diff or side by side diff
- etc...
- [x] user can apply and save configuration instances from manager
- [x] **instance snapshot**: create a new instance from the current live value of all of a set's target signals (optional label, else auto name) — exposed in the config editor (⎙ Snapshot), control loop (`action.config.snapshot`) and logic editor (`action.config.snapshot`)
- [x] **live diff**: compare a stored instance against current signal values ("Diff vs current" button → `/config/instances/{id}/livediff`)
- [x] git-style versioning for config sets and instances: fork any revision, click to view, promote to current, with unified / side-by-side diff (shared `VersionTree`)
- [x] in logic editor and control loop add nodes to read/write/create/apply config instances
- [x] **apply** and **read** config-instance nodes in both control logic (server Go) and panel logic (client TS)
- [x] **create / write** nodes (mutate versioned instances from automation) in both engines
- [x] **Config Selector** panel widget: operator picks an instance of a chosen set (optionally a subset) from a combo, writing its id to a panel-local string variable; apply/read/write panel-logic nodes can read their instance dynamically from that variable ("From variable" source)
- [x] support for all supported types (number, bools, enums, arrays, string etc)
- [x] ux elements for config set
- [x] the configuration editor should automatically get type and info from signal when possible (type is read-only, auto-derived from the bound signal)
- [x] a slick tree drag and drop editor to order elements and organize in group and sub-groups
- [x] possibility to customise unit, max, min etc
- [x] export / import a config set as JSON
- [x] full-screen config window
- [x] ux elements for config instances:
- automatically use the correct setting widget depending on the type (e.g. combo for enum, nubmer input for number):
- for arrays: import csv option, display points as mini plot (+ open dialog plot with more info), manual enter points via table like interface
- automatically fill with default or existing values (when forking an existing instance)
- explicity show errors/missing info and give additional info on hover
- [x] add advanced validation / transformation framework to configurations using custom CUE rules:
- [x] backend runs validation / transformation rules (real CUE via `cuelang.org/go`; `internal/confmgr/cue.go`). Rules are a third versioned `Kind` bound to a set; evaluated on instance create/update — violations block the save, concrete derivations transform & persist the values
- [x] user can create/edit/delete/compare rules from webui (Rules tab in ConfigManager; git-style versioning + side-by-side/unified source diff)
- [x] integrate syntax highlight (hand-rolled `CueEditor.tsx`, mirroring `LuaEditor`)
- [x] integrate autocomplete for signals names and cue grammars (param keys + target signals + CUE keywords/types; Ctrl+Space)
- [ ] ~~integrate cue lsp~~ — descoped: a separate language-server process does not fit the no-npm / single portable-binary architecture
- [x] when validation fails the error is propagated to the user (live `/config/rules/check` panel while editing; save returns the structured violation)
- [x] all actions tracked via history (versioned rules) + audit (`config.rule.create/update/delete/promote/fork`)
- [ ] Improve UX:
- [ ] Synthetic editor:
- [ ] color code the node link by type
- [ ] edges color depends on type (e.g. float) including arrays
- [ ] input / outputs are color coded (if can accept many, gray, otherwise specific color, can be dynamic)
- [ ] can not connect input / output that are not compatible
- [x] hover on a block in error should show the reason
- [x] add proper array functionality
- [x] add shortcut to add Signals (S) and nodes (N) with HUD
- [ ] Panels:
- [x] in view mode the widgets should have no border/bg but blend with background
- [ ] add widgets such as toggle switch, table and other industrial hmi widgets
- [ ] add container widgets: labelled/title pane, tab panes, collapsable panes etc
- [ ] plot pane:
- [ ] add toolbar
- [ ] add time window selector to toolbar
- [ ] plot x-axis synchronised (option to unlink in toolbar)
- [ ] cursors and measuraments
- [ ] pause/resume
- [ ] save screenshot
- [ ] clean ui:
- create small statusbar where connection widget and other status related info will be placed
- group advanced items (audit/control loops/config manager etc) in a tool menu or something similar to not fill the toolbar
- make the toolbar as clean as possible
- [ ] Logic editor:
- add full support to local array values: dynamic, dynamic but capped max, fixed size etc:
- array functions should work with new local array
- [ ] Control loop:
- add full support to server side array values
- [x] Implement git style versioning for: synthetic variable, panels, control logic:
- [x] possibility to fork any version
- [x] click to view the version
- [x] possibility to view graphical diff between versions (side by side or unified diff)
- [x] simple slick versioning pane:
- [x] vertical tree like
- [x] each version represented by a circle
- [x] active (the one currently view/edited) version has circle bigger then rest
- [x] selected (the one that will be executed/showed by user) version has circle full, not active only border
- [x] unsaved / new version appear with connection line dashed
- [ ] Implement admin pane: create / manage groups, set users permits, manage auditors etc
- [ ] Implement new datasources:
- [ ] Finalize alarm service
- [ ] modbus tcp
- [ ] scpi tcp
- [ ] udp? other?
- [ ] **MAJOR** Implement proper distributed server side nodes to balance load and have redundancy (if a node is not available anymore all its clients migrate seamelessly to another)
+58 -3
View File
@@ -9,14 +9,19 @@ import (
"log/slog" "log/slog"
"os" "os"
"os/signal" "os/signal"
"os/user"
"path/filepath"
"syscall" "syscall"
"github.com/uopi/uopi/internal/access" "github.com/uopi/uopi/internal/access"
"github.com/uopi/uopi/internal/audit"
"github.com/uopi/uopi/internal/broker" "github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/config" "github.com/uopi/uopi/internal/config"
"github.com/uopi/uopi/internal/confmgr"
"github.com/uopi/uopi/internal/controllogic" "github.com/uopi/uopi/internal/controllogic"
"github.com/uopi/uopi/internal/datasource/epics" "github.com/uopi/uopi/internal/datasource/epics"
"github.com/uopi/uopi/internal/datasource/pva" "github.com/uopi/uopi/internal/datasource/pva"
"github.com/uopi/uopi/internal/datasource/servervar"
"github.com/uopi/uopi/internal/datasource/stub" "github.com/uopi/uopi/internal/datasource/stub"
"github.com/uopi/uopi/internal/datasource/synthetic" "github.com/uopi/uopi/internal/datasource/synthetic"
"github.com/uopi/uopi/internal/panelacl" "github.com/uopi/uopi/internal/panelacl"
@@ -41,6 +46,16 @@ func main() {
os.Exit(1) os.Exit(1)
} }
// In an unproxied/local deployment (no trusted_user_header) without an explicit
// default_user, attribute actions to the OS user running the process so the UI
// and audit log show a real identity instead of anonymous.
if cfg.Server.TrustedUserHeader == "" && cfg.Server.DefaultUser == "" {
if u, err := user.Current(); err == nil && u.Username != "" {
cfg.Server.DefaultUser = u.Username
log.Info("no default_user configured; defaulting to OS user", "user", u.Username)
}
}
if err := os.MkdirAll(cfg.Server.StorageDir, 0o755); err != nil { if err := os.MkdirAll(cfg.Server.StorageDir, 0o755); err != nil {
log.Error("failed to create storage dir", "dir", cfg.Server.StorageDir, "err", err) log.Error("failed to create storage dir", "dir", cfg.Server.StorageDir, "err", err)
os.Exit(1) os.Exit(1)
@@ -58,6 +73,12 @@ func main() {
os.Exit(1) 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") webFS, err := fs.Sub(web.FS, "dist")
if err != nil { if err != nil {
log.Error("failed to sub web dist", "err", err) log.Error("failed to sub web dist", "err", err)
@@ -122,6 +143,15 @@ func main() {
} }
} }
// Server variables: a small persistent key/value source ("srv") that the
// control-logic engine writes (e.g. sequence state) and panels can read.
srvVars, err := servervar.New(cfg.Server.StorageDir)
if err != nil {
log.Error("server variables init", "err", err)
os.Exit(1)
}
brk.Register(srvVars)
// Build the global access policy from config: every user is trusted with // Build the global access policy from config: every user is trusted with
// full write access unless downgraded by the blacklist; groups are named // full write access unless downgraded by the blacklist; groups are named
// sets of users referenced by per-panel sharing. // sets of users referenced by per-panel sharing.
@@ -133,7 +163,26 @@ func main() {
for _, g := range cfg.Groups { for _, g := range cfg.Groups {
groups[g.Name] = g.Members groups[g.Name] = g.Members
} }
policy := access.New(cfg.Server.DefaultUser, blacklist, groups, cfg.Server.LogicEditors) policy := access.New(cfg.Server.DefaultUser, blacklist, groups, cfg.Server.LogicEditors, cfg.Audit.Readers)
// Audit log: when enabled, every system-affecting action (user/automated
// signal writes, interface and control-logic mutations) is recorded to SQLite.
// When disabled a no-op recorder is used so call sites need no guards.
recorder := audit.Nop()
if cfg.Audit.Enabled {
dbPath := cfg.Audit.DBPath
if dbPath == "" {
dbPath = filepath.Join(cfg.Server.StorageDir, "audit.db")
}
r, err := audit.NewSQLite(dbPath, log)
if err != nil {
log.Error("failed to open audit log", "path", dbPath, "err", err)
os.Exit(1)
}
defer r.Close()
recorder = r
log.Info("audit log enabled", "path", dbPath)
}
// Server-side control logic: flow graphs that run continuously under the root // Server-side control logic: flow graphs that run continuously under the root
// context, independent of any panel. The store is loaded from disk and the // context, independent of any panel. The store is loaded from disk and the
@@ -143,10 +192,16 @@ func main() {
log.Error("failed to open control logic store", "err", err) log.Error("failed to open control logic store", "err", err)
os.Exit(1) os.Exit(1)
} }
ctrlEngine := controllogic.NewEngine(ctx, brk, ctrlStore, log) ctrlEngine := controllogic.NewEngine(ctx, brk, ctrlStore, cfgStore, recorder, log)
// Dialog hub: control-logic action.dialog nodes push notifications/inputs to
// connected clients (filtered by user/group) and route input responses back
// to server variables. Installed before Reload so running graphs can emit.
dialogs := server.NewDialogHub(brk, policy, recorder, log)
ctrlEngine.SetNotifier(dialogs)
ctrlEngine.Reload() ctrlEngine.Reload()
srv := server.New(cfg.Server.Listen, webFS, brk, synthDS, store, policy, aclStore, ctrlStore, ctrlEngine, cfg.Datasource.EPICS.ChannelFinderURL, cfg.Datasource.EPICS.ArchiveURL, cfg.Server.TrustedUserHeader, log) srv := server.New(cfg.Server.Listen, webFS, brk, synthDS, store, cfgStore, policy, aclStore, ctrlStore, ctrlEngine, dialogs, recorder, cfg.Datasource.EPICS.ChannelFinderURL, cfg.Datasource.EPICS.ArchiveURL, cfg.Server.TrustedUserHeader, log)
if err := srv.Start(ctx); err != nil { if err := srv.Start(ctx); err != nil {
fmt.Fprintf(os.Stderr, "server error: %v\n", err) fmt.Fprintf(os.Stderr, "server error: %v\n", err)
+96 -3
View File
@@ -192,6 +192,7 @@ When multiple widgets are selected:
| Histogram | numeric scalar(s) | | Histogram | numeric scalar(s) |
| Bar chart | numeric scalar(s) | | Bar chart | numeric scalar(s) |
| Logic analyser | boolean / integer (bitset) | | Logic analyser | boolean / integer (bitset) |
| Waveform | 1-D numeric array (latest sample, x-vs-index) |
### 4.5 Widget Properties (Properties Pane) ### 4.5 Widget Properties (Properties Pane)
@@ -278,7 +279,7 @@ copy/paste.
|----------|-------| |----------|-------|
| Triggers | Button press, threshold crossing, value change, timer/interval, panel loop, On-open / On-close lifecycle | | Triggers | Button press, threshold crossing, value change, timer/interval, panel loop, On-open / On-close lifecycle |
| Logic | AND gate, If (then/else), Loop (count or while) | | Logic | AND gate, If (then/else), Loop (count or while) |
| Actions | Write to signal/variable, Delay, Log; Accumulate / Export-CSV / Clear for in-memory data arrays | | Actions | Write to signal/variable, Delay, Log; Accumulate / Export-CSV / Clear for in-memory data arrays; Apply config / Read config / Write config / Create config / Snapshot config (see §11) |
| Dialogs | Info and Error pop-ups; Set-point prompt (asks the user for a number and writes it) | | Dialogs | Info and Error pop-ups; Set-point prompt (asks the user for a number and writes it) |
**System helpers in expressions:** `{sys:time}` (epoch seconds) and `{sys:dt}` (seconds **System helpers in expressions:** `{sys:time}` (epoch seconds) and `{sys:dt}` (seconds
@@ -295,6 +296,12 @@ managed through the REST API.
- Triggers include *cron* schedules and signal *alarm*/threshold conditions. - Triggers include *cron* schedules and signal *alarm*/threshold conditions.
- A **Lua** block provides custom logic; results are written back to signals. - A **Lua** block provides custom logic; results are written back to signals.
- **Apply / Read / Write / Create config** action nodes drive the configuration manager
(§11): *Apply config* writes every value of a chosen instance to its bound signals
(audited); *Read config* reads one parameter's value into a target signal or variable;
*Write config* stores a value into a parameter (creating a new instance revision); *Create
config* makes a new instance for a set, optionally seeded from another instance. Both
mutating nodes are audited.
- Each graph can be enabled/disabled independently; saving reloads the engine live. - Each graph can be enabled/disabled independently; saving reloads the engine live.
- Editing is gated by the logic-edit restriction (§2). - Editing is gated by the logic-edit restriction (§2).
@@ -304,7 +311,7 @@ managed through the REST API.
- Interfaces are saved to the server in XML format and are available to all connected clients. - Interfaces are saved to the server in XML format and are available to all connected clients.
- Per-panel access rules and panel-folder placement are stored server-side (sidecar JSON). - Per-panel access rules and panel-folder placement are stored server-side (sidecar JSON).
- Saved versions are retained; a panel's version history can be listed, tagged and promoted. - Saved versions are retained; a panel's version history can be listed, tagged, viewed, promoted, forked and diffed — the same git-style versioning shared by all versioned documents (§12).
- Export/Import allows local file exchange of XML files. - Export/Import allows local file exchange of XML files.
- The XML schema records: interface kind (panel/plot) and split layout, widget type, - The XML schema records: interface kind (panel/plot) and split layout, widget type,
position, size, signal bindings, all property values, local variables, and panel logic. position, size, signal bindings, all property values, local variables, and panel logic.
@@ -344,7 +351,93 @@ When the server has archive access configured:
--- ---
## 11. Non-functional Requirements ## 11. Configuration Manager
The **Configuration manager** (opened from the View-mode toolbar) manages three kinds of
versioned documents, on **Sets**, **Instances** and **Rules** tabs, in a full-screen modal.
**Configuration sets** are schemas: an ordered list of typed parameters, each binding a
target signal. Parameters can be organised into **groups** and **subgroups** via a
drag-and-drop tree.
- A parameter's **type** (number, integer, boolean, string, enum, array/waveform) and its
unit/range/enum values are auto-derived from the bound signal's metadata when available;
the type is read-only.
- Each parameter may declare a **default**, a **mandatory** flag, and **min/max** /
**enum** constraints.
- Sets can be **exported / imported** as JSON.
**Configuration instances** assign concrete values to a chosen set's parameters.
- The editor renders the right control per type (number input, enum/boolean combo, and a
waveform editor with live sparkline, table editing, CSV import and a pop-out plot for
arrays); empty fields fall back to the parameter default.
- Validation mirrors the backend (required-but-unset, out-of-range, wrong type, bad enum)
and is surfaced inline with hover detail.
- **Apply** writes every value to its target signal and reports a per-parameter
applied / failed / skipped result.
- **Snapshot** (⎙ in the Instances tab) captures the *current live value* of every target
signal of a chosen set into a new instance — an optional label, otherwise an auto name. This
records "what the hardware holds right now" as a reusable configuration.
- **Diff vs current** compares a stored instance (resolved with defaults) against the current
live signal values, so an operator can see how the saved configuration differs from what the
system currently has.
The Sets and Instances tabs support the shared version history (§12): view, fork and promote
revisions, plus a **structural diff** between any two revisions (per-parameter added / removed
/ changed / unchanged).
**Validation / transformation rules** (Rules tab) attach **CUE** logic to a set. Each rule is
CUE source describing constraints and/or derivations over the parameter values: a field like
`voltage: >=0 & <=24` validates a value, while a concrete derivation like
`power: voltage * current` computes one. When an instance of the bound set is saved, every
rule runs — a failed constraint **blocks the save** and surfaces the violation, and derived
values are written into the stored instance. The rule editor is a CUE-aware code editor with
syntax highlighting and autocomplete (parameter keys, target signal names, and CUE
keywords/types via Ctrl+Space); a live panel re-evaluates the rule against the set's default
values on every keystroke, showing compile errors, violations, or the derived values. Rules
are versioned like the other documents, with a side-by-side / unified source diff, and every
create / edit / delete is audited. (A full CUE language server is intentionally out of scope:
it does not fit the no-dependency, single portable-binary design.)
**Automation:** both the panel-logic (§6) and control-logic (§7) editors expose **Apply
config**, **Read config**, **Write config**, **Create config** and **Snapshot config** action
nodes. *Apply config* applies a chosen instance exactly like the Apply button; *Read config*
reads a single numeric parameter into a target signal or variable; *Write config* stores a
value into a parameter, creating a new instance revision; *Create config* makes a new instance
for a set, optionally seeded from another instance; *Snapshot config* captures every target
signal's current live value of a set into a new instance (like the ⎙ Snapshot button). The
mutating nodes are audited.
**Config Selector widget:** a panel widget that lets an operator pick which configuration a
flow operates on at run time. It lists the instances of a chosen set — all of them or a
defined subset — in a combo and writes the selected instance id to a panel-local string
variable. The panel-logic Apply / Read / Write nodes can take their instance "From variable"
(reading that id) instead of a fixed instance, so the operator's choice drives the flow.
Control-logic nodes use fixed instances only (their variables are numeric).
---
## 12. Version History & Diff
All versioned documents — panels, synthetic signals, control-logic graphs, and
configuration sets/instances — share one git-style version model and a common history
pane:
- A **vertical version tree** lists every revision (one node each); the **current**
(executed) revision is filled, the **viewed** revision is enlarged, and unsaved edits show
a dashed connector.
- **View** loads any past revision read-only into the editor — viewing alone is not an edit
and does not mark the document dirty; saving from a viewed revision creates a new revision
on top (history is never destroyed).
- **Fork** copies a revision into a brand-new document (version reset to 1).
- **Promote** re-saves an older revision as a new current revision.
- **Diff** compares two revisions, defaulting to *current-vs-selected*, shown either
**unified** or **side-by-side**. Panels, synthetic and control-logic use a generic line
diff of the serialized document; configuration sets/instances use a richer per-parameter
structural diff.
---
## 13. Non-functional Requirements
| Requirement | Target | | Requirement | Target |
|-------------|--------| |-------------|--------|
+112 -17
View File
@@ -41,7 +41,7 @@
| ---------------- | --------------------------------------------------------------- | | ---------------- | --------------------------------------------------------------- |
| `preact` 10 | Virtual DOM UI framework | | `preact` 10 | Virtual DOM UI framework |
| `uPlot` | Extremely fast time-series/line plot (canvas-based, < 40 kB) | | `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 | | `uplot.css` | uPlot default stylesheet |
**Intentionally excluded:** React, Vue, Svelte, Konva, WebGPU, jQuery, npm at runtime. **Intentionally excluded:** React, Vue, Svelte, Konva, WebGPU, jQuery, npm at runtime.
@@ -198,10 +198,6 @@ Base path: `/api/v1`
| POST | `/interfaces/reorder` | Reorder panels / move between folders | | POST | `/interfaces/reorder` | Reorder panels / move between folders |
| GET, PUT, DELETE| `/interfaces/{id}` | Download, update, or delete an interface | | GET, PUT, DELETE| `/interfaces/{id}` | Download, update, or delete an interface |
| POST | `/interfaces/{id}/clone` | Clone an interface | | POST | `/interfaces/{id}/clone` | Clone an interface |
| GET | `/interfaces/{id}/versions` | List saved versions; `…/{version}` to fetch one |
| PUT | `/interfaces/{id}/versions/{v}/tag` | Tag a version |
| POST | `/interfaces/{id}/versions/{v}/promote` | Promote a version to current |
| POST | `/interfaces/{id}/versions/{v}/fork` | Fork a version into a new panel |
| GET, PUT | `/interfaces/{id}/acl` | Read or set a panel's sharing rules | | GET, PUT | `/interfaces/{id}/acl` | Read or set a panel's sharing rules |
| GET, POST | `/folders` | List or create panel folders | | GET, POST | `/folders` | List or create panel folders |
| PUT, DELETE | `/folders/{id}` | Rename/reparent or delete a folder | | PUT, DELETE | `/folders/{id}` | Rename/reparent or delete a folder |
@@ -211,10 +207,33 @@ Base path: `/api/v1`
| GET, PUT, DELETE| `/synthetic/{name}` | Read, update, or delete a synthetic definition | | GET, PUT, DELETE| `/synthetic/{name}` | Read, update, or delete a synthetic definition |
| GET, POST | `/controllogic` | List or create server-side control-logic graphs | | GET, POST | `/controllogic` | List or create server-side control-logic graphs |
| GET, PUT, DELETE| `/controllogic/{id}` | Read, update, or delete a control-logic graph | | GET, PUT, DELETE| `/controllogic/{id}` | Read, update, or delete a control-logic graph |
| GET, POST | `/config/sets` | List or create configuration sets (schemas) |
| GET, PUT, DELETE| `/config/sets/{id}` | Read, update, or delete a config set |
| GET, POST | `/config/instances` | List or create configuration instances (values) |
| GET, PUT, DELETE| `/config/instances/{id}` | Read, update, or delete a config instance |
| POST | `/config/instances/{id}/apply` | Write an instance's values to their target signals |
| POST | `/config/instances/{id}/validate` | Run the set's CUE rules over the stored values; returns a structured `RuleResult` (no save) |
| GET | `/config/instances/{id}/livediff` | Diff the stored instance (resolved with defaults) against the current live signal values |
| POST | `/config/sets/{id}/snapshot` | Capture every target signal's current live value into a new instance (body: optional `{name}`) |
| GET | `/config/{sets\|instances}/diff` | Structural diff between two revisions (`a,av,b,bv`)|
| GET, POST | `/config/rules` | List or create CUE validation/transformation rules |
| GET, PUT, DELETE| `/config/rules/{id}` | Read, update, or delete a rule |
| POST | `/config/rules/check` | Compile an (unsaved) CUE source and evaluate it against sample values — powers the live editor |
Mutating requests are gated by the access middleware (§8): global level for writes, **Versioning (shared).** Interfaces, synthetic signals, control-logic graphs and config
sets/instances all expose the same revision endpoints under their `{base}`:
| Method | Path | Description |
| ------ | ---- | ----------- |
| GET | `{base}/{id}/versions` | List revisions; `…/{version}` fetches one |
| POST | `{base}/{id}/versions/{v}/promote` | Promote a revision to current |
| POST | `{base}/{id}/versions/{v}/fork` | Fork a revision into a new document |
| PUT | `/interfaces/{id}/versions/{v}/tag` | Tag a panel version |
Mutating requests are gated by the access middleware (§3.10): global level for writes,
per-panel ACL for interface endpoints, and the logic-editor allowlist for control-logic per-panel ACL for interface endpoints, and the logic-editor allowlist for control-logic
endpoints and for any change to a panel's `<logic>` block. endpoints and for any change to a panel's `<logic>` block. Config-set/instance
promote/fork are gated by the same write policy.
### 3.5 EPICS Data Source ### 3.5 EPICS Data Source
@@ -234,15 +253,30 @@ endpoints and for any change to a panel's `<logic>` block.
**Built-in node types:** **Built-in node types:**
| Node type | Parameters | Description | | Node type | Parameters | In→Out | Description |
| ------------ | ----------------------------------- | ------------------------------------------------ | | ---------------- | --------------------------- | -------------- | ----------------------------------------------- |
| `source` | `ds`, `name` | Reads a signal from any data source | | `source` | `ds`, `name` | — | Reads a signal from any data source |
| `gain` | `factor` | Multiplies by a constant | | `gain` | `gain` | elementwise | Multiplies by a constant |
| `offset` | `value` | Adds a constant | | `offset` | `offset` | elementwise | Adds a constant |
| `moving_avg` | `window` (samples) | Rolling mean | | `add`/`subtract` | — | elementwise | Sum of inputs / `a b` |
| `lowpass` | `freq` (Hz), `order` (18) | Cascaded IIR Butterworth-style low-pass filter | | `multiply`/`divide` | — | elementwise | Product of inputs / `a ÷ b` |
| `formula` | `expr` | Inline math expression (variables: `a`, `b`, …) | | `clamp` | `min`, `max` | elementwise | Constrains to a range |
| `lua` | `script` | Arbitrary Lua 5.1 code with persistent state | | `threshold` | `threshold`, `high`, `low` | elementwise | Comparator output |
| `moving_average` | `window` (samples) | scalar-only | Rolling mean |
| `rms` | `window` (samples) | scalar-only | Rolling RMS |
| `derivative` | — | scalar-only | Time derivative (per-sample `dt`) |
| `integrate` | — | scalar-only | Trapezoidal integral |
| `lowpass` | `freq` (Hz), `order` (18) | scalar-only | Cascaded IIR Butterworth-style low-pass filter |
| `expr` | `expr`, `vars` | elementwise | Inline math expression (named inputs) |
| `lua` | `script`, `vars` | scalar-only | Arbitrary Lua 5.1 code with persistent state |
| `index` | `i` | array→scalar | Element `i` of a waveform (bounds-checked) |
| `slice` | `start`, `end` | array→array | Sub-range of a waveform (clamped) |
| `sum`/`mean` | — | array→scalar | Σ / average of a waveform |
| `min`/`max` | — | array→scalar | Reduction of a waveform |
| `length` | — | array→scalar | Element count of a waveform |
| `fft` | — | array→array | Magnitude spectrum (zero-padded to next pow-2) |
**Scalar vs waveform values:** A value flowing through the graph is a `dsp.Sample` — either a scalar `float64` or a `[]float64` waveform (the array-aware counterpart of EPICS `TypeFloat64Array`). *Elementwise* ops broadcast over arrays (scalar inputs act as constants; array inputs must share a length). *Reduction*/*producer* ops (`index`/`slice`/`sum`/…/`fft`) operate natively on waveforms. *Scalar-only* ops (stateful filters + `lua`) reject array inputs, since their per-evaluation state cannot be split across array lanes. `OpOutputType` (`internal/dsp/types.go`) propagates types statically at compile time to reject invalid wirings and to report the synthetic's metadata type; the editor mirrors these rules in `web/src/lib/synthTypes.ts` to colour wires by data type (scalar vs array) and flag type-incompatible links. Runtime `Sample` typing is authoritative.
**Low-pass filter implementation:** Cascaded first-order IIR sections. Each stage computes `y = y_prev + α·(x y_prev)` where `α = dt / (RC + dt)` and `RC = 1/(2π·fc)`. `dt` is computed per sample from source timestamps so the filter is correct for event-driven (non-uniform) data. **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.
@@ -303,6 +337,27 @@ and writes results back to signals. Graphs are persisted by a store and managed
`/api/v1/controllogic`; each mutation calls `Engine.Reload()` to apply changes live. Graphs `/api/v1/controllogic`; each mutation calls `Engine.Reload()` to apply changes live. Graphs
can be individually enabled/disabled. can be individually enabled/disabled.
The engine also holds a `*confmgr.Store` (injected via `NewEngine`) for five config action
nodes: `action.config.apply` resolves an instance + its set and runs `confmgr.Apply` with a
broker-backed, audited write closure; `action.config.read` resolves a single parameter value
(`ConfigInstance.Resolve`), coerces it to float64 and writes it to the node's target;
`action.config.write` evaluates an expression and stores the (type-coerced, via
`coerceParamValue`) value into a parameter, creating a new instance revision;
`action.config.create` creates a new instance for a set, optionally seeding its values from
another instance; and `action.config.snapshot` reads every target signal's current live value
(via the one-shot `Broker.ReadNow`, type-coerced by `confmgr.Snapshot`) and stores them as a
new instance. All mutating nodes are audited. The panel-logic engine
(`web/src/lib/logic.ts`) mirrors all five client-side via the REST endpoints
(`/config/instances/{id}/apply`, GET/PUT `/config/instances/{id}`, POST `/config/instances`,
POST `/config/sets/{id}/snapshot`).
Panel-logic apply/read/write nodes additionally support an `instanceSource: 'var'` mode that
reads the target instance id (a string) from a panel-local variable rather than a fixed id —
fed by the **Config Selector** widget (`web/src/widgets/ConfigSelect.tsx`), which lists a
set's instances in a combo and writes the chosen id to that variable. Control-logic nodes use
fixed ids only (its variables are numeric, instance ids are strings). To let the selector
filter instances by set without a GET per instance, `confmgr.Store.List` now returns each
instance's `setId` in its `Meta`.
### 3.10 Access Control ### 3.10 Access Control
Identity and global policy live in `internal/access` (`Policy`, `Level`). The user identity Identity and global policy live in `internal/access` (`Policy`, `Level`). The user identity
@@ -314,6 +369,46 @@ the optional `server.logic_editors` allowlist over control-logic endpoints and o
change to a panel's `<logic>` block; it is surfaced to the frontend through `/api/v1/me` change to a panel's `<logic>` block; it is surfaced to the frontend through `/api/v1/me`
(`canEditLogic`) so the UI can hide logic-editing affordances. (`canEditLogic`) so the UI can hide logic-editing affordances.
### 3.11 Configuration Manager
The configuration manager is `internal/confmgr`: a two-tier model of **sets** (typed
parameter schemas binding target signals) and **instances** (values for a chosen set).
`model.go` defines `ConfigSet`/`Parameter`/`ConfigInstance`; `apply.go` validates an
instance against its set (`checkValue`) and writes each value to its target signal,
returning a per-parameter apply report; `diff.go` produces the structural per-parameter
diff used by the `/config/{sets,instances}/diff` endpoints.
`store.go` persists each object as `configs/{sets,instances,rules}/{id}.json`, with superseded
revisions backed up as `{id}.vN.json` alongside — the same git-style scheme as panels and
synthetic/control-logic, so `Versions`/`GetVersion`/`Promote`/`Fork` behave identically.
`Delete` is non-destructive: the object and all its backups are moved to a timestamped
`trash/configs/…` folder.
**CUE rules (`cue.go`, `KindRule`).** A third versioned object type carries a CUE source
(`cuelang.org/go`) bound to a set via `SetID`. `EvaluateRule` compiles the source, unifies it
with the instance values (`ctx.Encode`), and validates with `cue.Concrete(true)`: regular
fields whose key matches a parameter constrain its value (failures become `RuleViolation`s),
while concrete derivations whose value differs from the input are reported (and persisted) as
**transformations**; hidden fields `_x` and definitions `#X` are helpers, excluded from both.
`CreateInstance`/`UpdateInstance` call `applyRules` after the structural `ValidateAgainst`:
all rules bound to the set are run in order (`evaluateRules`), a violation aborts the save as
a `*RuleError`, and successful transformations are merged back into the stored values (set
parameters only). `ValidateInstanceRules` is the read-only counterpart behind
`/config/instances/{id}/validate`; `EvaluateRule` is exposed directly via
`/config/rules/check` for the live editor.
### 3.12 Document Versioning
Versioning is implemented per storage layer but follows one shared contract: the live
revision lives in the primary file; each save backs up the previous revision as
`{id}.v{N}.{ext}`; `VersionMeta{Version,Name,Tag,Current,SavedAt}` describes each. `Promote`
re-saves an older revision on top (creating a new current revision, never destroying
history); `Fork` writes the revision out under a fresh id with its version reset to 1. The
frontend `web/src/VersionHistory.tsx` (`VersionTree` + `DiffViewer`) consumes these
generically; line diffs are computed client-side in `web/src/lib/linediff.ts`, while
config sets/instances use the backend structural diff instead. Config **rules** reuse the
generic client-side `DiffViewer` (line diff over the source).
--- ---
## 4. Frontend Architecture ## 4. Frontend Architecture
@@ -353,7 +448,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: 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. - 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. - 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. - 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.
+14 -1
View File
@@ -3,10 +3,23 @@ module github.com/uopi/uopi
go 1.26.2 go 1.26.2
require ( require (
cuelang.org/go v0.16.1
github.com/BurntSushi/toml v1.6.0 github.com/BurntSushi/toml v1.6.0
github.com/coder/websocket v1.8.14 github.com/coder/websocket v1.8.14
github.com/evanw/esbuild v0.28.0 github.com/evanw/esbuild v0.28.0
github.com/yuin/gopher-lua v1.1.2 github.com/yuin/gopher-lua v1.1.2
) )
require golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/sys v0.42.0 // indirect
modernc.org/libc v1.66.10 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.42.2 // indirect
)
+27
View File
@@ -1,10 +1,37 @@
cuelang.org/go v0.16.1 h1:iPN1lHZd2J0hjcr8hfq9PnIGk7VfPkKFfxH4de+m9sE=
cuelang.org/go v0.16.1/go.mod h1:/aW3967FeWC5Hc1cDrN4Z4ICVApdMi83wO5L3uF/1hM=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/evanw/esbuild v0.28.0 h1:V96ghtc5p5JnNUQIUsc5H3kr+AcFcMqOJll2ZmJW6Lo= github.com/evanw/esbuild v0.28.0 h1:V96ghtc5p5JnNUQIUsc5H3kr+AcFcMqOJll2ZmJW6Lo=
github.com/evanw/esbuild v0.28.0/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48= github.com/evanw/esbuild v0.28.0/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA= github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA=
github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8= github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.42.2 h1:7hkZUNJvJFN2PgfUdjni9Kbvd4ef4mNLOu0B9FGxM74=
modernc.org/sqlite v1.42.2/go.mod h1:+VkC6v3pLOAE0A0uVucQEcbVW0I5nHCeDaBf+DpsQT8=
+32 -1
View File
@@ -61,18 +61,20 @@ type Policy struct {
userGroups map[string][]string // user → groups they belong to userGroups map[string][]string // user → groups they belong to
groupNames []string // all configured group names (sorted) groupNames []string // all configured group names (sorted)
logicEditors map[string]bool // users + group names allowed to edit logic logicEditors map[string]bool // users + group names allowed to edit logic
auditReaders map[string]bool // users + group names allowed to view the audit log
} }
// New builds a Policy. blacklist maps a username to a config level string; // New builds a Policy. blacklist maps a username to a config level string;
// groups maps a group name to its member usernames. logicEditors optionally // groups maps a group name to its member usernames. logicEditors optionally
// restricts who may edit panel/control logic (usernames or group names); empty // restricts who may edit panel/control logic (usernames or group names); empty
// means no restriction. // means no restriction.
func New(defaultUser string, blacklist map[string]string, groups map[string][]string, logicEditors []string) *Policy { func New(defaultUser string, blacklist map[string]string, groups map[string][]string, logicEditors, auditReaders []string) *Policy {
p := &Policy{ p := &Policy{
defaultUser: strings.TrimSpace(defaultUser), defaultUser: strings.TrimSpace(defaultUser),
blacklist: make(map[string]Level), blacklist: make(map[string]Level),
userGroups: make(map[string][]string), userGroups: make(map[string][]string),
logicEditors: make(map[string]bool), logicEditors: make(map[string]bool),
auditReaders: make(map[string]bool),
} }
for _, e := range logicEditors { for _, e := range logicEditors {
e = strings.TrimSpace(e) e = strings.TrimSpace(e)
@@ -80,6 +82,12 @@ func New(defaultUser string, blacklist map[string]string, groups map[string][]st
p.logicEditors[e] = true p.logicEditors[e] = true
} }
} }
for _, e := range auditReaders {
e = strings.TrimSpace(e)
if e != "" {
p.auditReaders[e] = true
}
}
for user, lvl := range blacklist { for user, lvl := range blacklist {
u := strings.TrimSpace(user) u := strings.TrimSpace(user)
if u == "" { if u == "" {
@@ -165,6 +173,29 @@ func (p *Policy) CanEditLogic(user string) bool {
return false return false
} }
// CanViewAudit reports whether a user may view the audit log. When no reader
// allowlist is configured everyone with read access qualifies; otherwise the
// user (or one of their groups) must be listed. Anonymous/trusted-LAN callers
// (user=="") are always permitted.
func (p *Policy) CanViewAudit(user string) bool {
user = strings.TrimSpace(user)
if user == "" {
return true
}
if len(p.auditReaders) == 0 {
return true
}
if p.auditReaders[user] {
return true
}
for _, g := range p.userGroups[user] {
if p.auditReaders[g] {
return true
}
}
return false
}
// GroupsOf returns a copy of the groups a user belongs to. // GroupsOf returns a copy of the groups a user belongs to.
func (p *Policy) GroupsOf(user string) []string { func (p *Policy) GroupsOf(user string) []string {
src := p.userGroups[strings.TrimSpace(user)] src := p.userGroups[strings.TrimSpace(user)]
+2 -2
View File
@@ -6,7 +6,7 @@ func TestCanEditLogic(t *testing.T) {
groups := map[string][]string{"ops": {"carol"}} groups := map[string][]string{"ops": {"carol"}}
// No allowlist configured: everyone with write access may edit logic. // No allowlist configured: everyone with write access may edit logic.
open := New("", nil, groups, nil) open := New("", nil, groups, nil, nil)
for _, u := range []string{"", "alice", "carol"} { for _, u := range []string{"", "alice", "carol"} {
if !open.CanEditLogic(u) { if !open.CanEditLogic(u) {
t.Errorf("unrestricted: CanEditLogic(%q) = false, want true", u) t.Errorf("unrestricted: CanEditLogic(%q) = false, want true", u)
@@ -17,7 +17,7 @@ func TestCanEditLogic(t *testing.T) {
} }
// Allowlist by username and by group name. // Allowlist by username and by group name.
p := New("", nil, groups, []string{"alice", "ops"}) p := New("", nil, groups, []string{"alice", "ops"}, nil)
if !p.LogicRestricted() { if !p.LogicRestricted() {
t.Error("LogicRestricted() = false with allowlist set") t.Error("LogicRestricted() = false with allowlist set")
} }
+308 -3
View File
@@ -6,15 +6,20 @@ import (
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"io" "io"
"log/slog" "log/slog"
"net"
"net/http" "net/http"
"net/url" "net/url"
"strconv" "strconv"
"strings" "strings"
"time"
"github.com/uopi/uopi/internal/access" "github.com/uopi/uopi/internal/access"
"github.com/uopi/uopi/internal/audit"
"github.com/uopi/uopi/internal/broker" "github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/confmgr"
"github.com/uopi/uopi/internal/controllogic" "github.com/uopi/uopi/internal/controllogic"
"github.com/uopi/uopi/internal/datasource" "github.com/uopi/uopi/internal/datasource"
"github.com/uopi/uopi/internal/datasource/synthetic" "github.com/uopi/uopi/internal/datasource/synthetic"
@@ -27,25 +32,33 @@ type Handler struct {
broker *broker.Broker broker *broker.Broker
synthetic *synthetic.Synthetic // nil if not enabled synthetic *synthetic.Synthetic // nil if not enabled
store *storage.Store store *storage.Store
cfg *confmgr.Store
policy *access.Policy policy *access.Policy
acl *panelacl.Store acl *panelacl.Store
ctrlLogic *controllogic.Store ctrlLogic *controllogic.Store
ctrlEngine *controllogic.Engine ctrlEngine *controllogic.Engine
audit audit.Recorder // never nil; audit.Nop when disabled
channelFinderURL string // empty if not configured channelFinderURL string // empty if not configured
archiverURL string // empty if not configured archiverURL string // empty if not configured
log *slog.Logger log *slog.Logger
} }
// New creates an API Handler. synth may be nil if the synthetic DS is disabled. // New creates an API Handler. synth may be nil if the synthetic DS is disabled.
func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, policy *access.Policy, acl *panelacl.Store, ctrlLogic *controllogic.Store, ctrlEngine *controllogic.Engine, channelFinderURL, archiverURL string, log *slog.Logger) *Handler { // rec records system-affecting mutations; pass audit.Nop() to disable auditing.
func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, cfg *confmgr.Store, policy *access.Policy, acl *panelacl.Store, ctrlLogic *controllogic.Store, ctrlEngine *controllogic.Engine, rec audit.Recorder, channelFinderURL, archiverURL string, log *slog.Logger) *Handler {
if rec == nil {
rec = audit.Nop()
}
return &Handler{ return &Handler{
broker: b, broker: b,
synthetic: synth, synthetic: synth,
store: store, store: store,
cfg: cfg,
policy: policy, policy: policy,
acl: acl, acl: acl,
ctrlLogic: ctrlLogic, ctrlLogic: ctrlLogic,
ctrlEngine: ctrlEngine, ctrlEngine: ctrlEngine,
audit: rec,
channelFinderURL: channelFinderURL, channelFinderURL: channelFinderURL,
archiverURL: archiverURL, archiverURL: archiverURL,
log: log, log: log,
@@ -56,6 +69,7 @@ func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, pol
// Requires Go 1.22+ for method+path routing. // Requires Go 1.22+ for method+path routing.
func (h *Handler) Register(mux *http.ServeMux, prefix string) { func (h *Handler) Register(mux *http.ServeMux, prefix string) {
mux.HandleFunc("GET "+prefix+"/me", h.getMe) mux.HandleFunc("GET "+prefix+"/me", h.getMe)
mux.HandleFunc("GET "+prefix+"/audit", h.getAudit)
mux.HandleFunc("GET "+prefix+"/datasources", h.listDataSources) mux.HandleFunc("GET "+prefix+"/datasources", h.listDataSources)
mux.HandleFunc("GET "+prefix+"/signals", h.listSignals) mux.HandleFunc("GET "+prefix+"/signals", h.listSignals)
mux.HandleFunc("GET "+prefix+"/signals/search", h.searchSignals) mux.HandleFunc("GET "+prefix+"/signals/search", h.searchSignals)
@@ -92,6 +106,11 @@ func (h *Handler) Register(mux *http.ServeMux, prefix string) {
mux.HandleFunc("GET "+prefix+"/synthetic/{name}", h.getSynthetic) mux.HandleFunc("GET "+prefix+"/synthetic/{name}", h.getSynthetic)
mux.HandleFunc("PUT "+prefix+"/synthetic/{name}", h.updateSynthetic) mux.HandleFunc("PUT "+prefix+"/synthetic/{name}", h.updateSynthetic)
mux.HandleFunc("DELETE "+prefix+"/synthetic/{name}", h.deleteSynthetic) mux.HandleFunc("DELETE "+prefix+"/synthetic/{name}", h.deleteSynthetic)
// Synthetic signal git-style versioning (list / view / promote / fork).
mux.HandleFunc("GET "+prefix+"/synthetic/{name}/versions", h.listSyntheticVersions)
mux.HandleFunc("GET "+prefix+"/synthetic/{name}/versions/{version}", h.getSyntheticVersion)
mux.HandleFunc("POST "+prefix+"/synthetic/{name}/versions/{version}/promote", h.promoteSyntheticVersion)
mux.HandleFunc("POST "+prefix+"/synthetic/{name}/versions/{version}/fork", h.forkSyntheticVersion)
// Server-side control logic CRUD (mutations are write-gated by the access // Server-side control logic CRUD (mutations are write-gated by the access
// middleware; each mutation reloads the running engine). // middleware; each mutation reloads the running engine).
mux.HandleFunc("GET "+prefix+"/controllogic", h.listControlLogic) mux.HandleFunc("GET "+prefix+"/controllogic", h.listControlLogic)
@@ -99,6 +118,51 @@ func (h *Handler) Register(mux *http.ServeMux, prefix string) {
mux.HandleFunc("GET "+prefix+"/controllogic/{id}", h.getControlLogic) mux.HandleFunc("GET "+prefix+"/controllogic/{id}", h.getControlLogic)
mux.HandleFunc("PUT "+prefix+"/controllogic/{id}", h.updateControlLogic) mux.HandleFunc("PUT "+prefix+"/controllogic/{id}", h.updateControlLogic)
mux.HandleFunc("DELETE "+prefix+"/controllogic/{id}", h.deleteControlLogic) mux.HandleFunc("DELETE "+prefix+"/controllogic/{id}", h.deleteControlLogic)
// Control-logic git-style versioning (list / view / promote / fork).
mux.HandleFunc("GET "+prefix+"/controllogic/{id}/versions", h.listControlLogicVersions)
mux.HandleFunc("GET "+prefix+"/controllogic/{id}/versions/{version}", h.getControlLogicVersion)
mux.HandleFunc("POST "+prefix+"/controllogic/{id}/versions/{version}/promote", h.promoteControlLogicVersion)
mux.HandleFunc("POST "+prefix+"/controllogic/{id}/versions/{version}/fork", h.forkControlLogicVersion)
// 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("POST "+prefix+"/config/sets/{id}/snapshot", h.snapshotConfigSet)
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("POST "+prefix+"/config/instances/{id}/validate", h.validateConfigInstance)
mux.HandleFunc("GET "+prefix+"/config/instances/{id}/livediff", h.diffConfigInstanceLive)
mux.HandleFunc("GET "+prefix+"/config/instances/diff", h.diffConfigInstances)
// Config rules — CUE validation/transformation logic bound to a set, run
// when instances of that set are saved. Versioned git-style; "check"
// evaluates an unsaved source for the live editor.
mux.HandleFunc("GET "+prefix+"/config/rules", h.listConfigRules)
mux.HandleFunc("POST "+prefix+"/config/rules", h.createConfigRule)
mux.HandleFunc("POST "+prefix+"/config/rules/check", h.checkConfigRule)
mux.HandleFunc("GET "+prefix+"/config/rules/{id}", h.getConfigRule)
mux.HandleFunc("PUT "+prefix+"/config/rules/{id}", h.updateConfigRule)
mux.HandleFunc("DELETE "+prefix+"/config/rules/{id}", h.deleteConfigRule)
mux.HandleFunc("GET "+prefix+"/config/rules/{id}/versions", h.listConfigRuleVersions)
mux.HandleFunc("GET "+prefix+"/config/rules/{id}/versions/{version}", h.getConfigRuleVersion)
mux.HandleFunc("POST "+prefix+"/config/rules/{id}/versions/{version}/promote", h.promoteConfigRule)
mux.HandleFunc("POST "+prefix+"/config/rules/{id}/versions/{version}/fork", h.forkConfigRule)
} }
// ── /me ───────────────────────────────────────────────────────────────────── // ── /me ─────────────────────────────────────────────────────────────────────
@@ -118,15 +182,93 @@ func (h *Handler) getMe(w http.ResponseWriter, r *http.Request) {
"level": h.policy.Level(user).String(), "level": h.policy.Level(user).String(),
"groups": groups, "groups": groups,
"canEditLogic": h.policy.CanEditLogic(user), "canEditLogic": h.policy.CanEditLogic(user),
"canViewAudit": h.policy.CanViewAudit(user),
}) })
} }
// ── /audit ────────────────────────────────────────────────────────────────────
// getAudit returns audit-log entries matching the query filters. Access is
// restricted to users permitted by CanViewAudit (the audit-readers allowlist).
// Filters: start, end (RFC3339), user, action, ds, signal, limit.
func (h *Handler) getAudit(w http.ResponseWriter, r *http.Request) {
if !h.policy.CanViewAudit(caller(r)) {
jsonError(w, http.StatusForbidden, "you are not permitted to view the audit log")
return
}
q := r.URL.Query()
f := audit.Filter{
Actor: q.Get("user"),
Action: q.Get("action"),
DS: q.Get("ds"),
Signal: q.Get("signal"),
}
if v := q.Get("start"); v != "" {
t, err := time.Parse(time.RFC3339, v)
if err != nil {
jsonError(w, http.StatusBadRequest, "invalid start time (want RFC3339): "+v)
return
}
f.Start = t
}
if v := q.Get("end"); v != "" {
t, err := time.Parse(time.RFC3339, v)
if err != nil {
jsonError(w, http.StatusBadRequest, "invalid end time (want RFC3339): "+v)
return
}
f.End = t
}
if v := q.Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
f.Limit = n
}
}
events, err := h.audit.Query(f)
if err != nil {
h.log.Error("audit query", "err", err)
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
jsonOK(w, events)
}
// ── access helpers ──────────────────────────────────────────────────────────── // ── access helpers ────────────────────────────────────────────────────────────
// caller returns the resolved end-user identity for the request (set by the // caller returns the resolved end-user identity for the request (set by the
// access middleware). // access middleware).
func caller(r *http.Request) string { return access.UserFrom(r.Context()) } func caller(r *http.Request) string { return access.UserFrom(r.Context()) }
// clientIP returns the best-effort client address for audit attribution,
// preferring the proxy-set X-Forwarded-For / X-Real-IP headers.
func clientIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if i := strings.IndexByte(xff, ','); i >= 0 {
return strings.TrimSpace(xff[:i])
}
return strings.TrimSpace(xff)
}
if xr := r.Header.Get("X-Real-IP"); xr != "" {
return strings.TrimSpace(xr)
}
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
return host
}
return r.RemoteAddr
}
// recordMutation appends a successful configuration change to the audit log.
func (h *Handler) recordMutation(r *http.Request, action, detail string) {
h.audit.Record(audit.Event{
Actor: caller(r),
ActorType: audit.ActorUser,
Action: action,
Detail: detail,
IP: clientIP(r),
Outcome: audit.OutcomeOK,
})
}
// capByGlobal lowers a per-panel/folder permission to what the user's global // capByGlobal lowers a per-panel/folder permission to what the user's global
// access level allows: read-only users can never exceed read, no-access users // access level allows: read-only users can never exceed read, no-access users
// get nothing. // get nothing.
@@ -490,6 +632,7 @@ func (h *Handler) createInterface(w http.ResponseWriter, r *http.Request) {
h.log.Error("record panel ownership", "id", id, "err", err) h.log.Error("record panel ownership", "id", id, "err", err)
} }
} }
h.recordMutation(r, "interface.create", id)
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated) w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(map[string]string{"id": id}) _ = json.NewEncoder(w).Encode(map[string]string{"id": id})
@@ -580,6 +723,7 @@ func (h *Handler) updateInterface(w http.ResponseWriter, r *http.Request) {
} }
return return
} }
h.recordMutation(r, "interface.update", id)
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
@@ -721,6 +865,7 @@ func (h *Handler) deleteInterface(w http.ResponseWriter, r *http.Request) {
if err := h.acl.DeletePanel(id); err != nil { if err := h.acl.DeletePanel(id); err != nil {
h.log.Error("delete panel ACL", "id", id, "err", err) h.log.Error("delete panel ACL", "id", id, "err", err)
} }
h.recordMutation(r, "interface.delete", id)
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
@@ -1076,6 +1221,80 @@ func (h *Handler) deleteSynthetic(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
func (h *Handler) listSyntheticVersions(w http.ResponseWriter, r *http.Request) {
if h.synthetic == nil {
jsonError(w, http.StatusServiceUnavailable, "synthetic data source not enabled")
return
}
name := r.PathValue("name")
versions, err := h.synthetic.Versions(name)
if err != nil {
jsonError(w, http.StatusNotFound, "synthetic signal not found: "+name)
return
}
jsonOK(w, versions)
}
func (h *Handler) getSyntheticVersion(w http.ResponseWriter, r *http.Request) {
if h.synthetic == nil {
jsonError(w, http.StatusServiceUnavailable, "synthetic data source not enabled")
return
}
name := r.PathValue("name")
version, err := strconv.Atoi(r.PathValue("version"))
if err != nil {
jsonError(w, http.StatusBadRequest, "invalid version: "+r.PathValue("version"))
return
}
def, err := h.synthetic.GetVersion(name, version)
if err != nil {
jsonError(w, http.StatusNotFound, "version not found")
return
}
jsonOK(w, def)
}
func (h *Handler) promoteSyntheticVersion(w http.ResponseWriter, r *http.Request) {
if h.synthetic == nil {
jsonError(w, http.StatusServiceUnavailable, "synthetic data source not enabled")
return
}
name := r.PathValue("name")
version, err := strconv.Atoi(r.PathValue("version"))
if err != nil {
jsonError(w, http.StatusBadRequest, "invalid version: "+r.PathValue("version"))
return
}
def, err := h.synthetic.PromoteVersion(name, version)
if err != nil {
jsonError(w, http.StatusNotFound, "version not found")
return
}
h.recordMutation(r, "synthetic.promote", fmt.Sprintf("%s v%d", name, version))
jsonOK(w, def)
}
func (h *Handler) forkSyntheticVersion(w http.ResponseWriter, r *http.Request) {
if h.synthetic == nil {
jsonError(w, http.StatusServiceUnavailable, "synthetic data source not enabled")
return
}
name := r.PathValue("name")
version, err := strconv.Atoi(r.PathValue("version"))
if err != nil {
jsonError(w, http.StatusBadRequest, "invalid version: "+r.PathValue("version"))
return
}
def, err := h.synthetic.ForkVersion(name, version)
if err != nil {
jsonError(w, http.StatusNotFound, "version not found")
return
}
h.recordMutation(r, "synthetic.fork", fmt.Sprintf("%s v%d -> %s", name, version, def.Name))
w.WriteHeader(http.StatusCreated)
jsonOK(w, map[string]string{"id": def.Name})
}
// ── Control logic ────────────────────────────────────────────────────────────── // ── Control logic ──────────────────────────────────────────────────────────────
func (h *Handler) listControlLogic(w http.ResponseWriter, _ *http.Request) { func (h *Handler) listControlLogic(w http.ResponseWriter, _ *http.Request) {
@@ -1123,6 +1342,7 @@ func (h *Handler) createControlLogic(w http.ResponseWriter, r *http.Request) {
return return
} }
h.ctrlEngine.Reload() h.ctrlEngine.Reload()
h.recordMutation(r, "controllogic.create", g.ID+" "+g.Name)
w.WriteHeader(http.StatusCreated) w.WriteHeader(http.StatusCreated)
jsonOK(w, g) jsonOK(w, g)
} }
@@ -1152,6 +1372,7 @@ func (h *Handler) updateControlLogic(w http.ResponseWriter, r *http.Request) {
return return
} }
h.ctrlEngine.Reload() h.ctrlEngine.Reload()
h.recordMutation(r, "controllogic.update", g.ID+" "+g.Name)
jsonOK(w, g) jsonOK(w, g)
} }
@@ -1164,14 +1385,98 @@ func (h *Handler) deleteControlLogic(w http.ResponseWriter, r *http.Request) {
jsonError(w, http.StatusForbidden, "you are not permitted to edit logic") jsonError(w, http.StatusForbidden, "you are not permitted to edit logic")
return return
} }
if err := h.ctrlLogic.Delete(r.PathValue("id")); err != nil { id := r.PathValue("id")
jsonError(w, http.StatusNotFound, "control logic graph not found: "+r.PathValue("id")) if err := h.ctrlLogic.Delete(id); err != nil {
jsonError(w, http.StatusNotFound, "control logic graph not found: "+id)
return return
} }
h.ctrlEngine.Reload() h.ctrlEngine.Reload()
h.recordMutation(r, "controllogic.delete", id)
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
func (h *Handler) listControlLogicVersions(w http.ResponseWriter, r *http.Request) {
if h.ctrlLogic == nil {
jsonError(w, http.StatusServiceUnavailable, "control logic not enabled")
return
}
versions, err := h.ctrlLogic.Versions(r.PathValue("id"))
if err != nil {
jsonError(w, http.StatusNotFound, "control logic graph not found: "+r.PathValue("id"))
return
}
jsonOK(w, versions)
}
func (h *Handler) getControlLogicVersion(w http.ResponseWriter, r *http.Request) {
if h.ctrlLogic == nil {
jsonError(w, http.StatusServiceUnavailable, "control logic not enabled")
return
}
version, err := strconv.Atoi(r.PathValue("version"))
if err != nil {
jsonError(w, http.StatusBadRequest, "invalid version: "+r.PathValue("version"))
return
}
g, err := h.ctrlLogic.GetVersion(r.PathValue("id"), version)
if err != nil {
jsonError(w, http.StatusNotFound, "version not found")
return
}
jsonOK(w, g)
}
func (h *Handler) promoteControlLogicVersion(w http.ResponseWriter, r *http.Request) {
if h.ctrlLogic == nil {
jsonError(w, http.StatusServiceUnavailable, "control logic not enabled")
return
}
if !h.policy.CanEditLogic(caller(r)) {
jsonError(w, http.StatusForbidden, "you are not permitted to edit logic")
return
}
id := r.PathValue("id")
version, err := strconv.Atoi(r.PathValue("version"))
if err != nil {
jsonError(w, http.StatusBadRequest, "invalid version: "+r.PathValue("version"))
return
}
g, err := h.ctrlLogic.Promote(id, version)
if err != nil {
jsonError(w, http.StatusNotFound, "version not found")
return
}
h.ctrlEngine.Reload()
h.recordMutation(r, "controllogic.promote", fmt.Sprintf("%s v%d", id, version))
jsonOK(w, g)
}
func (h *Handler) forkControlLogicVersion(w http.ResponseWriter, r *http.Request) {
if h.ctrlLogic == nil {
jsonError(w, http.StatusServiceUnavailable, "control logic not enabled")
return
}
if !h.policy.CanEditLogic(caller(r)) {
jsonError(w, http.StatusForbidden, "you are not permitted to edit logic")
return
}
id := r.PathValue("id")
version, err := strconv.Atoi(r.PathValue("version"))
if err != nil {
jsonError(w, http.StatusBadRequest, "invalid version: "+r.PathValue("version"))
return
}
g, err := h.ctrlLogic.Fork(id, version)
if err != nil {
jsonError(w, http.StatusNotFound, "version not found")
return
}
h.ctrlEngine.Reload()
h.recordMutation(r, "controllogic.fork", fmt.Sprintf("%s v%d -> %s", id, version, g.ID))
w.WriteHeader(http.StatusCreated)
jsonOK(w, map[string]string{"id": g.ID})
}
// ── Helpers ─────────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────────
// extractLogicBlock returns the verbatim <logic>…</logic> section of an // extractLogicBlock returns the verbatim <logic>…</logic> section of an
+19 -5
View File
@@ -14,7 +14,9 @@ import (
"github.com/uopi/uopi/internal/access" "github.com/uopi/uopi/internal/access"
"github.com/uopi/uopi/internal/api" "github.com/uopi/uopi/internal/api"
"github.com/uopi/uopi/internal/audit"
"github.com/uopi/uopi/internal/broker" "github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/confmgr"
"github.com/uopi/uopi/internal/controllogic" "github.com/uopi/uopi/internal/controllogic"
"github.com/uopi/uopi/internal/datasource/stub" "github.com/uopi/uopi/internal/datasource/stub"
"github.com/uopi/uopi/internal/panelacl" "github.com/uopi/uopi/internal/panelacl"
@@ -50,10 +52,16 @@ func setup(t *testing.T) (*httptest.Server, func()) {
if err != nil { if err != nil {
t.Fatal("controllogic.NewStore:", err) t.Fatal("controllogic.NewStore:", err)
} }
clEngine := controllogic.NewEngine(ctx, brk, clStore, log)
cfgStore, err := confmgr.New(dir)
if err != nil {
t.Fatal("confmgr.New:", err)
}
clEngine := controllogic.NewEngine(ctx, brk, clStore, cfgStore, audit.Nop(), log)
mux := http.NewServeMux() mux := http.NewServeMux()
api.New(brk, nil, store, access.New("", nil, nil, nil), acl, clStore, clEngine, "", "", log).Register(mux, "/api/v1") api.New(brk, nil, store, cfgStore, access.New("", nil, nil, nil, nil), acl, clStore, clEngine, audit.Nop(), "", "", log).Register(mux, "/api/v1")
srv := httptest.NewServer(mux) srv := httptest.NewServer(mux)
return srv, func() { return srv, func() {
@@ -214,7 +222,9 @@ func TestSearchSignals(t *testing.T) {
resp := get(t, srv, "/api/v1/signals/search?q=sine") resp := get(t, srv, "/api/v1/signals/search?q=sine")
assertStatus(t, resp, http.StatusOK) assertStatus(t, resp, http.StatusOK)
var signals []struct{ Name string `json:"name"` } var signals []struct {
Name string `json:"name"`
}
readJSON(t, resp, &signals) readJSON(t, resp, &signals)
for _, s := range signals { for _, s := range signals {
@@ -259,7 +269,9 @@ func TestInterfaceCRUD(t *testing.T) {
// Create // Create
resp = postRaw(t, srv, "/api/v1/interfaces", "application/xml", []byte(sampleXML)) resp = postRaw(t, srv, "/api/v1/interfaces", "application/xml", []byte(sampleXML))
assertStatus(t, resp, http.StatusCreated) assertStatus(t, resp, http.StatusCreated)
var created struct{ ID string `json:"id"` } var created struct {
ID string `json:"id"`
}
readJSON(t, resp, &created) readJSON(t, resp, &created)
if created.ID == "" { if created.ID == "" {
t.Fatal("expected non-empty ID from create") t.Fatal("expected non-empty ID from create")
@@ -303,7 +315,9 @@ func TestInterfaceCRUD(t *testing.T) {
t.Fatal("clone:", err) t.Fatal("clone:", err)
} }
assertStatus(t, resp, http.StatusCreated) assertStatus(t, resp, http.StatusCreated)
var cloned struct{ ID string `json:"id"` } var cloned struct {
ID string `json:"id"`
}
readJSON(t, resp, &cloned) readJSON(t, resp, &cloned)
if cloned.ID == created.ID { if cloned.ID == created.ID {
t.Error("clone produced same ID as original") t.Error("clone produced same ID as original")
+767
View File
@@ -0,0 +1,767 @@
package api
import (
"context"
"encoding/json"
"errors"
"net/http"
"strconv"
"sync"
"time"
"github.com/uopi/uopi/internal/broker"
"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)
}
// validateConfigInstance evaluates the CUE rules bound to an instance's set
// against its stored values, without persisting anything. The structured
// RuleResult (violations + any transformed values) lets the UI surface
// per-parameter failures.
func (h *Handler) validateConfigInstance(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
}
res, err := h.cfg.ValidateInstanceRules(inst)
if err != nil {
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
jsonOK(w, res)
}
// ── config snapshot / live diff ─────────────────────────────────────────────
// readResult is the per-signal outcome of a parallel live read.
type readResult struct {
val any
err error
}
// readSetSignals reads the current value of every distinct target signal of a
// set in parallel (deduplicated by ds+signal), bounded by ctx. The returned map
// is keyed by {ds, signal}.
func (h *Handler) readSetSignals(ctx context.Context, set confmgr.ConfigSet) map[[2]string]readResult {
jobs := make(map[[2]string]struct{}, len(set.Parameters))
for _, p := range set.Parameters {
jobs[[2]string{p.DS, p.Signal}] = struct{}{}
}
results := make(map[[2]string]readResult, len(jobs))
var mu sync.Mutex
var wg sync.WaitGroup
for k := range jobs {
wg.Add(1)
go func(ds, signal string) {
defer wg.Done()
v, err := h.broker.ReadNow(ctx, broker.SignalRef{DS: ds, Name: signal})
mu.Lock()
results[[2]string{ds, signal}] = readResult{val: v.Data, err: err}
mu.Unlock()
}(k[0], k[1])
}
wg.Wait()
return results
}
// snapshotReader builds a confmgr.ReadFunc backed by a pre-read results map.
func snapshotReader(results map[[2]string]readResult) confmgr.ReadFunc {
return func(ds, signal string) (any, error) {
r, ok := results[[2]string{ds, signal}]
if !ok {
return nil, errors.New("signal not read")
}
return r.val, r.err
}
}
// snapshotConfigSet captures the current value of every target signal of a set
// and stores them as a new config instance. Body: optional {name}. Returns the
// created instance plus the per-parameter capture result.
func (h *Handler) snapshotConfigSet(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
}
var body struct {
Name string `json:"name"`
}
if r.Body != nil {
_ = json.NewDecoder(r.Body).Decode(&body) // body is optional
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
results := h.readSetSignals(ctx, set)
snap := confmgr.Snapshot(set, snapshotReader(results))
name := body.Name
if name == "" {
name = set.Name + " snapshot " + time.Now().Format("2006-01-02 15:04:05")
}
inst := confmgr.ConfigInstance{
Name: name,
SetID: set.ID,
Owner: caller(r),
Values: snap.Values,
}
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.snapshot", out.ID+" "+out.Name+" captured="+strconv.Itoa(snap.Captured)+" failed="+strconv.Itoa(snap.Failed))
w.WriteHeader(http.StatusCreated)
jsonOK(w, struct {
Instance confmgr.ConfigInstance `json:"instance"`
Snapshot confmgr.SnapshotResult `json:"snapshot"`
}{out, snap})
}
// diffConfigInstanceLive compares a stored instance against the current live
// values of its set's target signals, so an operator can see how the saved
// config differs from what the hardware currently holds. The stored side uses
// resolved values (instance value or parameter default) so default-backed
// parameters are not reported as spurious additions.
func (h *Handler) diffConfigInstanceLive(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
}
set, err := h.cfg.SetForInstance(inst)
if err != nil {
jsonError(w, configStatus(err), "load set: "+err.Error())
return
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
results := h.readSetSignals(ctx, set)
snap := confmgr.Snapshot(set, snapshotReader(results))
left := confmgr.ConfigInstance{ID: inst.ID, Name: inst.Name, Values: map[string]any{}}
for _, p := range set.Parameters {
if v, ok := inst.Resolve(p); ok {
left.Values[p.Key] = v
}
}
current := confmgr.ConfigInstance{Name: "current", Values: snap.Values}
jsonOK(w, confmgr.DiffInstances(left, current))
}
// ── config rules (CUE validation/transformation) ────────────────────────────
func (h *Handler) listConfigRules(w http.ResponseWriter, _ *http.Request) {
if !h.configEnabled(w) {
return
}
rules, err := h.cfg.List(confmgr.KindRule)
if err != nil {
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
jsonOK(w, rules)
}
func (h *Handler) getConfigRule(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
rule, err := h.cfg.GetRule(r.PathValue("id"))
if err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
jsonOK(w, rule)
}
func (h *Handler) createConfigRule(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
var rule confmgr.ConfigRule
if err := json.NewDecoder(r.Body).Decode(&rule); err != nil {
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
rule.ID = ""
rule.Owner = caller(r)
out, err := h.cfg.CreateRule(rule, r.URL.Query().Get("tag"))
if err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
h.recordMutation(r, "config.rule.create", out.ID+" "+out.Name)
w.WriteHeader(http.StatusCreated)
jsonOK(w, out)
}
func (h *Handler) updateConfigRule(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
id := r.PathValue("id")
var rule confmgr.ConfigRule
if err := json.NewDecoder(r.Body).Decode(&rule); err != nil {
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
out, err := h.cfg.UpdateRule(id, rule, r.URL.Query().Get("tag"))
if err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
h.recordMutation(r, "config.rule.update", out.ID+" v"+strconv.Itoa(out.Version))
jsonOK(w, out)
}
func (h *Handler) deleteConfigRule(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
id := r.PathValue("id")
if err := h.cfg.Delete(confmgr.KindRule, id); err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
h.recordMutation(r, "config.rule.delete", id)
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) listConfigRuleVersions(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
versions, err := h.cfg.Versions(confmgr.KindRule, r.PathValue("id"))
if err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
jsonOK(w, versions)
}
func (h *Handler) getConfigRuleVersion(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
}
rule, err := h.cfg.GetRuleVersion(r.PathValue("id"), version)
if err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
jsonOK(w, rule)
}
func (h *Handler) promoteConfigRule(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.KindRule, id, version); err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
h.recordMutation(r, "config.rule.promote", id+" v"+strconv.Itoa(version))
rule, err := h.cfg.GetRule(id)
if err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
jsonOK(w, rule)
}
func (h *Handler) forkConfigRule(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.KindRule, id, version)
if err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
h.recordMutation(r, "config.rule.fork", id+" v"+strconv.Itoa(version)+" -> "+newID)
rule, err := h.cfg.GetRule(newID)
if err != nil {
jsonError(w, configStatus(err), err.Error())
return
}
w.WriteHeader(http.StatusCreated)
jsonOK(w, rule)
}
// checkConfigRule compiles a (possibly unsaved) CUE source and, when sample
// values are supplied, evaluates it against them. It powers the editor's live
// validation panel; nothing is persisted.
func (h *Handler) checkConfigRule(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
var body struct {
Source string `json:"source"`
Values map[string]any `json:"values"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
jsonOK(w, confmgr.EvaluateRule(body.Source, body.Values))
}
+107
View File
@@ -0,0 +1,107 @@
package api_test
import (
"net/http"
"testing"
)
// TestConfigSetCRUD exercises create → get → version → apply over HTTP, using
// the stub data source's writable "setpoint" signal as the apply target.
func TestConfigManagerEndToEnd(t *testing.T) {
srv, teardown := setup(t)
defer teardown()
// Create a config set targeting the stub's writable setpoint.
setBody := map[string]any{
"name": "Stub PSU",
"parameters": []map[string]any{
{"key": "sp", "ds": "stub", "signal": "setpoint", "type": "float64", "default": 42.0, "mandatory": true, "min": 0.0, "max": 100.0},
},
}
resp := postJSON(t, srv, "/api/v1/config/sets", setBody)
assertStatus(t, resp, http.StatusCreated)
var set struct {
ID string `json:"id"`
Version int `json:"version"`
}
readJSON(t, resp, &set)
if set.ID == "" || set.Version != 1 {
t.Fatalf("unexpected created set: %+v", set)
}
// List sets includes it.
resp = get(t, srv, "/api/v1/config/sets")
assertStatus(t, resp, http.StatusOK)
var sets []map[string]any
readJSON(t, resp, &sets)
if len(sets) != 1 {
t.Fatalf("want 1 set, got %d", len(sets))
}
// Create an instance assigning a concrete value.
instBody := map[string]any{
"name": "nominal",
"setId": set.ID,
"values": map[string]any{"sp": 73.0},
}
resp = postJSON(t, srv, "/api/v1/config/instances", instBody)
assertStatus(t, resp, http.StatusCreated)
var inst struct {
ID string `json:"id"`
}
readJSON(t, resp, &inst)
if inst.ID == "" {
t.Fatal("instance missing id")
}
// Apply writes the value to the target signal.
resp = postJSON(t, srv, "/api/v1/config/instances/"+inst.ID+"/apply", nil)
assertStatus(t, resp, http.StatusOK)
var res struct {
Applied int `json:"applied"`
Failed int `json:"failed"`
Entries []struct {
Signal string `json:"signal"`
OK bool `json:"ok"`
} `json:"entries"`
}
readJSON(t, resp, &res)
if res.Applied != 1 || res.Failed != 0 {
t.Fatalf("apply summary: applied=%d failed=%d", res.Applied, res.Failed)
}
if len(res.Entries) != 1 || res.Entries[0].Signal != "setpoint" || !res.Entries[0].OK {
t.Errorf("unexpected apply entries: %+v", res.Entries)
}
// Delete the set soft-deletes it.
resp = deleteReq(t, srv, "/api/v1/config/sets/"+set.ID)
assertStatus(t, resp, http.StatusNoContent)
resp = get(t, srv, "/api/v1/config/sets/"+set.ID)
assertStatus(t, resp, http.StatusNotFound)
}
// TestConfigInstanceRejectsInvalidValue verifies server-side validation against
// the bound set (value above the parameter's max).
func TestConfigInstanceRejectsInvalidValue(t *testing.T) {
srv, teardown := setup(t)
defer teardown()
resp := postJSON(t, srv, "/api/v1/config/sets", map[string]any{
"name": "S",
"parameters": []map[string]any{
{"key": "sp", "ds": "stub", "signal": "setpoint", "type": "float64", "max": 10.0},
},
})
assertStatus(t, resp, http.StatusCreated)
var set struct {
ID string `json:"id"`
}
readJSON(t, resp, &set)
resp = postJSON(t, srv, "/api/v1/config/instances", map[string]any{
"name": "bad",
"setId": set.ID,
"values": map[string]any{"sp": 999.0},
})
assertStatus(t, resp, http.StatusBadRequest)
}
+65
View File
@@ -0,0 +1,65 @@
// Package audit records system-affecting actions (signal writes by users or the
// control-logic engine, interface and control-logic mutations) to an append-only
// SQLite log that audit staff can query later. It is enabled via [audit] in the
// config; when disabled a no-op Recorder is used so call sites need no guards.
package audit
import "time"
// Actor types distinguish human-initiated actions from automated ones.
const (
ActorUser = "user" // action attributed to an authenticated end-user
ActorSystem = "system" // action performed by the control-logic engine
)
// Outcomes record whether the action succeeded.
const (
OutcomeOK = "ok"
OutcomeError = "error"
)
// Event is a single audit record. Optional fields are omitted from the database
// when empty. Time defaults to the current time when zero.
type Event struct {
Time time.Time `json:"time"`
Actor string `json:"actor"` // username, or graph name for system actions
ActorType string `json:"actorType"` // ActorUser | ActorSystem
Action string `json:"action"` // e.g. "signal.write", "interface.update"
DS string `json:"ds,omitempty"` // data source (signal writes)
Signal string `json:"signal,omitempty"` // signal / target name
Value string `json:"value,omitempty"` // serialised written value
Detail string `json:"detail,omitempty"` // free-form context (id, graph, trigger)
IP string `json:"ip,omitempty"` // client address (user actions)
Outcome string `json:"outcome"` // OutcomeOK | OutcomeError
Error string `json:"error,omitempty"` // message when Outcome==OutcomeError
}
// Filter selects a subset of events for Query. Zero-valued fields are ignored.
type Filter struct {
Start time.Time
End time.Time
Actor string
Action string
DS string
Signal string
Limit int // <=0 means a default cap is applied
}
// Recorder appends events and answers queries. Implementations must be safe for
// concurrent use and Record must never block the caller for long.
type Recorder interface {
Record(Event)
Query(Filter) ([]Event, error)
Close() error
}
// nopRecorder is used when auditing is disabled. It discards every event.
type nopRecorder struct{}
func (nopRecorder) Record(Event) {}
func (nopRecorder) Query(Filter) ([]Event, error) { return []Event{}, nil }
func (nopRecorder) Close() error { return nil }
// Nop returns a Recorder that discards everything. Call sites can hold a Recorder
// unconditionally and call Record without checking whether auditing is enabled.
func Nop() Recorder { return nopRecorder{} }
+183
View File
@@ -0,0 +1,183 @@
package audit
import (
"database/sql"
"fmt"
"log/slog"
"strings"
"sync"
"time"
_ "modernc.org/sqlite" // pure-Go SQLite driver (no CGo, keeps the single static binary)
)
// defaultQueryLimit caps how many rows Query returns when the filter does not
// specify a smaller limit, protecting the API and UI from unbounded result sets.
const defaultQueryLimit = 1000
// writeBuffer is the depth of the async insert queue. When full, Record falls
// back to a synchronous insert so events are never silently dropped.
const writeBuffer = 4096
// sqliteRecorder appends events to a SQLite database. Inserts are normally
// handled by a background goroutine so Record does not block the calling write
// path; the queue has a synchronous fallback to guarantee durability under load.
type sqliteRecorder struct {
db *sql.DB
log *slog.Logger
ch chan Event
wg sync.WaitGroup
closed chan struct{}
once sync.Once
}
// NewSQLite opens (creating if needed) the audit database at path and starts the
// background writer. The returned Recorder must be Closed on shutdown.
func NewSQLite(path string, log *slog.Logger) (Recorder, error) {
// WAL + a busy timeout let the async writer and synchronous query/fallback
// paths share the file without "database is locked" errors.
dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)", path)
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("open audit db: %w", err)
}
if _, err := db.Exec(schema); err != nil {
_ = db.Close()
return nil, fmt.Errorf("init audit schema: %w", err)
}
r := &sqliteRecorder{
db: db,
log: log,
ch: make(chan Event, writeBuffer),
closed: make(chan struct{}),
}
r.wg.Add(1)
go r.writeLoop()
return r, nil
}
const schema = `
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts_ns INTEGER NOT NULL,
actor TEXT NOT NULL,
actor_type TEXT NOT NULL,
action TEXT NOT NULL,
ds TEXT NOT NULL DEFAULT '',
signal TEXT NOT NULL DEFAULT '',
value TEXT NOT NULL DEFAULT '',
detail TEXT NOT NULL DEFAULT '',
ip TEXT NOT NULL DEFAULT '',
outcome TEXT NOT NULL DEFAULT 'ok',
error TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit_log(ts_ns);
CREATE INDEX IF NOT EXISTS idx_audit_actor ON audit_log(actor);
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log(action);
`
func (r *sqliteRecorder) Record(e Event) {
if e.Time.IsZero() {
e.Time = time.Now()
}
if e.Outcome == "" {
e.Outcome = OutcomeOK
}
select {
case <-r.closed:
// Recorder is shutting down; best-effort synchronous insert.
r.insert(e)
case r.ch <- e:
default:
// Queue full: write synchronously rather than drop an audit record.
r.insert(e)
}
}
func (r *sqliteRecorder) writeLoop() {
defer r.wg.Done()
for e := range r.ch {
r.insert(e)
}
}
func (r *sqliteRecorder) insert(e Event) {
_, err := r.db.Exec(
`INSERT INTO audit_log (ts_ns, actor, actor_type, action, ds, signal, value, detail, ip, outcome, error)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
e.Time.UnixNano(), e.Actor, e.ActorType, e.Action,
e.DS, e.Signal, e.Value, e.Detail, e.IP, e.Outcome, e.Error,
)
if err != nil {
r.log.Error("audit: insert failed", "action", e.Action, "err", err)
}
}
func (r *sqliteRecorder) Query(f Filter) ([]Event, error) {
var where []string
var args []any
if !f.Start.IsZero() {
where = append(where, "ts_ns >= ?")
args = append(args, f.Start.UnixNano())
}
if !f.End.IsZero() {
where = append(where, "ts_ns <= ?")
args = append(args, f.End.UnixNano())
}
if f.Actor != "" {
where = append(where, "actor = ?")
args = append(args, f.Actor)
}
if f.Action != "" {
where = append(where, "action = ?")
args = append(args, f.Action)
}
if f.DS != "" {
where = append(where, "ds = ?")
args = append(args, f.DS)
}
if f.Signal != "" {
where = append(where, "signal LIKE ?")
args = append(args, "%"+f.Signal+"%")
}
limit := f.Limit
if limit <= 0 || limit > defaultQueryLimit {
limit = defaultQueryLimit
}
q := "SELECT ts_ns, actor, actor_type, action, ds, signal, value, detail, ip, outcome, error FROM audit_log"
if len(where) > 0 {
q += " WHERE " + strings.Join(where, " AND ")
}
q += " ORDER BY ts_ns DESC LIMIT ?"
args = append(args, limit)
rows, err := r.db.Query(q, args...)
if err != nil {
return nil, fmt.Errorf("query audit log: %w", err)
}
defer rows.Close()
out := []Event{}
for rows.Next() {
var e Event
var tsNs int64
if err := rows.Scan(&tsNs, &e.Actor, &e.ActorType, &e.Action,
&e.DS, &e.Signal, &e.Value, &e.Detail, &e.IP, &e.Outcome, &e.Error); err != nil {
return nil, fmt.Errorf("scan audit row: %w", err)
}
e.Time = time.Unix(0, tsNs)
out = append(out, e)
}
return out, rows.Err()
}
func (r *sqliteRecorder) Close() error {
r.once.Do(func() {
close(r.closed)
close(r.ch)
})
r.wg.Wait()
return r.db.Close()
}
+68
View File
@@ -0,0 +1,68 @@
package audit
import (
"log/slog"
"path/filepath"
"testing"
"time"
)
func TestSQLiteRoundTrip(t *testing.T) {
path := filepath.Join(t.TempDir(), "audit.db")
rec, err := NewSQLite(path, slog.Default())
if err != nil {
t.Fatal("NewSQLite:", err)
}
defer rec.Close()
base := time.Now()
rec.Record(Event{Time: base, Actor: "alice", ActorType: ActorUser, Action: "signal.write", DS: "epics", Signal: "PV:A", Value: "1.5"})
rec.Record(Event{Time: base.Add(time.Second), Actor: "flow1", ActorType: ActorSystem, Action: "signal.write", DS: "epics", Signal: "PV:B", Value: "0"})
rec.Record(Event{Time: base.Add(2 * time.Second), Actor: "bob", ActorType: ActorUser, Action: "interface.update", Detail: "panel-1", Outcome: OutcomeError, Error: "denied"})
// Flush the async writer.
if err := rec.Close(); err != nil {
t.Fatal("Close:", err)
}
rec, err = NewSQLite(path, slog.Default())
if err != nil {
t.Fatal("reopen:", err)
}
defer rec.Close()
all, err := rec.Query(Filter{})
if err != nil {
t.Fatal("Query:", err)
}
if len(all) != 3 {
t.Fatalf("got %d events, want 3", len(all))
}
// Newest first.
if all[0].Actor != "bob" {
t.Errorf("first actor = %q, want bob", all[0].Actor)
}
byActor, err := rec.Query(Filter{Actor: "alice"})
if err != nil {
t.Fatal("Query actor:", err)
}
if len(byActor) != 1 || byActor[0].Signal != "PV:A" {
t.Errorf("actor filter = %+v, want one PV:A event", byActor)
}
byAction, err := rec.Query(Filter{Action: "signal.write"})
if err != nil {
t.Fatal("Query action:", err)
}
if len(byAction) != 2 {
t.Errorf("action filter returned %d, want 2", len(byAction))
}
since, err := rec.Query(Filter{Start: base.Add(1500 * time.Millisecond)})
if err != nil {
t.Fatal("Query start:", err)
}
if len(since) != 1 || since[0].Actor != "bob" {
t.Errorf("time filter = %+v, want one bob event", since)
}
}
+25
View File
@@ -251,6 +251,31 @@ func (b *Broker) fanOut(ref SignalRef, sub *signalSub, rawCh <-chan datasource.V
} }
} }
// ReadNow performs a one-shot synchronous read of a signal's current value.
// It starts a dedicated upstream subscription, returns the first value the data
// source delivers, then tears the subscription down. The read is bounded by ctx
// (callers should pass a timeout). This bypasses the shared fan-out cache because
// not every signal of interest (e.g. arbitrary config-set targets) is otherwise
// subscribed.
func (b *Broker) ReadNow(ctx context.Context, ref SignalRef) (datasource.Value, error) {
ds, ok := b.Source(ref.DS)
if !ok {
return datasource.Value{}, fmt.Errorf("unknown data source %q", ref.DS)
}
ch := make(chan datasource.Value, 1)
cancel, err := ds.Subscribe(ctx, ref.Name, ch)
if err != nil {
return datasource.Value{}, fmt.Errorf("read %s/%s: %w", ref.DS, ref.Name, err)
}
defer cancel()
select {
case v := <-ch:
return v, nil
case <-ctx.Done():
return datasource.Value{}, ctx.Err()
}
}
// ActiveSubscriptions returns the number of currently active upstream signal // ActiveSubscriptions returns the number of currently active upstream signal
// subscriptions. Useful for diagnostics and tests. // subscriptions. Useful for diagnostics and tests.
func (b *Broker) ActiveSubscriptions() int { func (b *Broker) ActiveSubscriptions() int {
+22
View File
@@ -12,10 +12,23 @@ import (
type Config struct { type Config struct {
Server ServerConfig `toml:"server"` Server ServerConfig `toml:"server"`
Datasource DatasourceConfig `toml:"datasource"` Datasource DatasourceConfig `toml:"datasource"`
Audit AuditConfig `toml:"audit"`
// Groups are named sets of users, referenced by panel sharing rules. // Groups are named sets of users, referenced by panel sharing rules.
Groups []GroupDef `toml:"groups"` Groups []GroupDef `toml:"groups"`
} }
// AuditConfig controls the audit trail. When enabled, every user and automated
// action that could affect the controlled system (signal writes, control-logic
// changes) is recorded to a SQLite database for later review by audit staff.
type AuditConfig struct {
Enabled bool `toml:"enabled"`
DBPath string `toml:"db_path"` // SQLite file; default {storage_dir}/audit.db
// Readers lists users or group names allowed to view the audit log. Empty
// leaves viewing unrestricted (any caller with read access). Anonymous /
// trusted-LAN callers are always permitted.
Readers []string `toml:"readers"`
}
// GroupDef is a named set of users defined as [[groups]] in the config file. // GroupDef is a named set of users defined as [[groups]] in the config file.
type GroupDef struct { type GroupDef struct {
Name string `toml:"name"` Name string `toml:"name"`
@@ -141,6 +154,15 @@ func applyEnv(cfg *Config) {
if v := env("UOPI_SERVER_LOGIC_EDITORS"); v != "" { if v := env("UOPI_SERVER_LOGIC_EDITORS"); v != "" {
cfg.Server.LogicEditors = strings.Fields(v) cfg.Server.LogicEditors = strings.Fields(v)
} }
if v := env("UOPI_AUDIT_ENABLED"); v != "" {
cfg.Audit.Enabled = (v == "true" || v == "YES")
}
if v := env("UOPI_AUDIT_DB_PATH"); v != "" {
cfg.Audit.DBPath = v
}
if v := env("UOPI_AUDIT_READERS"); v != "" {
cfg.Audit.Readers = strings.Fields(v)
}
if v := env("UOPI_EPICS_CA_ADDR_LIST"); v != "" { if v := env("UOPI_EPICS_CA_ADDR_LIST"); v != "" {
cfg.Datasource.EPICS.CAAddrList = v cfg.Datasource.EPICS.CAAddrList = v
} }
+56
View File
@@ -0,0 +1,56 @@
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
}
v = p.normalize(v)
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
}
+108
View File
@@ -0,0 +1,108 @@
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 TestApplyArrayNormalizes(t *testing.T) {
set := ConfigSet{ID: "s", Name: "s", Parameters: []Parameter{
{Key: "wf", DS: "d", Signal: "WF", Type: TypeFloatArray},
}}
// JSON decoding yields []any of float64 — apply must hand the datasource a []float64.
inst := ConfigInstance{ID: "i", SetID: "s", Values: map[string]any{"wf": []any{1.0, 2.0, 3.0}}}
var got any
res := Apply(set, inst, func(_, _ string, value any) error { got = value; return nil })
if res.Applied != 1 {
t.Fatalf("applied=%d", res.Applied)
}
arr, ok := got.([]float64)
if !ok || len(arr) != 3 || arr[0] != 1 || arr[2] != 3 {
t.Fatalf("want []float64{1,2,3}, got %T %v", got, got)
}
}
func TestArrayValidation(t *testing.T) {
lo := 0.0
p := Parameter{Key: "wf", DS: "d", Signal: "S", Type: TypeFloatArray, Min: &lo}
if err := p.checkValue([]any{1.0, 2.0}); err != nil {
t.Fatalf("valid array rejected: %v", err)
}
if err := p.checkValue([]any{1.0, -5.0}); err == nil {
t.Fatal("expected out-of-range element to be rejected")
}
if err := p.checkValue("notarray"); err == nil {
t.Fatal("expected non-array value to be rejected")
}
}
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)
}
}
}
+199
View File
@@ -0,0 +1,199 @@
package confmgr
import (
"fmt"
"reflect"
"strings"
"cuelang.org/go/cue"
"cuelang.org/go/cue/cuecontext"
cueerrors "cuelang.org/go/cue/errors"
)
// ConfigRule is a CUE-based validation/transformation rule bound to a config
// set. Its Source is CUE describing constraints and/or derivations over the
// instance's parameter values. Regular CUE fields whose key matches a set
// parameter are unified with the instance value; constraints that fail produce
// violations, and concrete fields that differ from the instance value are
// reported (and persisted) as transformations. Hidden fields (_x) and
// definitions (#X) are available for helpers and are excluded from both
// concreteness checks and transformation output. Rules are versioned git-style,
// exactly like sets and instances.
type ConfigRule struct {
ID string `json:"id"`
Name string `json:"name"`
SetID string `json:"setId"`
Description string `json:"description,omitempty"`
Version int `json:"version"`
Tag string `json:"tag,omitempty"`
Owner string `json:"owner,omitempty"`
Source string `json:"source"`
}
// RuleViolation is a single constraint failure from evaluating a rule.
type RuleViolation struct {
Rule string `json:"rule,omitempty"` // rule ID that produced it (aggregate runs)
Path string `json:"path,omitempty"` // dotted parameter path, when known
Message string `json:"message"`
}
// RuleResult is the outcome of evaluating one or more rules against a set of
// instance values. OK is true only when there is no compile error and no
// violation. Transformed holds the parameter values the rule(s) derived or
// overrode (keys are parameter keys; values are the new concrete values).
type RuleResult struct {
OK bool `json:"ok"`
Violations []RuleViolation `json:"violations,omitempty"`
Transformed map[string]any `json:"transformed,omitempty"`
CompileError string `json:"compileError,omitempty"`
}
// RuleError wraps a failing RuleResult so it can flow through the store's
// error-returning API while preserving the structured violations.
type RuleError struct {
Result RuleResult
}
func (e *RuleError) Error() string {
if e.Result.CompileError != "" {
return "rule compile error: " + e.Result.CompileError
}
msgs := make([]string, 0, len(e.Result.Violations))
for _, v := range e.Result.Violations {
if v.Path != "" {
msgs = append(msgs, v.Path+": "+v.Message)
} else {
msgs = append(msgs, v.Message)
}
}
if len(msgs) == 0 {
return "rule validation failed"
}
return "rule validation failed: " + strings.Join(msgs, "; ")
}
// Validate compiles the rule's CUE source and checks basic metadata. It does
// not require concrete values — only that the source is syntactically and
// structurally well-formed CUE.
func (r ConfigRule) Validate() error {
if strings.TrimSpace(r.Name) == "" {
return fmt.Errorf("rule name must not be empty")
}
if strings.TrimSpace(r.SetID) == "" {
return fmt.Errorf("rule must reference a config set")
}
ctx := cuecontext.New()
v := ctx.CompileString(r.Source)
if err := v.Err(); err != nil {
return fmt.Errorf("invalid CUE: %s", firstError(err))
}
return nil
}
// EvaluateRule unifies a single CUE source with the given instance values and
// reports violations plus any transformed values. A compile error yields a
// non-OK result with CompileError populated (and no violations), so callers can
// distinguish "the rule is broken" from "the values are invalid".
func EvaluateRule(source string, values map[string]any) RuleResult {
ctx := cuecontext.New()
schema := ctx.CompileString(source)
if err := schema.Err(); err != nil {
return RuleResult{OK: false, CompileError: firstError(err)}
}
data := ctx.Encode(values)
if err := data.Err(); err != nil {
return RuleResult{OK: false, CompileError: "cannot encode values: " + firstError(err)}
}
unified := schema.Unify(data)
res := RuleResult{OK: true}
if err := unified.Validate(cue.Concrete(true), cue.All()); err != nil {
res.OK = false
for _, e := range cueerrors.Errors(err) {
res.Violations = append(res.Violations, RuleViolation{
Path: strings.Join(e.Path(), "."),
Message: cleanMessage(e),
})
}
if len(res.Violations) == 0 {
res.Violations = append(res.Violations, RuleViolation{Message: firstError(err)})
}
return res
}
// Extract transformations: regular concrete fields whose value differs from
// the supplied input. Definitions and hidden fields are skipped by Fields().
iter, err := unified.Fields()
if err != nil {
return res
}
for iter.Next() {
key := iter.Selector().Unquoted()
if key == "" {
key = strings.Trim(iter.Selector().String(), `"`)
}
var v any
if err := iter.Value().Decode(&v); err != nil {
continue
}
if !reflect.DeepEqual(v, values[key]) {
if res.Transformed == nil {
res.Transformed = map[string]any{}
}
res.Transformed[key] = v
}
}
return res
}
// evaluateRules runs several rules against the same values and aggregates the
// outcome. Violations are tagged with the rule ID. Transformations are applied
// cumulatively in order; a later rule sees earlier rules' outputs.
func evaluateRules(rules []ConfigRule, values map[string]any) RuleResult {
agg := RuleResult{OK: true}
cur := make(map[string]any, len(values))
for k, v := range values {
cur[k] = v
}
for _, r := range rules {
one := EvaluateRule(r.Source, cur)
if one.CompileError != "" {
agg.OK = false
agg.Violations = append(agg.Violations, RuleViolation{Rule: r.ID, Message: "compile error: " + one.CompileError})
continue
}
if !one.OK {
agg.OK = false
for _, v := range one.Violations {
v.Rule = r.ID
agg.Violations = append(agg.Violations, v)
}
continue
}
for k, v := range one.Transformed {
if agg.Transformed == nil {
agg.Transformed = map[string]any{}
}
agg.Transformed[k] = v
cur[k] = v
}
}
return agg
}
// firstError renders the first CUE error as a single line.
func firstError(err error) string {
errs := cueerrors.Errors(err)
if len(errs) == 0 {
return err.Error()
}
return cleanMessage(errs[0])
}
// cleanMessage formats a CUE error to a compact single-line string without the
// noisy file:line position prefix that cuecontext synthesises for anonymous
// sources.
func cleanMessage(e cueerrors.Error) string {
format, args := e.Msg()
return fmt.Sprintf(format, args...)
}
+80
View File
@@ -0,0 +1,80 @@
package confmgr
import "testing"
func TestEvaluateRule_Valid(t *testing.T) {
src := `
current_limit: >=0 & <=max
max: 100
`
res := EvaluateRule(src, map[string]any{"current_limit": 50.0})
if !res.OK {
t.Fatalf("expected OK, got violations: %+v compile=%q", res.Violations, res.CompileError)
}
if res.CompileError != "" {
t.Fatalf("unexpected compile error: %s", res.CompileError)
}
}
func TestEvaluateRule_Violation(t *testing.T) {
src := `current_limit: >=0 & <=100`
res := EvaluateRule(src, map[string]any{"current_limit": 150.0})
if res.OK {
t.Fatalf("expected violation, got OK")
}
if len(res.Violations) == 0 {
t.Fatalf("expected at least one violation")
}
}
func TestEvaluateRule_Transform(t *testing.T) {
// power is derived from the supplied current & voltage.
src := `
current: number
voltage: number
power: current * voltage
`
res := EvaluateRule(src, map[string]any{"current": 2.0, "voltage": 3.0})
if !res.OK {
t.Fatalf("expected OK, got %+v", res.Violations)
}
got, ok := res.Transformed["power"]
if !ok {
t.Fatalf("expected transformed power, got %+v", res.Transformed)
}
if f, _ := toFloat(got); f != 6 {
t.Fatalf("expected power=6, got %v", got)
}
}
func TestEvaluateRule_DefaultFillsMissing(t *testing.T) {
src := `mode: *"auto" | "manual"`
res := EvaluateRule(src, map[string]any{})
if !res.OK {
t.Fatalf("expected OK, got %+v", res.Violations)
}
if res.Transformed["mode"] != "auto" {
t.Fatalf("expected mode default auto, got %v", res.Transformed["mode"])
}
}
func TestEvaluateRule_CompileError(t *testing.T) {
res := EvaluateRule(`this is : : not cue`, map[string]any{})
if res.OK || res.CompileError == "" {
t.Fatalf("expected compile error, got %+v", res)
}
}
func TestConfigRule_Validate(t *testing.T) {
r := ConfigRule{Name: "r", SetID: "s", Source: `x: >=0`}
if err := r.Validate(); err != nil {
t.Fatalf("expected valid rule, got %v", err)
}
bad := ConfigRule{Name: "r", SetID: "s", Source: `x: : :`}
if err := bad.Validate(); err == nil {
t.Fatalf("expected compile error for bad source")
}
if err := (ConfigRule{Source: `x: 1`}).Validate(); err == nil {
t.Fatalf("expected error for missing name/setID")
}
}
+140
View File
@@ -0,0 +1,140 @@
package confmgr
import (
"fmt"
"sort"
)
// ChangeStatus classifies a single diff entry.
type ChangeStatus string
const (
StatusAdded ChangeStatus = "added"
StatusRemoved ChangeStatus = "removed"
StatusChanged ChangeStatus = "changed"
StatusUnchanged ChangeStatus = "unchanged"
)
// SetDiffEntry is one parameter-level difference between two config sets.
type SetDiffEntry struct {
Key string `json:"key"`
Status ChangeStatus `json:"status"`
Left *Parameter `json:"left,omitempty"`
Right *Parameter `json:"right,omitempty"`
}
// InstanceDiffEntry is one value-level difference between two config instances.
type InstanceDiffEntry struct {
Key string `json:"key"`
Status ChangeStatus `json:"status"`
Left any `json:"left,omitempty"`
Right any `json:"right,omitempty"`
}
// DiffSets compares two config sets parameter-by-parameter (keyed by Key),
// returning entries sorted by key. Both sides are included so the frontend can
// render either unified or side-by-side.
func DiffSets(left, right ConfigSet) []SetDiffEntry {
keys := map[string]bool{}
li := map[string]Parameter{}
ri := map[string]Parameter{}
for _, p := range left.Parameters {
li[p.Key] = p
keys[p.Key] = true
}
for _, p := range right.Parameters {
ri[p.Key] = p
keys[p.Key] = true
}
out := make([]SetDiffEntry, 0, len(keys))
for k := range keys {
l, lok := li[k]
r, rok := ri[k]
e := SetDiffEntry{Key: k}
switch {
case lok && !rok:
e.Status = StatusRemoved
lp := l
e.Left = &lp
case !lok && rok:
e.Status = StatusAdded
rp := r
e.Right = &rp
default:
lp, rp := l, r
e.Left, e.Right = &lp, &rp
if paramEqual(l, r) {
e.Status = StatusUnchanged
} else {
e.Status = StatusChanged
}
}
out = append(out, e)
}
sort.Slice(out, func(i, j int) bool { return out[i].Key < out[j].Key })
return out
}
// DiffInstances compares two config instances value-by-value (keyed by
// parameter key), returning entries sorted by key.
func DiffInstances(left, right ConfigInstance) []InstanceDiffEntry {
keys := map[string]bool{}
for k := range left.Values {
keys[k] = true
}
for k := range right.Values {
keys[k] = true
}
out := make([]InstanceDiffEntry, 0, len(keys))
for k := range keys {
lv, lok := left.Values[k]
rv, rok := right.Values[k]
e := InstanceDiffEntry{Key: k, Left: lv, Right: rv}
switch {
case lok && !rok:
e.Status = StatusRemoved
case !lok && rok:
e.Status = StatusAdded
case valueEqual(lv, rv):
e.Status = StatusUnchanged
default:
e.Status = StatusChanged
}
out = append(out, e)
}
sort.Slice(out, func(i, j int) bool { return out[i].Key < out[j].Key })
return out
}
func paramEqual(a, b Parameter) bool {
if a.Label != b.Label || a.Group != b.Group || a.Subgroup != b.Subgroup ||
a.DS != b.DS || a.Signal != b.Signal || a.Type != b.Type ||
a.Mandatory != b.Mandatory || a.Unit != b.Unit || a.Description != b.Description {
return false
}
if !valueEqual(a.Default, b.Default) || !floatPtrEqual(a.Min, b.Min) || !floatPtrEqual(a.Max, b.Max) {
return false
}
if len(a.EnumValues) != len(b.EnumValues) {
return false
}
for i := range a.EnumValues {
if a.EnumValues[i] != b.EnumValues[i] {
return false
}
}
return true
}
func floatPtrEqual(a, b *float64) bool {
if a == nil || b == nil {
return a == b
}
return *a == *b
}
// valueEqual compares two JSON-decoded scalar values for diff purposes by
// rendering them to a canonical string.
func valueEqual(a, b any) bool {
return fmt.Sprintf("%v", a) == fmt.Sprintf("%v", b)
}
+55
View File
@@ -0,0 +1,55 @@
package confmgr
import "testing"
func TestDiffSets(t *testing.T) {
left := ConfigSet{Parameters: []Parameter{
{Key: "a", DS: "d", Signal: "A", Type: TypeFloat, Default: 1.0},
{Key: "b", DS: "d", Signal: "B", Type: TypeFloat},
}}
right := ConfigSet{Parameters: []Parameter{
{Key: "a", DS: "d", Signal: "A", Type: TypeFloat, Default: 2.0}, // changed default
{Key: "c", DS: "d", Signal: "C", Type: TypeFloat}, // added
}}
diff := DiffSets(left, right)
got := map[string]ChangeStatus{}
for _, e := range diff {
got[e.Key] = e.Status
}
if got["a"] != StatusChanged {
t.Errorf("a: want changed, got %s", got["a"])
}
if got["b"] != StatusRemoved {
t.Errorf("b: want removed, got %s", got["b"])
}
if got["c"] != StatusAdded {
t.Errorf("c: want added, got %s", got["c"])
}
}
func TestDiffSetsUnchanged(t *testing.T) {
p := Parameter{Key: "a", DS: "d", Signal: "A", Type: TypeFloat, Default: 1.0}
diff := DiffSets(ConfigSet{Parameters: []Parameter{p}}, ConfigSet{Parameters: []Parameter{p}})
if len(diff) != 1 || diff[0].Status != StatusUnchanged {
t.Errorf("want single unchanged entry, got %+v", diff)
}
}
func TestDiffInstances(t *testing.T) {
left := ConfigInstance{Values: map[string]any{"a": 1.0, "b": "x"}}
right := ConfigInstance{Values: map[string]any{"a": 2.0, "c": true}}
diff := DiffInstances(left, right)
got := map[string]ChangeStatus{}
for _, e := range diff {
got[e.Key] = e.Status
}
if got["a"] != StatusChanged {
t.Errorf("a: want changed, got %s", got["a"])
}
if got["b"] != StatusRemoved {
t.Errorf("b: want removed, got %s", got["b"])
}
if got["c"] != StatusAdded {
t.Errorf("c: want added, got %s", got["c"])
}
}
+273
View File
@@ -0,0 +1,273 @@
// 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"
TypeFloatArray ParamType = "float64[]" // waveform; value is a list of numbers
)
func (t ParamType) valid() bool {
switch t {
case TypeFloat, TypeInt, TypeBool, TypeString, TypeEnum, TypeFloatArray:
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)
}
case TypeFloatArray:
arr, err := toFloatArray(v)
if err != nil {
return err
}
for i, f := range arr {
if p.Min != nil && f < *p.Min {
return fmt.Errorf("element %d value %v below minimum %v", i, f, *p.Min)
}
if p.Max != nil && f > *p.Max {
return fmt.Errorf("element %d value %v above maximum %v", i, f, *p.Max)
}
}
}
return nil
}
// normalize coerces a JSON-decoded value into the canonical Go type the
// datasource write path expects. Array parameters become []float64 (JSON yields
// []any); scalar values are returned unchanged.
func (p Parameter) normalize(v any) any {
if p.Type == TypeFloatArray {
if arr, err := toFloatArray(v); err == nil {
return arr
}
}
return v
}
// toFloatArray coerces a JSON-decoded array value to []float64. JSON
// unmarshalling yields []any of float64, but a native []float64 is also
// accepted.
func toFloatArray(v any) ([]float64, error) {
switch a := v.(type) {
case []float64:
return a, nil
case []any:
out := make([]float64, len(a))
for i, e := range a {
f, err := toFloat(e)
if err != nil {
return nil, fmt.Errorf("element %d: %w", i, err)
}
out[i] = f
}
return out, nil
default:
return nil, fmt.Errorf("value %v is not an array", v)
}
}
// 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)
}
}
+129
View File
@@ -0,0 +1,129 @@
package confmgr
import (
"fmt"
"math"
"slices"
"strconv"
)
// ReadFunc reads the current raw value of a target signal. The API/engine layer
// supplies a closure backed by the broker (ReadNow) so confmgr stays decoupled
// from the transport. The returned value mirrors datasource.Value.Data
// (float64 | []float64 | string | int64 | bool; enums arrive as an int64 index).
type ReadFunc func(ds, signal string) (any, error)
// SnapshotEntry records the outcome of reading one parameter's target signal.
type SnapshotEntry struct {
Key string `json:"key"`
DS string `json:"ds"`
Signal string `json:"signal"`
Value any `json:"value,omitempty"`
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
}
// SnapshotResult summarises a snapshot run. Values holds the captured,
// type-coerced values ready to populate a new ConfigInstance.
type SnapshotResult struct {
SetID string `json:"setId"`
Entries []SnapshotEntry `json:"entries"`
Captured int `json:"captured"`
Failed int `json:"failed"`
Values map[string]any `json:"-"`
}
// Snapshot reads the current value of every parameter's target signal via read
// and builds a value map for a new instance. Read failures and values that
// cannot be coerced to the parameter's type are recorded per-entry rather than
// aborting the whole snapshot, so a partial capture is reported faithfully.
func Snapshot(set ConfigSet, read ReadFunc) SnapshotResult {
res := SnapshotResult{
SetID: set.ID,
Entries: make([]SnapshotEntry, 0, len(set.Parameters)),
Values: make(map[string]any, len(set.Parameters)),
}
for _, p := range set.Parameters {
e := SnapshotEntry{Key: p.Key, DS: p.DS, Signal: p.Signal}
raw, err := read(p.DS, p.Signal)
if err != nil {
e.Error = err.Error()
res.Failed++
res.Entries = append(res.Entries, e)
continue
}
v, err := p.coerceSnapshot(raw)
if err != nil {
e.Error = err.Error()
res.Failed++
res.Entries = append(res.Entries, e)
continue
}
e.Value = v
e.OK = true
res.Values[p.Key] = v
res.Captured++
res.Entries = append(res.Entries, e)
}
return res
}
// coerceSnapshot converts a raw datasource value into the canonical Go value the
// parameter's type expects, so the result passes checkValue and serialises
// cleanly. Enum signals arrive as an int64 index and are mapped to their string.
func (p Parameter) coerceSnapshot(raw any) (any, error) {
switch p.Type {
case TypeFloat:
return toFloat(raw)
case TypeInt:
f, err := toFloat(raw)
if err != nil {
return nil, err
}
return int64(math.Round(f)), nil
case TypeBool:
switch b := raw.(type) {
case bool:
return b, nil
case string:
pb, err := strconv.ParseBool(b)
if err != nil {
return nil, fmt.Errorf("value %q is not a bool", b)
}
return pb, nil
default:
f, err := toFloat(raw)
if err != nil {
return nil, fmt.Errorf("value %v is not a bool", raw)
}
return f != 0, nil
}
case TypeString:
if s, ok := raw.(string); ok {
return s, nil
}
return fmt.Sprintf("%v", raw), nil
case TypeEnum:
// Enum signals deliver an int64 index into the metadata strings; map it
// to this parameter's enum value. A string already in range is kept.
if s, ok := raw.(string); ok {
if slices.Contains(p.EnumValues, s) {
return s, nil
}
return nil, fmt.Errorf("value %q is not an allowed enum value", s)
}
f, err := toFloat(raw)
if err != nil {
return nil, fmt.Errorf("enum value %v is not an index", raw)
}
idx := int(math.Round(f))
if idx < 0 || idx >= len(p.EnumValues) {
return nil, fmt.Errorf("enum index %d out of range", idx)
}
return p.EnumValues[idx], nil
case TypeFloatArray:
return toFloatArray(raw)
default:
return raw, nil
}
}
+83
View File
@@ -0,0 +1,83 @@
package confmgr
import (
"errors"
"testing"
)
func TestSnapshotCapturesAndCoerces(t *testing.T) {
set := ConfigSet{
ID: "set1",
Name: "s",
Parameters: []Parameter{
{Key: "v", DS: "epics", Signal: "PSU:V", Type: TypeFloat},
{Key: "n", DS: "epics", Signal: "PSU:N", Type: TypeInt},
{Key: "en", DS: "epics", Signal: "PSU:EN", Type: TypeBool},
{Key: "mode", DS: "epics", Signal: "PSU:MODE", Type: TypeEnum, EnumValues: []string{"off", "low", "high"}},
{Key: "wf", DS: "epics", Signal: "PSU:WF", Type: TypeFloatArray},
{Key: "lbl", DS: "epics", Signal: "PSU:LBL", Type: TypeString},
},
}
live := map[string]any{
"PSU:V": 24.5,
"PSU:N": 3.0, // float reading → coerced to int64
"PSU:EN": int64(1), // numeric → bool true
"PSU:MODE": int64(2), // enum index → "high"
"PSU:WF": []float64{1, 2, 3},
"PSU:LBL": "ready",
}
res := Snapshot(set, func(_, signal string) (any, error) {
v, ok := live[signal]
if !ok {
return nil, errors.New("not read")
}
return v, nil
})
if res.Captured != 6 || res.Failed != 0 {
t.Fatalf("summary: captured=%d failed=%d", res.Captured, res.Failed)
}
if res.Values["v"] != 24.5 {
t.Errorf("v = %v, want 24.5", res.Values["v"])
}
if res.Values["n"] != int64(3) {
t.Errorf("n = %v (%T), want int64(3)", res.Values["n"], res.Values["n"])
}
if res.Values["en"] != true {
t.Errorf("en = %v, want true", res.Values["en"])
}
if res.Values["mode"] != "high" {
t.Errorf("mode = %v, want high", res.Values["mode"])
}
if res.Values["lbl"] != "ready" {
t.Errorf("lbl = %v, want ready", res.Values["lbl"])
}
// The captured values must validate against the set.
inst := ConfigInstance{SetID: set.ID, Values: res.Values}
if err := inst.ValidateAgainst(set); err != nil {
t.Errorf("snapshot values fail validation: %v", err)
}
}
func TestSnapshotReadFailureRecorded(t *testing.T) {
set := ConfigSet{
ID: "set1",
Name: "s",
Parameters: []Parameter{
{Key: "a", DS: "epics", Signal: "A", Type: TypeFloat},
{Key: "b", DS: "epics", Signal: "B", Type: TypeFloat},
},
}
res := Snapshot(set, func(_, signal string) (any, error) {
if signal == "B" {
return nil, errors.New("offline")
}
return 1.0, nil
})
if res.Captured != 1 || res.Failed != 1 {
t.Fatalf("summary: captured=%d failed=%d", res.Captured, res.Failed)
}
if _, ok := res.Values["b"]; ok {
t.Errorf("failed parameter must not appear in values")
}
}
+684
View File
@@ -0,0 +1,684 @@
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
KindRule
)
func (k Kind) sub() string {
switch k {
case KindInstance:
return "instances"
case KindRule:
return "rules"
default:
return "sets"
}
}
// Meta is the lightweight listing representation.
type Meta struct {
ID string `json:"id"`
Name string `json:"name"`
Version int `json:"version"`
// SetID is only populated for instances (the schema they belong to); it is
// empty for sets. Lets clients group/filter instances by set without a GET
// per instance.
SetID string `json:"setId,omitempty"`
}
// 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, KindRule} {
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"`
SetID string `json:"setId"`
}
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, SetID: h.SetID})
}
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
}
if err := s.applyRules(&inst, 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
}
if err := s.applyRules(&inst, 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)
}
// ── rules ────────────────────────────────────────────────────────────────────
// GetRule returns the current revision of a CUE validation/transformation rule.
func (s *Store) GetRule(id string) (ConfigRule, error) {
data, err := s.get(KindRule, id)
if err != nil {
return ConfigRule{}, err
}
var rule ConfigRule
return rule, json.Unmarshal(data, &rule)
}
// GetRuleVersion returns a specific revision of a rule.
func (s *Store) GetRuleVersion(id string, version int) (ConfigRule, error) {
data, err := s.getVersion(KindRule, id, version)
if err != nil {
return ConfigRule{}, err
}
var rule ConfigRule
return rule, json.Unmarshal(data, &rule)
}
// CreateRule validates the rule's CUE source and stores it.
func (s *Store) CreateRule(rule ConfigRule, tag string) (ConfigRule, error) {
if err := rule.Validate(); err != nil {
return ConfigRule{}, err
}
obj, err := toMap(rule)
if err != nil {
return ConfigRule{}, err
}
_, data, err := s.create(KindRule, obj, tag)
if err != nil {
return ConfigRule{}, err
}
var out ConfigRule
return out, json.Unmarshal(data, &out)
}
// UpdateRule validates and stores a new revision of an existing rule.
func (s *Store) UpdateRule(id string, rule ConfigRule, tag string) (ConfigRule, error) {
if err := rule.Validate(); err != nil {
return ConfigRule{}, err
}
obj, err := toMap(rule)
if err != nil {
return ConfigRule{}, err
}
data, err := s.update(KindRule, id, obj, tag)
if err != nil {
return ConfigRule{}, err
}
var out ConfigRule
return out, json.Unmarshal(data, &out)
}
// rulesForSet loads every current-revision rule bound to the given set.
func (s *Store) rulesForSet(setID string) ([]ConfigRule, error) {
metas, err := s.List(KindRule)
if err != nil {
return nil, err
}
var out []ConfigRule
for _, m := range metas {
if m.SetID != setID {
continue
}
rule, err := s.GetRule(m.ID)
if err != nil {
continue
}
out = append(out, rule)
}
return out, nil
}
// applyRules runs every rule bound to inst's set against its values. On
// success it merges rule-derived values back into inst (parameter keys only) so
// the stored instance reflects the canonical, transformed configuration. A
// violation is returned as *RuleError so the caller can surface the structured
// details.
func (s *Store) applyRules(inst *ConfigInstance, set ConfigSet) error {
rules, err := s.rulesForSet(inst.SetID)
if err != nil {
return err
}
if len(rules) == 0 {
return nil
}
res := evaluateRules(rules, inst.Values)
if !res.OK {
return &RuleError{Result: res}
}
for k, v := range res.Transformed {
if _, ok := set.param(k); ok {
if inst.Values == nil {
inst.Values = map[string]any{}
}
inst.Values[k] = v
}
}
return nil
}
// ValidateInstanceRules evaluates the stored rules for an instance without
// persisting anything. It is the read-only counterpart used by the validate
// endpoint.
func (s *Store) ValidateInstanceRules(inst ConfigInstance) (RuleResult, error) {
rules, err := s.rulesForSet(inst.SetID)
if err != nil {
return RuleResult{}, err
}
if len(rules) == 0 {
return RuleResult{OK: true}, nil
}
return evaluateRules(rules, inst.Values), nil
}
// ── 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
}
+270
View File
@@ -0,0 +1,270 @@
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 TestRuleBlocksInvalidInstance(t *testing.T) {
s, _ := New(t.TempDir())
set, _ := s.CreateSet(sampleSet(), "")
if _, err := s.CreateRule(ConfigRule{
Name: "voltage cap",
SetID: set.ID,
Source: "voltage: <=24",
}, ""); err != nil {
t.Fatal(err)
}
// In-range value passes.
if _, err := s.CreateInstance(ConfigInstance{
Name: "ok", SetID: set.ID, Values: map[string]any{"voltage": 20.0},
}, ""); err != nil {
t.Fatalf("expected in-range instance to save, got %v", err)
}
// Out-of-range value is rejected by the rule.
_, err := s.CreateInstance(ConfigInstance{
Name: "bad", SetID: set.ID, Values: map[string]any{"voltage": 40.0},
}, "")
if err == nil {
t.Fatalf("expected rule violation to block save")
}
var re *RuleError
if !asRuleError(err, &re) {
t.Fatalf("expected *RuleError, got %T: %v", err, err)
}
}
func TestRuleTransformPersists(t *testing.T) {
s, _ := New(t.TempDir())
set := sampleSet()
max := 48.0
set.Parameters = append(set.Parameters, Parameter{Key: "vmax", DS: "epics", Signal: "PSU:VMAX", Type: TypeFloat, Min: &max})
created, _ := s.CreateSet(set, "")
// Rule derives vmax = voltage * 2 (within range for voltage=20 -> 40).
if _, err := s.CreateRule(ConfigRule{
Name: "vmax derive", SetID: created.ID, Source: "voltage: number\nvmax: voltage * 2",
}, ""); err != nil {
t.Fatal(err)
}
inst, err := s.CreateInstance(ConfigInstance{
Name: "x", SetID: created.ID, Values: map[string]any{"voltage": 20.0},
}, "")
if err != nil {
t.Fatal(err)
}
if f, _ := toFloat(inst.Values["vmax"]); f != 40 {
t.Fatalf("expected derived vmax=40 to persist, got %v", inst.Values["vmax"])
}
}
func asRuleError(err error, target **RuleError) bool {
for err != nil {
if re, ok := err.(*RuleError); ok {
*target = re
return true
}
type unwrapper interface{ Unwrap() error }
u, ok := err.(unwrapper)
if !ok {
return false
}
err = u.Unwrap()
}
return false
}
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")
}
}
+234
View File
@@ -0,0 +1,234 @@
package controllogic
import (
"context"
"io"
"log/slog"
"sync"
"testing"
"time"
"github.com/uopi/uopi/internal/audit"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/confmgr"
"github.com/uopi/uopi/internal/datasource"
)
// writableSource is a minimal in-memory DataSource that records the last value
// written to each signal, so config-apply/read nodes can be tested end-to-end.
type writableSource struct {
mu sync.Mutex
written map[string]any
}
func newWritableSource() *writableSource { return &writableSource{written: map[string]any{}} }
func (s *writableSource) Name() string { return "tgt" }
func (s *writableSource) Connect(context.Context) error { return nil }
func (s *writableSource) ListSignals(context.Context) ([]datasource.Metadata, error) {
return nil, nil
}
func (s *writableSource) GetMetadata(_ context.Context, sig string) (datasource.Metadata, error) {
return datasource.Metadata{Name: sig, Writable: true}, nil
}
// Subscribe delivers the signal's last-written value once (if any), so a
// one-shot ReadNow (used by config snapshot) resolves immediately.
func (s *writableSource) Subscribe(_ context.Context, sig string, ch chan<- datasource.Value) (datasource.CancelFunc, error) {
s.mu.Lock()
v, ok := s.written[sig]
s.mu.Unlock()
if ok {
select {
case ch <- datasource.Value{Data: v, Timestamp: time.Now()}:
default:
}
}
return func() {}, nil
}
func (s *writableSource) Write(_ context.Context, signal string, value any) error {
s.mu.Lock()
defer s.mu.Unlock()
s.written[signal] = value
return nil
}
func (s *writableSource) History(context.Context, string, time.Time, time.Time, int) ([]datasource.Value, error) {
return nil, datasource.ErrHistoryUnavailable
}
func (s *writableSource) get(signal string) (any, bool) {
s.mu.Lock()
defer s.mu.Unlock()
v, ok := s.written[signal]
return v, ok
}
// setupConfigEngine wires a broker (with a writable "tgt" source), a confmgr
// store seeded with a set + instance, and an Engine bound to both.
func setupConfigEngine(t *testing.T) (*Engine, *writableSource, string) {
t.Helper()
log := slog.New(slog.NewTextHandler(io.Discard, nil))
ctx := context.Background()
src := newWritableSource()
brk := broker.New(ctx, log)
brk.Register(src)
cfg, err := confmgr.New(t.TempDir())
if err != nil {
t.Fatal("confmgr.New:", err)
}
set, err := cfg.CreateSet(confmgr.ConfigSet{
Name: "tuning",
Parameters: []confmgr.Parameter{
{Key: "gain", DS: "tgt", Signal: "GAIN", Type: confmgr.TypeFloat, Default: 1.0},
{Key: "offset", DS: "tgt", Signal: "OFFSET", Type: confmgr.TypeFloat, Default: 0.0},
},
}, "")
if err != nil {
t.Fatal("CreateSet:", err)
}
inst, err := cfg.CreateInstance(confmgr.ConfigInstance{
Name: "warm",
SetID: set.ID,
Values: map[string]any{"gain": 2.5, "offset": 10.0},
}, "")
if err != nil {
t.Fatal("CreateInstance:", err)
}
e := NewEngine(ctx, brk, nil, cfg, audit.Nop(), log)
return e, src, inst.ID
}
func TestApplyConfigWritesEveryParam(t *testing.T) {
e, src, instID := setupConfigEngine(t)
cg := &compiledGraph{name: "flow", engine: e, locals: map[string]float64{}}
e.applyConfig(cg, instID)
if got, ok := src.get("GAIN"); !ok || toNum(got) != 2.5 {
t.Errorf("GAIN = %v (ok=%v), want 2.5", got, ok)
}
if got, ok := src.get("OFFSET"); !ok || toNum(got) != 10.0 {
t.Errorf("OFFSET = %v (ok=%v), want 10.0", got, ok)
}
}
func TestApplyConfigUnknownInstanceNoop(t *testing.T) {
e, src, _ := setupConfigEngine(t)
cg := &compiledGraph{name: "flow", engine: e, locals: map[string]float64{}}
e.applyConfig(cg, "does-not-exist")
if len(src.written) != 0 {
t.Errorf("expected no writes, got %v", src.written)
}
}
func TestReadConfigParam(t *testing.T) {
e, _, instID := setupConfigEngine(t)
if v, ok := e.readConfigParam(instID, "gain"); !ok || v != 2.5 {
t.Errorf("readConfigParam(gain) = %v (ok=%v), want 2.5", v, ok)
}
// Missing param key.
if _, ok := e.readConfigParam(instID, "nope"); ok {
t.Errorf("readConfigParam(nope) returned ok=true, want false")
}
// Missing instance.
if _, ok := e.readConfigParam("does-not-exist", "gain"); ok {
t.Errorf("readConfigParam(missing instance) returned ok=true, want false")
}
}
func TestWriteConfigParam(t *testing.T) {
e, _, instID := setupConfigEngine(t)
cg := &compiledGraph{name: "flow", engine: e, locals: map[string]float64{}}
e.writeConfigParam(cg, instID, "gain", 7.5)
// A new revision should now resolve to the written value.
if v, ok := e.readConfigParam(instID, "gain"); !ok || v != 7.5 {
t.Errorf("after write, gain = %v (ok=%v), want 7.5", v, ok)
}
inst, err := e.cfg.GetInstance(instID)
if err != nil {
t.Fatal("GetInstance:", err)
}
if inst.Version < 2 {
t.Errorf("instance version = %d, want >= 2 (a new revision)", inst.Version)
}
}
func TestCreateConfigInstance(t *testing.T) {
e, _, srcID := setupConfigEngine(t)
cg := &compiledGraph{name: "flow", engine: e, locals: map[string]float64{}}
src, err := e.cfg.GetInstance(srcID)
if err != nil {
t.Fatal("GetInstance:", err)
}
e.createConfigInstance(cg, src.SetID, "cold", srcID)
insts, err := e.cfg.List(confmgr.KindInstance)
if err != nil {
t.Fatal("List:", err)
}
var found *confmgr.ConfigInstance
for i := range insts {
if insts[i].Name == "cold" {
full, err := e.cfg.GetInstance(insts[i].ID)
if err != nil {
t.Fatal("GetInstance:", err)
}
found = &full
}
}
if found == nil {
t.Fatal("created instance 'cold' not found")
}
// Values copied from the source instance.
if got := toNum(found.Values["gain"]); got != 2.5 {
t.Errorf("copied gain = %v, want 2.5", got)
}
}
func TestSnapshotConfig(t *testing.T) {
e, src, instID := setupConfigEngine(t)
cg := &compiledGraph{name: "flow", engine: e, locals: map[string]float64{}}
// Seed current live values for the set's target signals.
_ = src.Write(context.Background(), "GAIN", 3.5)
_ = src.Write(context.Background(), "OFFSET", -2.0)
seed, err := e.cfg.GetInstance(instID)
if err != nil {
t.Fatal("GetInstance:", err)
}
e.snapshotConfig(cg, seed.SetID, "snap1")
insts, err := e.cfg.List(confmgr.KindInstance)
if err != nil {
t.Fatal("List:", err)
}
var found *confmgr.ConfigInstance
for i := range insts {
if insts[i].Name == "snap1" {
full, err := e.cfg.GetInstance(insts[i].ID)
if err != nil {
t.Fatal("GetInstance:", err)
}
found = &full
}
}
if found == nil {
t.Fatal("snapshot instance 'snap1' not found")
}
if got := toNum(found.Values["gain"]); got != 3.5 {
t.Errorf("snapshot gain = %v, want 3.5 (current live value)", got)
}
if got := toNum(found.Values["offset"]); got != -2.0 {
t.Errorf("snapshot offset = %v, want -2.0 (current live value)", got)
}
}
+343 -2
View File
@@ -2,17 +2,38 @@ package controllogic
import ( import (
"context" "context"
"errors"
"fmt"
"log/slog" "log/slog"
"math" "math"
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"time" "time"
"github.com/uopi/uopi/internal/audit"
"github.com/uopi/uopi/internal/broker" "github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/confmgr"
) )
// errUnknownSource is returned by config-apply writes targeting a data source
// that isn't registered with the broker.
var errUnknownSource = errors.New("unknown data source")
// formatAny renders a config value for the audit log.
func formatAny(v any) string {
switch x := v.(type) {
case float64:
return strconv.FormatFloat(x, 'g', -1, 64)
case string:
return x
default:
return fmt.Sprintf("%v", v)
}
}
// Guards against runaway flows (cycles / pathological loops). // Guards against runaway flows (cycles / pathological loops).
const ( const (
maxSteps = 100000 maxSteps = 100000
@@ -25,6 +46,8 @@ const (
type Engine struct { type Engine struct {
broker *broker.Broker broker *broker.Broker
store *Store store *Store
cfg *confmgr.Store
audit audit.Recorder
log *slog.Logger log *slog.Logger
root context.Context root context.Context
@@ -32,16 +55,39 @@ type Engine struct {
cancel context.CancelFunc // cancels the current generation cancel context.CancelFunc // cancels the current generation
wg *sync.WaitGroup // tracks the current generation's goroutines wg *sync.WaitGroup // tracks the current generation's goroutines
// notifier delivers action.dialog requests to connected clients. Stored in
// an atomic so flow goroutines can read it without taking e.mu (which Reload
// holds while it waits for those same goroutines to drain).
notifier atomic.Value // notifierBox
// Shared live signal cache for the current generation (key "ds\0name"). // Shared live signal cache for the current generation (key "ds\0name").
liveMu sync.RWMutex liveMu sync.RWMutex
live map[string]float64 live map[string]float64
} }
// NewEngine creates an engine bound to root. Call Reload to start it. // notifierBox wraps a Notifier so atomic.Value always sees one concrete type.
func NewEngine(root context.Context, brk *broker.Broker, store *Store, log *slog.Logger) *Engine { type notifierBox struct{ n Notifier }
// dialogSeq generates unique action.dialog ids across all graphs.
var dialogSeq uint64
// SetNotifier installs the sink for action.dialog requests. Safe to call once
// at startup before or after Reload; it is read lock-free by running flows.
func (e *Engine) SetNotifier(n Notifier) {
e.notifier.Store(notifierBox{n: n})
}
// NewEngine creates an engine bound to root. Call Reload to start it. rec records
// the writes performed by flows; pass audit.Nop() to disable auditing.
func NewEngine(root context.Context, brk *broker.Broker, store *Store, cfg *confmgr.Store, rec audit.Recorder, log *slog.Logger) *Engine {
if rec == nil {
rec = audit.Nop()
}
return &Engine{ return &Engine{
broker: brk, broker: brk,
store: store, store: store,
cfg: cfg,
audit: rec,
log: log, log: log,
root: root, root: root,
live: map[string]float64{}, live: map[string]float64{},
@@ -227,9 +273,276 @@ func (e *Engine) write(cg *compiledGraph, target string, val float64) {
e.log.Warn("control logic: write to unknown data source", "ds", ds, "signal", name) e.log.Warn("control logic: write to unknown data source", "ds", ds, "signal", name)
return return
} }
ev := audit.Event{
Actor: cg.name,
ActorType: audit.ActorSystem,
Action: "signal.write",
DS: ds,
Signal: name,
Value: strconv.FormatFloat(val, 'g', -1, 64),
Detail: "control logic: " + cg.name,
Outcome: audit.OutcomeOK,
}
if err := src.Write(e.root, name, val); err != nil { if err := src.Write(e.root, name, val); err != nil {
e.log.Warn("control logic: write failed", "ds", ds, "signal", name, "err", err) e.log.Warn("control logic: write failed", "ds", ds, "signal", name, "err", err)
ev.Outcome = audit.OutcomeError
ev.Error = err.Error()
} }
e.audit.Record(ev)
}
// applyConfig resolves a config instance + its set and writes every parameter
// to its target signal via the owning data source (confmgr.Apply). Each write is
// audited. A bare instance id or a missing config store is a no-op (logged).
func (e *Engine) applyConfig(cg *compiledGraph, instanceID string) {
if e.cfg == nil || instanceID == "" {
return
}
inst, err := e.cfg.GetInstance(instanceID)
if err != nil {
e.log.Warn("control logic: config apply: unknown instance", "instance", instanceID, "err", err)
return
}
set, err := e.cfg.SetForInstance(inst)
if err != nil {
e.log.Warn("control logic: config apply: set lookup failed", "instance", instanceID, "err", err)
return
}
write := func(ds, signal string, value any) error {
ev := audit.Event{
Actor: cg.name,
ActorType: audit.ActorSystem,
Action: "signal.write",
DS: ds,
Signal: signal,
Value: formatAny(value),
Detail: "control logic config apply: " + inst.Name,
Outcome: audit.OutcomeOK,
}
var werr error
if ds == "local" {
cg.setLocal(signal, toNum(value))
} else if src, ok := e.broker.Source(ds); ok {
werr = src.Write(e.root, signal, value)
} else {
werr = errUnknownSource
}
if werr != nil {
ev.Outcome = audit.OutcomeError
ev.Error = werr.Error()
}
e.audit.Record(ev)
return werr
}
confmgr.Apply(set, inst, write)
}
// readConfigParam resolves a single parameter's value from a config instance,
// coercing it to float64 for use as a control-logic value. Returns ok=false if
// the store/instance/param is missing or the value isn't numeric.
func (e *Engine) readConfigParam(instanceID, key string) (float64, bool) {
if e.cfg == nil || instanceID == "" || key == "" {
return 0, false
}
inst, err := e.cfg.GetInstance(instanceID)
if err != nil {
e.log.Warn("control logic: config read: unknown instance", "instance", instanceID, "err", err)
return 0, false
}
set, err := e.cfg.SetForInstance(inst)
if err != nil {
e.log.Warn("control logic: config read: set lookup failed", "instance", instanceID, "err", err)
return 0, false
}
for _, p := range set.Parameters {
if p.Key != key {
continue
}
v, ok := inst.Resolve(p)
if !ok {
return 0, false
}
f := toNum(v)
if math.IsNaN(f) {
return 0, false
}
return f, true
}
return 0, false
}
// writeConfigParam sets a single parameter's value on a config instance and
// saves a new revision (git-style). The value is coerced to the parameter's
// declared type (numeric/bool/string) so it validates against the set. A
// missing store/instance/param is a no-op (logged). Audited.
func (e *Engine) writeConfigParam(cg *compiledGraph, instanceID, key string, val float64) {
if e.cfg == nil || instanceID == "" || key == "" {
return
}
inst, err := e.cfg.GetInstance(instanceID)
if err != nil {
e.log.Warn("control logic: config write: unknown instance", "instance", instanceID, "err", err)
return
}
set, err := e.cfg.SetForInstance(inst)
if err != nil {
e.log.Warn("control logic: config write: set lookup failed", "instance", instanceID, "err", err)
return
}
var param *confmgr.Parameter
for i := range set.Parameters {
if set.Parameters[i].Key == key {
param = &set.Parameters[i]
break
}
}
if param == nil {
e.log.Warn("control logic: config write: unknown parameter", "instance", instanceID, "key", key)
return
}
if inst.Values == nil {
inst.Values = map[string]any{}
}
inst.Values[key] = coerceParamValue(*param, val)
ev := audit.Event{
Actor: cg.name,
ActorType: audit.ActorSystem,
Action: "config.instance.update",
Detail: "control logic config write: " + inst.Name + " " + key + "=" + formatAny(inst.Values[key]),
Outcome: audit.OutcomeOK,
}
if _, err := e.cfg.UpdateInstance(instanceID, inst, ""); err != nil {
e.log.Warn("control logic: config write failed", "instance", instanceID, "key", key, "err", err)
ev.Outcome = audit.OutcomeError
ev.Error = err.Error()
}
e.audit.Record(ev)
}
// createConfigInstance creates a new config instance for setID, optionally
// copying values from an existing instance (fromID). A missing store/set is a
// no-op (logged). Audited. The new instance's id is logged but not written back
// to a flow variable (control-logic variables are numeric, ids are strings).
func (e *Engine) createConfigInstance(cg *compiledGraph, setID, name, fromID string) {
if e.cfg == nil || setID == "" {
return
}
if name == "" {
name = "auto"
}
values := map[string]any{}
if fromID != "" {
if src, err := e.cfg.GetInstance(fromID); err == nil {
for k, v := range src.Values {
values[k] = v
}
} else {
e.log.Warn("control logic: config create: copy source missing", "from", fromID, "err", err)
}
}
inst := confmgr.ConfigInstance{Name: name, SetID: setID, Values: values}
ev := audit.Event{
Actor: cg.name,
ActorType: audit.ActorSystem,
Action: "config.instance.create",
Detail: "control logic config create: set=" + setID + " name=" + name,
Outcome: audit.OutcomeOK,
}
out, err := e.cfg.CreateInstance(inst, "")
if err != nil {
e.log.Warn("control logic: config create failed", "set", setID, "err", err)
ev.Outcome = audit.OutcomeError
ev.Error = err.Error()
} else {
ev.Detail += " -> " + out.ID
}
e.audit.Record(ev)
}
// snapshotConfig captures the current value of every target signal of a set and
// stores them as a new config instance. A missing store/set is a no-op (logged).
// Audited. The new instance's id is logged but not written back to a flow
// variable (control-logic variables are numeric, ids are strings).
func (e *Engine) snapshotConfig(cg *compiledGraph, setID, name string) {
if e.cfg == nil || setID == "" {
return
}
set, err := e.cfg.GetSet(setID)
if err != nil {
e.log.Warn("control logic: config snapshot: unknown set", "set", setID, "err", err)
return
}
ctx, cancel := context.WithTimeout(e.root, 5*time.Second)
defer cancel()
read := func(ds, signal string) (any, error) {
if ds == "local" {
return cg.getLocal(signal), nil
}
v, err := e.broker.ReadNow(ctx, broker.SignalRef{DS: ds, Name: signal})
if err != nil {
return nil, err
}
return v.Data, nil
}
snap := confmgr.Snapshot(set, read)
if name == "" {
name = set.Name + " snapshot"
}
inst := confmgr.ConfigInstance{Name: name, SetID: setID, Values: snap.Values}
ev := audit.Event{
Actor: cg.name,
ActorType: audit.ActorSystem,
Action: "config.instance.snapshot",
Detail: "control logic config snapshot: set=" + setID + " name=" + name,
Outcome: audit.OutcomeOK,
}
out, err := e.cfg.CreateInstance(inst, "")
if err != nil {
e.log.Warn("control logic: config snapshot failed", "set", setID, "err", err)
ev.Outcome = audit.OutcomeError
ev.Error = err.Error()
} else {
ev.Detail += " -> " + out.ID + " captured=" + strconv.Itoa(snap.Captured) + " failed=" + strconv.Itoa(snap.Failed)
}
e.audit.Record(ev)
}
// coerceParamValue converts a numeric flow value to the Go type a config
// parameter expects, so the resulting instance validates against its set.
func coerceParamValue(p confmgr.Parameter, val float64) any {
switch p.Type {
case confmgr.TypeInt:
return int64(val)
case confmgr.TypeBool:
return val != 0
case confmgr.TypeString, confmgr.TypeEnum:
return strconv.FormatFloat(val, 'g', -1, 64)
default:
return val
}
}
// emitDialog delivers an action.dialog node's request to the installed Notifier
// (the WebSocket dialog hub). It is lock-free so it never deadlocks against a
// concurrent Reload that is waiting for this flow goroutine to finish.
func (e *Engine) emitDialog(n Node) {
box, _ := e.notifier.Load().(notifierBox)
if box.n == nil {
return
}
kind := strings.TrimSpace(n.param("kind"))
if kind != "error" && kind != "input" {
kind = "info"
}
box.n.Notify(Dialog{
ID: strconv.FormatUint(atomic.AddUint64(&dialogSeq, 1), 10),
Kind: kind,
Title: n.param("title"),
Message: n.param("message"),
Target: strings.TrimSpace(n.param("target")),
Users: splitCSV(n.param("users")),
Groups: splitCSV(n.param("groups")),
})
} }
// ── compiled graph ───────────────────────────────────────────────────────────── // ── compiled graph ─────────────────────────────────────────────────────────────
@@ -612,6 +925,30 @@ func (cg *compiledGraph) run(nodeID string, ctx *runCtx) {
cg.engine.write(cg, node.param("target"), val) cg.engine.write(cg, node.param("target"), val)
cg.follow(node.ID, "out", ctx) cg.follow(node.ID, "out", ctx)
case "action.config.apply":
cg.engine.applyConfig(cg, strings.TrimSpace(node.param("instance")))
cg.follow(node.ID, "out", ctx)
case "action.config.read":
v, ok := cg.engine.readConfigParam(strings.TrimSpace(node.param("instance")), strings.TrimSpace(node.param("key")))
if ok {
cg.engine.write(cg, node.param("target"), v)
}
cg.follow(node.ID, "out", ctx)
case "action.config.write":
val := EvalExpr(node.param("expr"), ctx.resolve)
cg.engine.writeConfigParam(cg, strings.TrimSpace(node.param("instance")), strings.TrimSpace(node.param("key")), val)
cg.follow(node.ID, "out", ctx)
case "action.config.create":
cg.engine.createConfigInstance(cg, strings.TrimSpace(node.param("set")), strings.TrimSpace(node.param("name")), strings.TrimSpace(node.param("from")))
cg.follow(node.ID, "out", ctx)
case "action.config.snapshot":
cg.engine.snapshotConfig(cg, strings.TrimSpace(node.param("set")), strings.TrimSpace(node.param("name")))
cg.follow(node.ID, "out", ctx)
case "action.delay": case "action.delay":
ms := 0 ms := 0
if v, err := strconv.Atoi(strings.TrimSpace(node.param("ms"))); err == nil && v > 0 { if v, err := strconv.Atoi(strings.TrimSpace(node.param("ms"))); err == nil && v > 0 {
@@ -638,6 +975,10 @@ func (cg *compiledGraph) run(nodeID string, ctx *runCtx) {
cg.runLua(node.ID, ctx) cg.runLua(node.ID, ctx)
cg.follow(node.ID, "out", ctx) cg.follow(node.ID, "out", ctx)
case "action.dialog":
cg.engine.emitDialog(node)
cg.follow(node.ID, "out", ctx)
default: default:
cg.follow(node.ID, "out", ctx) cg.follow(node.ID, "out", ctx)
} }
+4
View File
@@ -29,6 +29,8 @@ package controllogic
// action.delay — waits `ms` before continuing. // action.delay — waits `ms` before continuing.
// action.log — logs an expression value to the server log. // action.log — logs an expression value to the server log.
// action.lua — runs a sandboxed Lua script with get/set/log host funcs. // action.lua — runs a sandboxed Lua script with get/set/log host funcs.
// action.dialog — pushes an info/error/input dialog to connected clients
// filtered by user/group; input responses write `target`.
// Node is a single node in a control-logic graph. Params are stored as strings // Node is a single node in a control-logic graph. Params are stored as strings
// (matching the panel logic model) and parsed per-kind by the engine. // (matching the panel logic model) and parsed per-kind by the engine.
@@ -53,6 +55,8 @@ type Graph struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
Version int `json:"version,omitempty"` // git-style revision; bumped on each Save
Tag string `json:"tag,omitempty"` // optional revision label (e.g. "restored from v3")
Nodes []Node `json:"nodes"` Nodes []Node `json:"nodes"`
Wires []Wire `json:"wires"` Wires []Wire `json:"wires"`
} }
+37
View File
@@ -0,0 +1,37 @@
package controllogic
import "strings"
// Dialog is a user-facing notification or input request emitted by an
// action.dialog node. It is delivered to connected clients whose identity
// matches Users/Groups (both empty = everyone). For an "input" dialog the
// client's response is written back to Target (a "ds:name" reference, e.g.
// "srv:approved") so control logic can read it on a later activation.
type Dialog struct {
ID string `json:"id"`
Kind string `json:"kind"` // "info" | "error" | "input"
Title string `json:"title"`
Message string `json:"message"`
Target string `json:"target,omitempty"`
Users []string `json:"users,omitempty"`
Groups []string `json:"groups,omitempty"`
}
// Notifier delivers control-logic dialogs to connected clients. The server
// implements it; the engine calls Notify when an action.dialog node runs.
// Notify must not block (the hub fans out without waiting on slow clients).
type Notifier interface {
Notify(Dialog)
}
// splitCSV parses a comma-separated user/group filter into trimmed,
// non-empty tokens. An empty string yields a nil slice (no filter).
func splitCSV(s string) []string {
var out []string
for _, p := range strings.Split(s, ",") {
if t := strings.TrimSpace(p); t != "" {
out = append(out, t)
}
}
return out
}
+63 -3
View File
@@ -7,6 +7,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"sync" "sync"
"time"
) )
const definitionsFile = "controllogic.json" const definitionsFile = "controllogic.json"
@@ -16,9 +17,15 @@ var ErrNotFound = errors.New("control logic graph not found")
// Store persists control-logic graphs as a single JSON file in the storage dir. // Store persists control-logic graphs as a single JSON file in the storage dir.
// Writes are atomic (tmp file + rename); all access is mutex-guarded. // Writes are atomic (tmp file + rename); all access is mutex-guarded.
//
// Git-style versioning: the live graph lives in controllogic.json (the current
// revision), while every superseded revision is preserved as a backup file
// {id}.vN.json under versionsDir. Promote/Fork build on these backups.
type Store struct { type Store struct {
mu sync.RWMutex mu sync.RWMutex
path string path string
trashDir string
versionsDir string
items map[string]Graph items map[string]Graph
} }
@@ -26,6 +33,8 @@ type Store struct {
func NewStore(storageDir string) (*Store, error) { func NewStore(storageDir string) (*Store, error) {
s := &Store{ s := &Store{
path: filepath.Join(storageDir, definitionsFile), path: filepath.Join(storageDir, definitionsFile),
trashDir: filepath.Join(storageDir, "trash", "controllogic"),
versionsDir: filepath.Join(storageDir, "controllogic_versions"),
items: map[string]Graph{}, items: map[string]Graph{},
} }
if err := s.load(); err != nil { if err := s.load(); err != nil {
@@ -91,21 +100,72 @@ func (s *Store) Get(id string) (Graph, error) {
return g, nil return g, nil
} }
// Save inserts or replaces a graph and persists the store. // Save inserts or replaces a graph and persists the store. When replacing an
// existing graph the superseded revision is backed up as {id}.vN.json and the
// new graph's Version is bumped; a brand-new graph starts at version 1.
func (s *Store) Save(g Graph) error { func (s *Store) Save(g Graph) error {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
if old, ok := s.items[g.ID]; ok {
oldV := old.Version
if oldV < 1 {
oldV = 1
old.Version = 1
}
if err := s.backupLocked(old); err != nil {
return fmt.Errorf("back up control logic revision: %w", err)
}
g.Version = oldV + 1
} else if g.Version < 1 {
g.Version = 1
}
s.items[g.ID] = g s.items[g.ID] = g
return s.saveLocked() return s.saveLocked()
} }
// Delete removes a graph by id. // backupLocked writes a single graph revision to versionsDir as {id}.vN.json.
// Caller must hold s.mu.
func (s *Store) backupLocked(g Graph) error {
if err := os.MkdirAll(s.versionsDir, 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(g, "", " ")
if err != nil {
return err
}
return os.WriteFile(s.versionPath(g.ID, g.Version), data, 0o644)
}
func (s *Store) versionPath(id string, version int) string {
return filepath.Join(s.versionsDir, fmt.Sprintf("%s.v%d.json", id, version))
}
// Delete removes a graph by id, first writing a copy into the trash folder so it
// can be recovered if needed. The trash backup is best-effort: a failure to write
// it does not prevent the delete (but is reported).
func (s *Store) Delete(id string) error { func (s *Store) Delete(id string) error {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
if _, ok := s.items[id]; !ok { g, ok := s.items[id]
if !ok {
return ErrNotFound return ErrNotFound
} }
if err := s.trash(g); err != nil {
return fmt.Errorf("move control logic to trash: %w", err)
}
delete(s.items, id) delete(s.items, id)
return s.saveLocked() return s.saveLocked()
} }
// trash writes a single graph as a timestamped JSON file under the trash folder.
func (s *Store) trash(g Graph) error {
if err := os.MkdirAll(s.trashDir, 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(g, "", " ")
if err != nil {
return err
}
dst := filepath.Join(s.trashDir, fmt.Sprintf("%s.%d.json", g.ID, time.Now().UnixMilli()))
return os.WriteFile(dst, data, 0o644)
}
+142
View File
@@ -0,0 +1,142 @@
package controllogic
import (
"encoding/json"
"fmt"
"os"
"sort"
"strconv"
"strings"
"time"
)
// VersionMeta describes a single persisted revision of a control-logic graph,
// mirroring storage.VersionMeta so the frontend can treat all versioned
// document types uniformly.
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"`
}
// Versions returns metadata for every persisted revision of the graph, newest
// first. The live revision (held in controllogic.json) is flagged Current.
func (s *Store) Versions(id string) ([]VersionMeta, error) {
s.mu.RLock()
defer s.mu.RUnlock()
cur, ok := s.items[id]
if !ok {
return nil, ErrNotFound
}
curV := cur.Version
if curV < 1 {
curV = 1
}
var savedAt time.Time
if info, err := os.Stat(s.path); err == nil {
savedAt = info.ModTime()
}
out := []VersionMeta{{Version: curV, Name: cur.Name, Tag: cur.Tag, Current: true, SavedAt: savedAt}}
entries, err := os.ReadDir(s.versionsDir)
if err != nil && !os.IsNotExist(err) {
return nil, err
}
prefix := id + ".v"
for _, e := range entries {
name := e.Name()
if e.IsDir() || !strings.HasPrefix(name, prefix) || !strings.HasSuffix(name, ".json") {
continue
}
vStr := strings.TrimSuffix(strings.TrimPrefix(name, prefix), ".json")
v, err := strconv.Atoi(vStr)
if err != nil {
continue
}
g, err := s.readVersion(id, v)
if err != nil {
continue
}
info, err := e.Info()
if err != nil {
continue
}
out = append(out, VersionMeta{Version: v, Name: g.Name, Tag: g.Tag, SavedAt: info.ModTime()})
}
sort.Slice(out, func(i, j int) bool { return out[i].Version > out[j].Version })
return out, nil
}
// GetVersion returns a specific revision of the graph. The current revision is
// served from the live store; older revisions come from their backup file.
func (s *Store) GetVersion(id string, version int) (Graph, error) {
s.mu.RLock()
defer s.mu.RUnlock()
cur, ok := s.items[id]
if !ok {
return Graph{}, ErrNotFound
}
curV := cur.Version
if curV < 1 {
curV = 1
}
if version == curV {
return cur, nil
}
return s.readVersion(id, version)
}
// readVersion loads a backup revision file. Caller must hold s.mu.
func (s *Store) readVersion(id string, version int) (Graph, error) {
data, err := os.ReadFile(s.versionPath(id, version))
if os.IsNotExist(err) {
return Graph{}, ErrNotFound
}
if err != nil {
return Graph{}, err
}
var g Graph
if err := json.Unmarshal(data, &g); err != nil {
return Graph{}, fmt.Errorf("parse revision: %w", err)
}
return g, nil
}
// Promote makes a past revision current by re-saving it on top of history. The
// existing current revision is preserved as a backup, so promotion is
// non-destructive. Returns the resulting (new current) graph.
func (s *Store) Promote(id string, version int) (Graph, error) {
g, err := s.GetVersion(id, version)
if err != nil {
return Graph{}, err
}
g.Tag = fmt.Sprintf("restored from v%d", version)
if err := s.Save(g); err != nil {
return Graph{}, err
}
return s.Get(id)
}
// Fork creates a brand-new graph from a specific revision, assigning a fresh id
// and resetting its version to 1. Returns the new graph.
func (s *Store) Fork(id string, version int) (Graph, error) {
g, err := s.GetVersion(id, version)
if err != nil {
return Graph{}, err
}
g.ID = fmt.Sprintf("%s-fork-%d", id, time.Now().UnixMilli())
g.Version = 1
g.Tag = ""
if g.Name != "" {
g.Name = g.Name + " (fork)"
}
if err := s.Save(g); err != nil {
return Graph{}, err
}
return g, nil
}
+70
View File
@@ -0,0 +1,70 @@
package controllogic
import "testing"
func TestControlLogicVersioning(t *testing.T) {
dir := t.TempDir()
s, err := NewStore(dir)
if err != nil {
t.Fatal(err)
}
g := Graph{ID: "cl-1", Name: "loop", Enabled: true,
Nodes: []Node{{ID: "n1", Kind: "trigger.timer", Params: map[string]string{"interval": "1000"}}}}
if err := s.Save(g); err != nil {
t.Fatalf("Save v1: %v", err)
}
if got, _ := s.Get("cl-1"); got.Version != 1 {
t.Fatalf("after create: version=%d", got.Version)
}
// Two edits → v2, v3.
g.Name = "loop-2"
if err := s.Save(g); err != nil {
t.Fatalf("Save v2: %v", err)
}
g.Name = "loop-3"
if err := s.Save(g); err != nil {
t.Fatalf("Save v3: %v", err)
}
if got, _ := s.Get("cl-1"); got.Version != 3 || got.Name != "loop-3" {
t.Fatalf("current: version=%d name=%q", got.Version, got.Name)
}
versions, err := s.Versions("cl-1")
if err != nil {
t.Fatalf("Versions: %v", err)
}
if len(versions) != 3 {
t.Fatalf("want 3 versions, got %d", len(versions))
}
if !versions[0].Current || versions[0].Version != 3 {
t.Errorf("newest should be current v3: %+v", versions[0])
}
v1, err := s.GetVersion("cl-1", 1)
if err != nil || v1.Name != "loop" {
t.Fatalf("GetVersion v1: name=%q err=%v", v1.Name, err)
}
// Promote v1 → v4.
promoted, err := s.Promote("cl-1", 1)
if err != nil {
t.Fatalf("Promote: %v", err)
}
if promoted.Version != 4 || promoted.Name != "loop" {
t.Errorf("promote: version=%d name=%q", promoted.Version, promoted.Name)
}
// Fork v3 → new id, version 1.
forked, err := s.Fork("cl-1", 3)
if err != nil {
t.Fatalf("Fork: %v", err)
}
if forked.Version != 1 || forked.ID == "cl-1" {
t.Errorf("fork: id=%q version=%d", forked.ID, forked.Version)
}
if got, err := s.Get(forked.ID); err != nil || got.Name != forked.Name {
t.Errorf("forked graph not stored: %v", err)
}
}
+2
View File
@@ -112,6 +112,8 @@ func fetchArchiveHistory(ctx context.Context, archiveURL, signal string, start,
Timestamp: ts, Timestamp: ts,
Data: data, Data: data,
Quality: q, Quality: q,
Severity: p.Severity,
Status: p.Status,
}) })
} }
+9 -2
View File
@@ -22,7 +22,7 @@ import (
// //
//export goCAMonitorCallback //export goCAMonitorCallback
func goCAMonitorCallback(handle C.uintptr_t, dbrType C.int, count C.long, func goCAMonitorCallback(handle C.uintptr_t, dbrType C.int, count C.long,
dbr unsafe.Pointer, severity C.int, epicsTimeSecs C.double) { dbr unsafe.Pointer, severity C.int, status C.int, epicsTimeSecs C.double) {
h := uintptr(handle) h := uintptr(handle)
@@ -79,7 +79,13 @@ func goCAMonitorCallback(handle C.uintptr_t, dbrType C.int, count C.long,
data = float64(0) data = float64(0)
} }
v := datasource.Value{Timestamp: ts, Data: data, Quality: q} v := datasource.Value{
Timestamp: ts,
Data: data,
Quality: q,
Severity: int(severity),
Status: int(status),
}
// Look up the subscription channel under the global handle table lock and // Look up the subscription channel under the global handle table lock and
// perform a non-blocking send so we never stall the CA callback thread. // perform a non-blocking send so we never stall the CA callback thread.
@@ -129,6 +135,7 @@ func goCAConnectionCallback(handle C.uintptr_t, connected C.int) {
Timestamp: time.Now(), Timestamp: time.Now(),
Data: float64(0), Data: float64(0),
Quality: datasource.QualityBad, Quality: datasource.QualityBad,
Severity: 3, // INVALID
} }
select { select {
case ch <- v: case ch <- v:
+10 -3
View File
@@ -13,7 +13,7 @@
* goCAConnectionCallback is called when a channel connects or disconnects. * goCAConnectionCallback is called when a channel connects or disconnects.
*/ */
extern void goCAMonitorCallback(uintptr_t handle, int dbrType, long count, extern void goCAMonitorCallback(uintptr_t handle, int dbrType, long count,
void *dbr, int severity, void *dbr, int severity, int status,
double epicsTimeSecs); double epicsTimeSecs);
extern void goCAConnectionCallback(uintptr_t handle, int connected); extern void goCAConnectionCallback(uintptr_t handle, int connected);
@@ -30,9 +30,10 @@ static void caMonitorCallbackShim(struct event_handler_args args) {
double timeSecs = 0.0; double timeSecs = 0.0;
int severity = 0; int severity = 0;
int status = 0;
/* /*
* Determine the timestamp and alarm severity from the DBR type. * Determine the timestamp and alarm severity/status from the DBR type.
* We request DBR_TIME_* types so the timestamp is embedded in the value * We request DBR_TIME_* types so the timestamp is embedded in the value
* buffer right after the alarm fields (struct dbr_time_double et al.). * buffer right after the alarm fields (struct dbr_time_double et al.).
*/ */
@@ -41,36 +42,42 @@ static void caMonitorCallbackShim(struct event_handler_args args) {
const struct dbr_time_double *p = (const struct dbr_time_double *)args.dbr; const struct dbr_time_double *p = (const struct dbr_time_double *)args.dbr;
timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9; timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9;
severity = (int)p->severity; severity = (int)p->severity;
status = (int)p->status;
break; break;
} }
case DBR_TIME_FLOAT: { case DBR_TIME_FLOAT: {
const struct dbr_time_float *p = (const struct dbr_time_float *)args.dbr; const struct dbr_time_float *p = (const struct dbr_time_float *)args.dbr;
timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9; timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9;
severity = (int)p->severity; severity = (int)p->severity;
status = (int)p->status;
break; break;
} }
case DBR_TIME_LONG: { case DBR_TIME_LONG: {
const struct dbr_time_long *p = (const struct dbr_time_long *)args.dbr; const struct dbr_time_long *p = (const struct dbr_time_long *)args.dbr;
timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9; timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9;
severity = (int)p->severity; severity = (int)p->severity;
status = (int)p->status;
break; break;
} }
case DBR_TIME_SHORT: { case DBR_TIME_SHORT: {
const struct dbr_time_short *p = (const struct dbr_time_short *)args.dbr; const struct dbr_time_short *p = (const struct dbr_time_short *)args.dbr;
timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9; timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9;
severity = (int)p->severity; severity = (int)p->severity;
status = (int)p->status;
break; break;
} }
case DBR_TIME_STRING: { case DBR_TIME_STRING: {
const struct dbr_time_string *p = (const struct dbr_time_string *)args.dbr; const struct dbr_time_string *p = (const struct dbr_time_string *)args.dbr;
timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9; timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9;
severity = (int)p->severity; severity = (int)p->severity;
status = (int)p->status;
break; break;
} }
case DBR_TIME_ENUM: { case DBR_TIME_ENUM: {
const struct dbr_time_enum *p = (const struct dbr_time_enum *)args.dbr; const struct dbr_time_enum *p = (const struct dbr_time_enum *)args.dbr;
timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9; timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9;
severity = (int)p->severity; severity = (int)p->severity;
status = (int)p->status;
break; break;
} }
default: default:
@@ -78,7 +85,7 @@ static void caMonitorCallbackShim(struct event_handler_args args) {
} }
goCAMonitorCallback((uintptr_t)args.usr, (int)args.type, (long)args.count, goCAMonitorCallback((uintptr_t)args.usr, (int)args.type, (long)args.count,
(void *)args.dbr, severity, timeSecs); (void *)args.dbr, severity, status, timeSecs);
} }
/* /*
+2
View File
@@ -275,6 +275,8 @@ func (e *EPICS) timeValueToDS(signal string, tv proto.TimeValue) datasource.Valu
Timestamp: tv.Timestamp, Timestamp: tv.Timestamp,
Data: data, Data: data,
Quality: quality, Quality: quality,
Severity: int(tv.Severity),
Status: int(tv.Status),
} }
} }
+2
View File
@@ -45,6 +45,8 @@ type Value struct {
Timestamp time.Time Timestamp time.Time
Data any // float64 | []float64 | string | int64 | bool Data any // float64 | []float64 | string | int64 | bool
Quality Quality Quality Quality
Severity int // raw EPICS alarm severity (0=NO_ALARM,1=MINOR,2=MAJOR,3=INVALID); 0 for sources without alarm info
Status int // raw EPICS alarm status (.STAT); 0 for sources without alarm info
MetaUpdate bool // if true, metadata was refreshed — dispatcher should re-send meta MetaUpdate bool // if true, metadata was refreshed — dispatcher should re-send meta
} }
+23
View File
@@ -180,6 +180,7 @@ func structToValue(sv pvdata.StructValue) datasource.Value {
val := fieldByName(sv, "value") val := fieldByName(sv, "value")
ts := extractTimestamp(sv) ts := extractTimestamp(sv)
quality := extractQuality(sv) quality := extractQuality(sv)
severity, status := extractSeverityStatus(sv)
var data any var data any
if val != nil { if val != nil {
@@ -190,6 +191,8 @@ func structToValue(sv pvdata.StructValue) datasource.Value {
Timestamp: ts, Timestamp: ts,
Data: data, Data: data,
Quality: quality, Quality: quality,
Severity: severity,
Status: status,
} }
} }
@@ -329,6 +332,26 @@ func extractQuality(sv pvdata.StructValue) datasource.Quality {
} }
} }
// extractSeverityStatus pulls the raw EPICS alarm severity and status from the
// NTScalar "alarm" struct. Returns (0, 0) when no alarm struct is present.
func extractSeverityStatus(sv pvdata.StructValue) (severity, status int) {
alarm := structByName(sv, "alarm")
if alarm == nil {
return 0, 0
}
if v := fieldByName(*alarm, "severity"); v != nil {
if s, ok := v.(int32); ok {
severity = int(s)
}
}
if v := fieldByName(*alarm, "status"); v != nil {
if s, ok := v.(int32); ok {
status = int(s)
}
}
return severity, status
}
func toFloat64(v any) (float64, bool) { func toFloat64(v any) (float64, bool) {
switch x := v.(type) { switch x := v.(type) {
case float64: case float64:
+212
View File
@@ -0,0 +1,212 @@
// Package servervar provides a small persistent key/value data source for
// "server variables": named scalar values that the server-side control-logic
// engine writes (e.g. the state of a sequence) and that interface panels can
// read live. Panels may read any variable; writes from panels are gated to
// control-logic editors in the WebSocket write handler.
package servervar
import (
"context"
"encoding/json"
"os"
"path/filepath"
"sort"
"sync"
"time"
"github.com/uopi/uopi/internal/datasource"
)
const fileName = "servervars.json"
type variable struct {
value float64
ts time.Time
}
// Source is the "srv" data source. Variables are created on first write and
// persisted so their last value survives a restart.
type Source struct {
path string
mu sync.RWMutex
vars map[string]*variable
subs map[string]map[int]chan<- datasource.Value
nextID int
}
// New opens (or initialises) the server-variable store under storageDir.
func New(storageDir string) (*Source, error) {
s := &Source{
path: filepath.Join(storageDir, fileName),
vars: map[string]*variable{},
subs: map[string]map[int]chan<- datasource.Value{},
}
if err := s.load(); err != nil {
return nil, err
}
return s, nil
}
func (s *Source) load() error {
data, err := os.ReadFile(s.path)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return err
}
var raw map[string]float64
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
now := time.Now()
for name, v := range raw {
s.vars[name] = &variable{value: v, ts: now}
}
return nil
}
// saveLocked persists the current values atomically. Caller holds s.mu.
func (s *Source) saveLocked() {
raw := make(map[string]float64, len(s.vars))
for name, v := range s.vars {
raw[name] = v.value
}
data, err := json.MarshalIndent(raw, "", " ")
if err != nil {
return
}
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, data, 0o644); err != nil {
return
}
_ = os.Rename(tmp, s.path)
}
func meta(name string) datasource.Metadata {
return datasource.Metadata{
Name: name,
Type: datasource.TypeFloat64,
Description: "Server variable",
Writable: true,
}
}
// Name implements datasource.DataSource.
func (s *Source) Name() string { return "srv" }
// Connect is a no-op — the store is opened in New.
func (s *Source) Connect(_ context.Context) error { return nil }
// ListSignals returns metadata for every defined server variable.
func (s *Source) ListSignals(_ context.Context) ([]datasource.Metadata, error) {
s.mu.RLock()
defer s.mu.RUnlock()
names := make([]string, 0, len(s.vars))
for name := range s.vars {
names = append(names, name)
}
sort.Strings(names)
out := make([]datasource.Metadata, 0, len(names))
for _, name := range names {
out = append(out, meta(name))
}
return out, nil
}
// GetMetadata returns metadata for a single variable. Unknown names still report
// writable metadata so that control logic / authorised panels may create them.
func (s *Source) GetMetadata(_ context.Context, signal string) (datasource.Metadata, error) {
return meta(signal), nil
}
// Subscribe registers ch for updates and immediately delivers the current value.
func (s *Source) Subscribe(ctx context.Context, signal string, ch chan<- datasource.Value) (datasource.CancelFunc, error) {
s.mu.Lock()
if s.subs[signal] == nil {
s.subs[signal] = map[int]chan<- datasource.Value{}
}
id := s.nextID
s.nextID++
s.subs[signal][id] = ch
cur, ok := s.vars[signal]
var first datasource.Value
if ok {
first = datasource.Value{Timestamp: cur.ts, Data: cur.value, Quality: datasource.QualityGood}
}
s.mu.Unlock()
if ok {
go func() {
select {
case ch <- first:
case <-ctx.Done():
}
}()
}
return func() {
s.mu.Lock()
if m := s.subs[signal]; m != nil {
delete(m, id)
if len(m) == 0 {
delete(s.subs, signal)
}
}
s.mu.Unlock()
}, nil
}
func toFloat64(v any) float64 {
switch x := v.(type) {
case float64:
return x
case float32:
return float64(x)
case int:
return float64(x)
case int64:
return float64(x)
case bool:
if x {
return 1
}
return 0
case json.Number:
f, _ := x.Float64()
return f
default:
return 0
}
}
// Write sets a variable (creating it if needed), persists, and fans the new value
// out to all subscribers.
func (s *Source) Write(_ context.Context, signal string, value any) error {
v := toFloat64(value)
s.mu.Lock()
now := time.Now()
s.vars[signal] = &variable{value: v, ts: now}
s.saveLocked()
subs := make([]chan<- datasource.Value, 0, len(s.subs[signal]))
for _, ch := range s.subs[signal] {
subs = append(subs, ch)
}
s.mu.Unlock()
upd := datasource.Value{Timestamp: now, Data: v, Quality: datasource.QualityGood}
for _, ch := range subs {
select {
case ch <- upd:
default:
}
}
return nil
}
// History is not supported.
func (s *Source) History(_ context.Context, _ string, _, _ time.Time, _ int) ([]datasource.Value, error) {
return nil, datasource.ErrHistoryUnavailable
}
@@ -0,0 +1,74 @@
package servervar
import (
"context"
"testing"
"time"
"github.com/uopi/uopi/internal/datasource"
)
func TestWriteSubscribePersist(t *testing.T) {
dir := t.TempDir()
s, err := New(dir)
if err != nil {
t.Fatalf("New: %v", err)
}
ctx := context.Background()
if err := s.Write(ctx, "seq_state", 3.0); err != nil {
t.Fatalf("Write: %v", err)
}
// Subscribe should deliver the current value immediately.
ch := make(chan datasource.Value, 4)
cancel, err := s.Subscribe(ctx, "seq_state", ch)
if err != nil {
t.Fatalf("Subscribe: %v", err)
}
defer cancel()
select {
case v := <-ch:
if got := v.Data.(float64); got != 3.0 {
t.Fatalf("initial value = %v, want 3", got)
}
case <-time.After(time.Second):
t.Fatal("no initial value delivered")
}
// A later write fans out to the subscriber.
if err := s.Write(ctx, "seq_state", 7.0); err != nil {
t.Fatalf("Write 2: %v", err)
}
select {
case v := <-ch:
if got := v.Data.(float64); got != 7.0 {
t.Fatalf("updated value = %v, want 7", got)
}
case <-time.After(time.Second):
t.Fatal("no update delivered")
}
// ListSignals reports the variable.
sigs, err := s.ListSignals(ctx)
if err != nil || len(sigs) != 1 || sigs[0].Name != "seq_state" {
t.Fatalf("ListSignals = %+v, err %v", sigs, err)
}
// A fresh store over the same dir recovers the last value.
s2, err := New(dir)
if err != nil {
t.Fatalf("reopen: %v", err)
}
ch2 := make(chan datasource.Value, 1)
cancel2, _ := s2.Subscribe(ctx, "seq_state", ch2)
defer cancel2()
select {
case v := <-ch2:
if got := v.Data.(float64); got != 7.0 {
t.Fatalf("persisted value = %v, want 7", got)
}
case <-time.After(time.Second):
t.Fatal("persisted value not delivered")
}
}
@@ -0,0 +1,222 @@
package synthetic
import (
"context"
"log/slog"
"math"
"os"
"testing"
"time"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/datasource"
"github.com/uopi/uopi/internal/dsp"
)
// evalSampleDef compiles a SignalDef and evaluates it against per-source
// Samples keyed by source node id, returning the output Sample.
func evalSampleDef(t *testing.T, def SignalDef, srcVals map[string]dsp.Sample) dsp.Sample {
t.Helper()
rg, err := compileGraph(def)
if err != nil {
t.Fatalf("compileGraph: %v", err)
}
out, err := rg.evalSample(srcVals)
if err != nil {
t.Fatalf("evalSample: %v", err)
}
return out
}
// TestArrayElementwiseChain runs an array source through an elementwise op
// (gain) and asserts the output stays an array, broadcast per element.
func TestArrayElementwiseChain(t *testing.T) {
def := SignalDef{
Name: "scaled",
Graph: &Graph{
Output: "out",
Nodes: []GraphNode{
{ID: "a", Kind: "source", DS: "x", Signal: "wave"},
{ID: "g", Kind: "op", Op: "gain", Inputs: []string{"a"}, Params: map[string]any{"gain": 2.0}},
{ID: "out", Kind: "output", Inputs: []string{"g"}},
},
},
}
out := evalSampleDef(t, def, map[string]dsp.Sample{"a": dsp.Array([]float64{1, 2, 3})})
if !out.IsArray {
t.Fatalf("want array output, got %v", out)
}
want := []float64{2, 4, 6}
for i, v := range want {
if out.Arr[i] != v {
t.Errorf("scaled[%d]: want %v, got %v", i, v, out.Arr[i])
}
}
}
// TestArrayReductionToScalar runs an array source into mean (array→scalar) and
// asserts a scalar output and an array-output compile type for the producer.
func TestArrayReductionToScalar(t *testing.T) {
def := SignalDef{
Name: "avg",
Graph: &Graph{
Output: "out",
Nodes: []GraphNode{
{ID: "a", Kind: "source", DS: "x", Signal: "wave"},
{ID: "m", Kind: "op", Op: "mean", Inputs: []string{"a"}},
{ID: "out", Kind: "output", Inputs: []string{"m"}},
},
},
}
out := evalSampleDef(t, def, map[string]dsp.Sample{"a": dsp.Array([]float64{2, 4, 6, 8})})
if out.IsArray {
t.Fatalf("want scalar output, got array %v", out.Arr)
}
if math.Abs(out.F-5) > 1e-9 {
t.Errorf("mean: want 5, got %v", out.F)
}
}
// TestArrayOutTypeMetadata verifies compileGraph reports an array output type
// for a pure-elementwise array graph and scalar for a reduction graph.
func TestArrayOutTypeMetadata(t *testing.T) {
arrayGraph := SignalDef{
Name: "fftout",
Graph: &Graph{
Output: "out",
Nodes: []GraphNode{
{ID: "a", Kind: "source", DS: "x", Signal: "wave"},
{ID: "f", Kind: "op", Op: "fft", Inputs: []string{"a"}},
{ID: "out", Kind: "output", Inputs: []string{"f"}},
},
},
}
rg, err := compileGraph(arrayGraph)
if err != nil {
t.Fatalf("compileGraph: %v", err)
}
if rg.outType != dsp.ValArray {
t.Errorf("fft graph outType: want ValArray, got %v", rg.outType)
}
reduction := SignalDef{
Name: "sumout",
Graph: &Graph{
Output: "out",
Nodes: []GraphNode{
{ID: "a", Kind: "source", DS: "x", Signal: "wave"},
{ID: "s", Kind: "op", Op: "sum", Inputs: []string{"a"}},
{ID: "out", Kind: "output", Inputs: []string{"s"}},
},
},
}
rg2, err := compileGraph(reduction)
if err != nil {
t.Fatalf("compileGraph: %v", err)
}
if rg2.outType != dsp.ValScalar {
t.Errorf("sum graph outType: want ValScalar, got %v", rg2.outType)
}
}
// TestStatefulRejectsArray verifies a stateful op (moving_average) errors when
// fed an array input at runtime.
func TestStatefulRejectsArray(t *testing.T) {
def := SignalDef{
Name: "ma",
Graph: &Graph{
Output: "out",
Nodes: []GraphNode{
{ID: "a", Kind: "source", DS: "x", Signal: "wave"},
{ID: "m", Kind: "op", Op: "moving_average", Inputs: []string{"a"}, Params: map[string]any{"window": 3.0}},
{ID: "out", Kind: "output", Inputs: []string{"m"}},
},
},
}
rg, err := compileGraph(def)
if err != nil {
t.Fatalf("compileGraph: %v", err)
}
if _, err := rg.evalSample(map[string]dsp.Sample{"a": dsp.Array([]float64{1, 2, 3})}); err == nil {
t.Error("expected moving_average to reject an array input")
}
}
// TestSubscribeArrayPassthrough is an end-to-end check that a synthetic with an
// array-valued source and an elementwise op emits a []float64 over the broker,
// and that GetMetadata reports the waveform type.
func TestSubscribeArrayPassthrough(t *testing.T) {
log := slog.New(slog.NewTextHandler(os.Stderr, nil))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
base := time.Date(2026, 6, 19, 10, 0, 0, 0, time.UTC)
src := &seqSource{name: "src", seq: []datasource.Value{
{Timestamp: base.Add(1 * time.Second), Data: []float64{1, 2, 3}, Quality: datasource.QualityGood},
{Timestamp: base.Add(2 * time.Second), Data: []float64{4, 5, 6}, Quality: datasource.QualityGood},
}}
brk := broker.New(ctx, log)
brk.Register(src)
syn := New(t.TempDir(), brk, log)
if err := syn.Connect(ctx); err != nil {
t.Fatal(err)
}
// gain x2 elementwise keeps the value an array.
if err := syn.AddSignal(SignalDef{
Name: "scaled",
Graph: &Graph{Output: "out", Nodes: []GraphNode{
{ID: "a", Kind: "source", DS: "src", Signal: "x"},
{ID: "g", Kind: "op", Op: "gain", Inputs: []string{"a"}, Params: map[string]any{"gain": 2.0}},
{ID: "out", Kind: "output", Inputs: []string{"g"}},
}},
}); err != nil {
t.Fatal(err)
}
// An fft-based signal has a statically-known array output type (the source's
// runtime type need not be known), so its metadata reports the waveform type.
if err := syn.AddSignal(SignalDef{
Name: "spectrum",
Graph: &Graph{Output: "out", Nodes: []GraphNode{
{ID: "a", Kind: "source", DS: "src", Signal: "x"},
{ID: "f", Kind: "op", Op: "fft", Inputs: []string{"a"}},
{ID: "out", Kind: "output", Inputs: []string{"f"}},
}},
}); err != nil {
t.Fatal(err)
}
meta, err := syn.GetMetadata(ctx, "spectrum")
if err != nil {
t.Fatal(err)
}
if meta.Type != datasource.TypeFloat64Array {
t.Errorf("metadata type: want TypeFloat64Array, got %v", meta.Type)
}
ch := make(chan datasource.Value, 8)
if _, err := syn.Subscribe(ctx, "scaled", ch); err != nil {
t.Fatal(err)
}
want := [][]float64{{2, 4, 6}, {8, 10, 12}}
for i, w := range want {
select {
case v := <-ch:
arr, ok := v.Data.([]float64)
if !ok {
t.Fatalf("emit #%d: want []float64, got %T", i, v.Data)
}
if len(arr) != len(w) {
t.Fatalf("emit #%d: want len %d, got %d", i, len(w), len(arr))
}
for k, val := range w {
if arr[k] != val {
t.Errorf("emit #%d [%d]: want %v, got %v", i, k, val, arr[k])
}
}
case <-time.After(2 * time.Second):
t.Fatalf("timeout waiting for emit #%d", i)
}
}
}
@@ -9,6 +9,7 @@ type SignalDef struct {
Signal string `json:"signal"` // upstream signal name (or "" for constant) Signal string `json:"signal"` // upstream signal name (or "" for constant)
Inputs []InputRef `json:"inputs"` // alternative multi-input format Inputs []InputRef `json:"inputs"` // alternative multi-input format
Pipeline []NodeDef `json:"pipeline"` // ordered list of DSP nodes Pipeline []NodeDef `json:"pipeline"` // ordered list of DSP nodes
Graph *Graph `json:"graph,omitempty"` // DAG form (preferred when present)
Meta MetaOverride `json:"meta"` // optional metadata overrides Meta MetaOverride `json:"meta"` // optional metadata overrides
// Visibility controls who sees this signal in the signal tree: // Visibility controls who sees this signal in the signal tree:
@@ -20,6 +21,12 @@ type SignalDef struct {
Visibility string `json:"visibility,omitempty"` Visibility string `json:"visibility,omitempty"`
Owner string `json:"owner,omitempty"` // creator identity (stamped server-side) Owner string `json:"owner,omitempty"` // creator identity (stamped server-side)
Panel string `json:"panel,omitempty"` // bound interface id for "panel" visibility Panel string `json:"panel,omitempty"` // bound interface id for "panel" visibility
// Version and Tag implement git-style revisioning. Version is bumped on
// every UpdateSignal; superseded revisions are kept as backup files. Tag is
// an optional human label for a revision (e.g. "restored from v3").
Version int `json:"version,omitempty"`
Tag string `json:"tag,omitempty"`
} }
// InputRef names one upstream signal used as input to the pipeline. // InputRef names one upstream signal used as input to the pipeline.
@@ -34,6 +41,37 @@ type NodeDef struct {
Params map[string]any `json:"params,omitempty"` Params map[string]any `json:"params,omitempty"`
} }
// Graph is the DAG form of a synthetic signal: a set of nodes (sources, ops and
// one output) wired together by explicit per-node ordered input lists. It
// supersedes the linear Inputs+Pipeline form: when SignalDef.Graph is set it is
// authoritative; otherwise the legacy linear fields are converted into an
// equivalent graph at load time (see toGraph).
type Graph struct {
Nodes []GraphNode `json:"nodes"`
Output string `json:"output"` // id of the output node
}
// GraphNode is one node in a Graph.
//
// kind=="source": carries DS+Signal; has no Inputs (a graph root).
// kind=="op": carries Op + Params; Inputs lists upstream node IDs in the
// order the op receives them (input 0, 1, … e.g. ab, a÷b).
// kind=="output": Inputs has a single upstream node whose value is the result.
//
// X/Y are the editor layout coordinates, persisted so a reloaded graph keeps its
// shape; they have no effect on evaluation.
type GraphNode struct {
ID string `json:"id"`
Kind string `json:"kind"`
Op string `json:"op,omitempty"`
Params map[string]any `json:"params,omitempty"`
DS string `json:"ds,omitempty"`
Signal string `json:"signal,omitempty"`
Inputs []string `json:"inputs,omitempty"`
X float64 `json:"x,omitempty"`
Y float64 `json:"y,omitempty"`
}
// MetaOverride allows the synthetic signal to override display metadata. // MetaOverride allows the synthetic signal to override display metadata.
type MetaOverride struct { type MetaOverride struct {
Unit string `json:"unit,omitempty"` Unit string `json:"unit,omitempty"`
+48 -13
View File
@@ -38,21 +38,32 @@ func stringParam(params map[string]any, key string) string {
return s return s
} }
// BuildPipeline converts a []NodeDef (from JSON) into a []dsp.Node ready for // stringSliceParam extracts a []string from params; JSON arrays decode to []any,
// execution. JSON numbers are float64, so all numeric params are handled as // so each element is coerced via its string value. Returns nil if missing.
// float64 regardless of the final type needed. func stringSliceParam(params map[string]any, key string) []string {
func BuildPipeline(defs []NodeDef) ([]dsp.Node, error) { if params == nil {
nodes := make([]dsp.Node, 0, len(defs)) return nil
for i, d := range defs {
n, err := buildNode(d)
if err != nil {
return nil, fmt.Errorf("pipeline node %d (%q): %w", i, d.Type, err)
} }
nodes = append(nodes, n) v, ok := params[key]
if !ok {
return nil
} }
return nodes, nil arr, ok := v.([]any)
if !ok {
return nil
}
out := make([]string, 0, len(arr))
for _, e := range arr {
if s, ok := e.(string); ok && s != "" {
out = append(out, s)
}
}
return out
} }
// buildNode converts a single NodeDef (from JSON) into a dsp.Node ready for
// execution. JSON numbers are float64, so all numeric params are handled as
// float64 regardless of the final type needed.
func buildNode(d NodeDef) (dsp.Node, error) { func buildNode(d NodeDef) (dsp.Node, error) {
p := d.Params p := d.Params
switch d.Type { switch d.Type {
@@ -100,7 +111,7 @@ func buildNode(d NodeDef) (dsp.Node, error) {
}, nil }, nil
case "expr": case "expr":
return &dsp.ExprNode{Expr: stringParam(p, "expr")}, nil return &dsp.ExprNode{Expr: stringParam(p, "expr"), Vars: stringSliceParam(p, "vars")}, nil
case "lowpass": case "lowpass":
order := int(floatParam(p, "order")) order := int(floatParam(p, "order"))
@@ -113,7 +124,31 @@ func buildNode(d NodeDef) (dsp.Node, error) {
}, nil }, nil
case "lua": case "lua":
return &dsp.LuaNode{Script: stringParam(p, "script")}, nil return &dsp.LuaNode{Script: stringParam(p, "script"), Vars: stringSliceParam(p, "vars")}, nil
case "index":
return &dsp.IndexNode{I: int(floatParam(p, "i"))}, nil
case "slice":
return &dsp.SliceNode{Start: int(floatParam(p, "start")), End: int(floatParam(p, "end"))}, nil
case "sum":
return &dsp.SumNode{}, nil
case "mean":
return &dsp.MeanNode{}, nil
case "min":
return &dsp.MinNode{}, nil
case "max":
return &dsp.MaxNode{}, nil
case "length":
return &dsp.LengthNode{}, nil
case "fft":
return &dsp.FFTNode{}, nil
default: default:
return nil, fmt.Errorf("unknown node type %q", d.Type) return nil, fmt.Errorf("unknown node type %q", d.Type)
+263
View File
@@ -0,0 +1,263 @@
package synthetic
import (
"errors"
"fmt"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/dsp"
)
// runtimeGraph is the executable form of a synthetic signal's DAG. Nodes are
// held in topological order so a single forward pass computes every value with
// each node's inputs already resolved. Op-node state maps persist across
// evaluations (for stateful nodes like moving_average / lua).
type runtimeGraph struct {
order []*rtNode // topological order (sources first, output last)
sources []rtSource // source nodes, in topological order
outputID string // id of the output node
outType dsp.ValType // best-effort output type (scalar/array/unknown)
}
type rtNode struct {
id string
kind string // source | op | output
op dsp.Node // set for kind==op
state map[string]any // persistent per-node state (op only)
inputs []string // upstream node ids, in input order
}
type rtSource struct {
id string
ref broker.SignalRef
}
// sourceRefs returns the broker references for every source node, in a stable
// order matching rg.sources.
func (rg *runtimeGraph) sourceRefs() []broker.SignalRef {
refs := make([]broker.SignalRef, len(rg.sources))
for i, s := range rg.sources {
refs[i] = s.ref
}
return refs
}
// evalSample computes the output Sample (scalar or array) given the latest
// value for each source node (keyed by source node id). Nodes are visited in
// topological order so every input is present by the time a node is processed.
//
// Op dispatch:
// - ArrayNode ops (reductions/producers) run natively on Samples.
// - stateless elementwise ops broadcast over array inputs.
// - stateful ops (filters) and lua are scalar-only; an array input errors,
// since their per-evaluation state cannot be split across array lanes.
func (rg *runtimeGraph) evalSample(sourceVals map[string]dsp.Sample) (dsp.Sample, error) {
vals := make(map[string]dsp.Sample, len(rg.order))
for id, v := range sourceVals {
vals[id] = v
}
for _, n := range rg.order {
switch n.kind {
case "op":
in := make([]dsp.Sample, len(n.inputs))
for i, id := range n.inputs {
in[i] = vals[id]
}
r, err := evalOp(n, in)
if err != nil {
return dsp.Sample{}, fmt.Errorf("node %s (%s): %w", n.id, n.op.Type(), err)
}
vals[n.id] = r
case "output":
if len(n.inputs) > 0 {
vals[n.id] = vals[n.inputs[0]]
}
}
}
return vals[rg.outputID], nil
}
// evalOp runs a single op node over its Sample inputs, choosing the right
// execution path for the node type.
func evalOp(n *rtNode, in []dsp.Sample) (dsp.Sample, error) {
if an, ok := n.op.(dsp.ArrayNode); ok {
return an.ProcessSample(in, n.state)
}
if dsp.StatelessElementwise(n.op.Type()) {
return dsp.ApplyElementwise(n.op, in, n.state)
}
// Stateful / lua: scalar-only.
row := make([]float64, len(in))
for i, s := range in {
if s.IsArray {
return dsp.Sample{}, fmt.Errorf("does not accept an array input")
}
row[i] = s.F
}
r, err := n.op.Process(row, n.state)
if err != nil {
return dsp.Sample{}, err
}
return dsp.Scalar(r), nil
}
// eval is the scalar wrapper around evalSample, kept so callers and tests that
// deal purely in float64 (legacy linear graphs, scalar sources) are unchanged.
func (rg *runtimeGraph) eval(sourceVals map[string]float64) (float64, error) {
sv := make(map[string]dsp.Sample, len(sourceVals))
for id, v := range sourceVals {
sv[id] = dsp.Scalar(v)
}
out, err := rg.evalSample(sv)
if err != nil {
return 0, err
}
return out.F, nil
}
// compileGraph converts a SignalDef into an executable runtimeGraph. When the
// def carries an explicit Graph it is used directly; otherwise the legacy
// Inputs+Pipeline form is converted to an equivalent linear graph (see toGraph).
func compileGraph(def SignalDef) (*runtimeGraph, error) {
g := toGraph(def)
if g == nil || len(g.Nodes) == 0 {
return &runtimeGraph{}, nil
}
order, err := topoOrder(g)
if err != nil {
return nil, err
}
rg := &runtimeGraph{outputID: g.Output}
// nodeType tracks each node's best-effort output type for static
// propagation. Sources are unknown at compile time (their real type is
// only known once data flows), so type errors here are advisory; runtime
// Sample typing is authoritative.
nodeType := make(map[string]dsp.ValType, len(order))
for _, gn := range order {
switch gn.Kind {
case "source":
rg.sources = append(rg.sources, rtSource{id: gn.ID, ref: broker.SignalRef{DS: gn.DS, Name: gn.Signal}})
nodeType[gn.ID] = dsp.ValUnknown
case "op":
node, err := buildNode(NodeDef{Type: gn.Op, Params: gn.Params})
if err != nil {
return nil, fmt.Errorf("node %q: %w", gn.ID, err)
}
inTypes := make([]dsp.ValType, len(gn.Inputs))
for i, id := range gn.Inputs {
inTypes[i] = nodeType[id]
}
ot, terr := dsp.OpOutputType(gn.Op, inTypes)
if terr != nil {
return nil, fmt.Errorf("node %q: %w", gn.ID, terr)
}
nodeType[gn.ID] = ot
rg.order = append(rg.order, &rtNode{id: gn.ID, kind: "op", op: node, state: map[string]any{}, inputs: gn.Inputs})
case "output":
rg.outputID = gn.ID
if len(gn.Inputs) > 0 {
nodeType[gn.ID] = nodeType[gn.Inputs[0]]
}
rg.order = append(rg.order, &rtNode{id: gn.ID, kind: "output", inputs: gn.Inputs})
default:
return nil, fmt.Errorf("node %q: unknown kind %q", gn.ID, gn.Kind)
}
}
rg.outType = nodeType[rg.outputID]
return rg, nil
}
// topoOrder returns the graph's nodes in a topological (dependency-first) order,
// treating each node's Inputs as its predecessors. It errors on dangling input
// references or cycles.
func topoOrder(g *Graph) ([]GraphNode, error) {
byID := make(map[string]GraphNode, len(g.Nodes))
for _, n := range g.Nodes {
byID[n.ID] = n
}
indeg := make(map[string]int, len(g.Nodes))
succ := make(map[string][]string, len(g.Nodes))
for _, n := range g.Nodes {
if _, ok := indeg[n.ID]; !ok {
indeg[n.ID] = 0
}
for _, in := range n.Inputs {
if _, ok := byID[in]; !ok {
return nil, fmt.Errorf("node %q references unknown input %q", n.ID, in)
}
indeg[n.ID]++
succ[in] = append(succ[in], n.ID)
}
}
// Seed the queue with roots, preserving the node slice order for determinism.
queue := make([]string, 0, len(g.Nodes))
for _, n := range g.Nodes {
if indeg[n.ID] == 0 {
queue = append(queue, n.ID)
}
}
order := make([]GraphNode, 0, len(g.Nodes))
for len(queue) > 0 {
id := queue[0]
queue = queue[1:]
order = append(order, byID[id])
for _, s := range succ[id] {
indeg[s]--
if indeg[s] == 0 {
queue = append(queue, s)
}
}
}
if len(order) != len(g.Nodes) {
return nil, errors.New("graph contains a cycle")
}
return order, nil
}
// toGraph returns the DAG for a SignalDef. If def.Graph is set it is returned
// as-is. Otherwise the legacy linear form is converted: each input signal
// becomes a source node, the pipeline becomes a chain of op nodes (the first op
// receiving every source, each later op the previous op's output), terminated by
// an output node. With no pipeline the output takes the first source directly,
// matching the old runPipeline behaviour.
func toGraph(def SignalDef) *Graph {
if def.Graph != nil && len(def.Graph.Nodes) > 0 {
return def.Graph
}
inputs := def.Inputs
if len(inputs) == 0 && def.DS != "" && def.Signal != "" {
inputs = []InputRef{{DS: def.DS, Signal: def.Signal}}
}
nodes := make([]GraphNode, 0, len(inputs)+len(def.Pipeline)+1)
srcIDs := make([]string, 0, len(inputs))
for i, inp := range inputs {
id := fmt.Sprintf("s%d", i)
nodes = append(nodes, GraphNode{ID: id, Kind: "source", DS: inp.DS, Signal: inp.Signal})
srcIDs = append(srcIDs, id)
}
opIDs := make([]string, 0, len(def.Pipeline))
for i, nd := range def.Pipeline {
id := fmt.Sprintf("p%d", i)
var ins []string
if i == 0 {
ins = srcIDs
} else {
ins = []string{opIDs[i-1]}
}
nodes = append(nodes, GraphNode{ID: id, Kind: "op", Op: nd.Type, Params: nd.Params, Inputs: ins})
opIDs = append(opIDs, id)
}
var outInputs []string
if len(opIDs) > 0 {
outInputs = []string{opIDs[len(opIDs)-1]}
} else if len(srcIDs) > 0 {
outInputs = []string{srcIDs[0]}
}
nodes = append(nodes, GraphNode{ID: "out", Kind: "output", Inputs: outInputs})
return &Graph{Nodes: nodes, Output: "out"}
}
+130
View File
@@ -0,0 +1,130 @@
package synthetic
import (
"math"
"testing"
)
// evalDef compiles a SignalDef and evaluates it against per-source values keyed
// by source node id.
func evalDef(t *testing.T, def SignalDef, srcVals map[string]float64) float64 {
t.Helper()
rg, err := compileGraph(def)
if err != nil {
t.Fatalf("compileGraph: %v", err)
}
out, err := rg.eval(srcVals)
if err != nil {
t.Fatalf("eval: %v", err)
}
return out
}
// TestGraphMultiInputDAG verifies that an intermediate op can take two
// independently-wired sources — the capability the old linear pipeline lacked.
func TestGraphMultiInputDAG(t *testing.T) {
def := SignalDef{
Name: "diff",
Graph: &Graph{
Output: "out",
Nodes: []GraphNode{
{ID: "a", Kind: "source", DS: "x", Signal: "left"},
{ID: "b", Kind: "source", DS: "x", Signal: "right"},
{ID: "sub", Kind: "op", Op: "subtract", Inputs: []string{"a", "b"}},
{ID: "out", Kind: "output", Inputs: []string{"sub"}},
},
},
}
got := evalDef(t, def, map[string]float64{"a": 10, "b": 3})
if got != 7 {
t.Errorf("subtract DAG: want 7, got %v", got)
}
}
// TestGraphExprNamedInputs verifies expr nodes bind arbitrary named inputs in
// wired order.
func TestGraphExprNamedInputs(t *testing.T) {
def := SignalDef{
Name: "formula",
Graph: &Graph{
Output: "out",
Nodes: []GraphNode{
{ID: "a", Kind: "source", DS: "x", Signal: "p"},
{ID: "b", Kind: "source", DS: "x", Signal: "q"},
{ID: "e", Kind: "op", Op: "expr", Inputs: []string{"a", "b"},
Params: map[string]any{"expr": "price * qty", "vars": []any{"price", "qty"}}},
{ID: "out", Kind: "output", Inputs: []string{"e"}},
},
},
}
got := evalDef(t, def, map[string]float64{"a": 4, "b": 2.5})
if math.Abs(got-10) > 1e-9 {
t.Errorf("expr named inputs: want 10, got %v", got)
}
}
// TestGraphFanInToExpr exercises a non-trivial DAG: two ops feeding one expr.
func TestGraphFanInToExpr(t *testing.T) {
def := SignalDef{
Name: "combo",
Graph: &Graph{
Output: "out",
Nodes: []GraphNode{
{ID: "a", Kind: "source", DS: "x", Signal: "p"},
{ID: "b", Kind: "source", DS: "x", Signal: "q"},
{ID: "g", Kind: "op", Op: "gain", Inputs: []string{"a"}, Params: map[string]any{"gain": 2.0}},
{ID: "o", Kind: "op", Op: "offset", Inputs: []string{"b"}, Params: map[string]any{"offset": 1.0}},
{ID: "e", Kind: "op", Op: "expr", Inputs: []string{"g", "o"}, Params: map[string]any{"expr": "a + b"}},
{ID: "out", Kind: "output", Inputs: []string{"e"}},
},
},
}
// g = 5*2 = 10 ; o = 4+1 = 5 ; a+b = 15
got := evalDef(t, def, map[string]float64{"a": 5, "b": 4})
if math.Abs(got-15) > 1e-9 {
t.Errorf("fan-in DAG: want 15, got %v", got)
}
}
// TestGraphLegacyConversion verifies the linear Inputs+Pipeline form still
// evaluates correctly via the graph runtime.
func TestGraphLegacyConversion(t *testing.T) {
def := SignalDef{
Name: "legacy",
DS: "x",
Signal: "p",
Pipeline: []NodeDef{{Type: "gain", Params: map[string]any{"gain": 3.0}}},
}
rg, err := compileGraph(def)
if err != nil {
t.Fatalf("compileGraph: %v", err)
}
if len(rg.sources) != 1 {
t.Fatalf("want 1 source, got %d", len(rg.sources))
}
got, err := rg.eval(map[string]float64{rg.sources[0].id: 4})
if err != nil {
t.Fatalf("eval: %v", err)
}
if got != 12 {
t.Errorf("legacy gain: want 12, got %v", got)
}
}
// TestGraphCycleRejected ensures a cyclic graph is refused at compile time.
func TestGraphCycleRejected(t *testing.T) {
def := SignalDef{
Name: "cyclic",
Graph: &Graph{
Output: "out",
Nodes: []GraphNode{
{ID: "a", Kind: "op", Op: "gain", Inputs: []string{"b"}, Params: map[string]any{"gain": 1.0}},
{ID: "b", Kind: "op", Op: "gain", Inputs: []string{"a"}, Params: map[string]any{"gain": 1.0}},
{ID: "out", Kind: "output", Inputs: []string{"a"}},
},
},
}
if _, err := compileGraph(def); err == nil {
t.Error("expected cycle to be rejected")
}
}
+97 -94
View File
@@ -21,8 +21,7 @@ const definitionsFile = "synthetic.json"
// signalState holds everything needed to run one synthetic signal. // signalState holds everything needed to run one synthetic signal.
type signalState struct { type signalState struct {
def SignalDef def SignalDef
nodes []dsp.Node rg *runtimeGraph // compiled DAG; op-node state persists across evaluations
states []map[string]any // one map per node, persistent across calls
// cancel stops the goroutine driving this signal. // cancel stops the goroutine driving this signal.
cancel context.CancelFunc cancel context.CancelFunc
@@ -80,7 +79,7 @@ func (s *Synthetic) ListSignals(_ context.Context) ([]datasource.Metadata, error
out := make([]datasource.Metadata, 0, len(s.signals)) out := make([]datasource.Metadata, 0, len(s.signals))
for _, st := range s.signals { for _, st := range s.signals {
out = append(out, defToMetadata(st.def)) out = append(out, defToMetadata(st.def, outTypeOf(st)))
} }
return out, nil return out, nil
} }
@@ -95,7 +94,7 @@ func (s *Synthetic) FilteredMetadata(keep func(SignalDef) bool) []datasource.Met
out := make([]datasource.Metadata, 0, len(s.signals)) out := make([]datasource.Metadata, 0, len(s.signals))
for _, st := range s.signals { for _, st := range s.signals {
if keep(st.def) { if keep(st.def) {
out = append(out, defToMetadata(st.def)) out = append(out, defToMetadata(st.def, outTypeOf(st)))
} }
} }
return out return out
@@ -110,7 +109,7 @@ func (s *Synthetic) GetMetadata(_ context.Context, signal string) (datasource.Me
if !ok { if !ok {
return datasource.Metadata{}, datasource.ErrNotFound 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. // Subscribe registers ch to receive computed values for the named signal.
@@ -123,19 +122,25 @@ func (s *Synthetic) Subscribe(ctx context.Context, signal string, ch chan<- data
return nil, datasource.ErrNotFound return nil, datasource.ErrNotFound
} }
// Collect the upstream references for this signal. // Collect the source node references for this signal's DAG.
refs := upstreamRefs(st.def) refs := st.rg.sourceRefs()
if len(refs) == 0 { if len(refs) == 0 {
return nil, fmt.Errorf("synthetic: signal %q has no upstream inputs", signal) return nil, fmt.Errorf("synthetic: signal %q has no upstream inputs", signal)
} }
// Source node ids, index-aligned with refs, so updates map to graph inputs.
srcIDs := make([]string, len(st.rg.sources))
for i, s := range st.rg.sources {
srcIDs[i] = s.id
}
ctx, cancel := context.WithCancel(ctx) ctx, cancel := context.WithCancel(ctx)
go func() { go func() {
defer cancel() defer cancel()
// Latest value per upstream input index. // Latest value and timestamp per source node id.
latest := make([]float64, len(refs)) latest := make(map[string]dsp.Sample, len(refs))
latestTs := make([]time.Time, len(refs))
ready := make([]bool, len(refs)) ready := make([]bool, len(refs))
// Subscribe to every upstream ref via the broker. // Subscribe to every upstream ref via the broker.
@@ -159,7 +164,7 @@ func (s *Synthetic) Subscribe(ctx context.Context, signal string, ch chan<- data
if !ok { if !ok {
return return
} }
val := toFloat64(u.Value.Data) val := toSample(u.Value.Data)
select { select {
case updateCh <- indexedUpdate{idx: idx, val: val, ts: u.Value.Timestamp}: case updateCh <- indexedUpdate{idx: idx, val: val, ts: u.Value.Timestamp}:
default: default:
@@ -184,7 +189,8 @@ func (s *Synthetic) Subscribe(ctx context.Context, signal string, ch chan<- data
return return
case upd := <-updateCh: case upd := <-updateCh:
latest[upd.idx] = upd.val latest[srcIDs[upd.idx]] = upd.val
latestTs[upd.idx] = upd.ts
ready[upd.idx] = true ready[upd.idx] = true
// Only compute once we have at least one value for every input. // Only compute once we have at least one value for every input.
@@ -199,7 +205,19 @@ func (s *Synthetic) Subscribe(ctx context.Context, signal string, ch chan<- data
continue continue
} }
// Run the pipeline. // The output is computed from the latest value of every input, so
// its timestamp is the most recent contributing sample time. Using
// the triggering update's timestamp instead would drag the output
// back in time whenever a slow/stale input fired, producing
// non-monotonic or duplicated timestamps on plots.
outTs := latestTs[0]
for _, ts := range latestTs[1:] {
if ts.After(outTs) {
outTs = ts
}
}
// Evaluate the DAG.
s.mu.RLock() s.mu.RLock()
cur, stillExists := s.signals[signal] cur, stillExists := s.signals[signal]
s.mu.RUnlock() s.mu.RUnlock()
@@ -207,15 +225,15 @@ func (s *Synthetic) Subscribe(ctx context.Context, signal string, ch chan<- data
return return
} }
result, err := runPipeline(cur.nodes, cur.states, latest) result, err := cur.rg.evalSample(latest)
if err != nil { if err != nil {
s.log.Warn("synthetic: pipeline error", "signal", signal, "err", err) s.log.Warn("synthetic: pipeline error", "signal", signal, "err", err)
continue continue
} }
v := datasource.Value{ v := datasource.Value{
Timestamp: upd.ts, Timestamp: outTs,
Data: result, Data: result.AsAny(),
Quality: datasource.QualityGood, Quality: datasource.QualityGood,
} }
select { select {
@@ -245,10 +263,13 @@ func (s *Synthetic) AddSignal(def SignalDef) error {
if def.Name == "" { if def.Name == "" {
return errors.New("signal name must not be empty") return errors.New("signal name must not be empty")
} }
if def.Version < 1 {
def.Version = 1
}
nodes, err := BuildPipeline(def.Pipeline) rg, err := compileGraph(def)
if err != nil { if err != nil {
return fmt.Errorf("build pipeline: %w", err) return fmt.Errorf("compile graph: %w", err)
} }
s.mu.Lock() s.mu.Lock()
@@ -257,16 +278,7 @@ func (s *Synthetic) AddSignal(def SignalDef) error {
return fmt.Errorf("signal %q already exists", def.Name) return fmt.Errorf("signal %q already exists", def.Name)
} }
states := make([]map[string]any, len(nodes)) st := &signalState{def: def, rg: rg}
for i := range states {
states[i] = make(map[string]any)
}
st := &signalState{
def: def,
nodes: nodes,
states: states,
}
s.signals[def.Name] = st s.signals[def.Name] = st
s.mu.Unlock() s.mu.Unlock()
@@ -320,14 +332,9 @@ func (s *Synthetic) UpdateSignal(def SignalDef) error {
return errors.New("signal name must not be empty") return errors.New("signal name must not be empty")
} }
nodes, err := BuildPipeline(def.Pipeline) rg, err := compileGraph(def)
if err != nil { if err != nil {
return fmt.Errorf("build pipeline: %w", err) return fmt.Errorf("compile graph: %w", err)
}
states := make([]map[string]any, len(nodes))
for i := range states {
states[i] = make(map[string]any)
} }
s.mu.Lock() s.mu.Lock()
@@ -336,10 +343,20 @@ func (s *Synthetic) UpdateSignal(def SignalDef) error {
s.mu.Unlock() s.mu.Unlock()
return datasource.ErrNotFound return datasource.ErrNotFound
} }
// Preserve the superseded revision as a backup and bump the version.
oldDef := old.def
if oldDef.Version < 1 {
oldDef.Version = 1
}
if err := s.backupVersion(oldDef); err != nil {
s.mu.Unlock()
return fmt.Errorf("back up revision: %w", err)
}
def.Version = oldDef.Version + 1
if old.cancel != nil { if old.cancel != nil {
old.cancel() old.cancel()
} }
s.signals[def.Name] = &signalState{def: def, nodes: nodes, states: states} s.signals[def.Name] = &signalState{def: def, rg: rg}
s.mu.Unlock() s.mu.Unlock()
if err := s.saveDefs(); err != nil { if err := s.saveDefs(); err != nil {
@@ -402,78 +419,33 @@ func (s *Synthetic) saveDefs() error {
return os.WriteFile(s.defsFilePath(), data, 0o644) return os.WriteFile(s.defsFilePath(), data, 0o644)
} }
// startSignal builds the pipeline for def and registers the signalState. // startSignal compiles the DAG for def and registers the signalState.
// The actual goroutines are started lazily by Subscribe. // The actual goroutines are started lazily by Subscribe.
func (s *Synthetic) startSignal(def SignalDef) error { func (s *Synthetic) startSignal(def SignalDef) error {
nodes, err := BuildPipeline(def.Pipeline) rg, err := compileGraph(def)
if err != nil { if err != nil {
return fmt.Errorf("build pipeline for %q: %w", def.Name, err) return fmt.Errorf("compile graph for %q: %w", def.Name, err)
}
states := make([]map[string]any, len(nodes))
for i := range states {
states[i] = make(map[string]any)
} }
s.mu.Lock() s.mu.Lock()
s.signals[def.Name] = &signalState{ s.signals[def.Name] = &signalState{def: def, rg: rg}
def: def,
nodes: nodes,
states: states,
}
s.mu.Unlock() s.mu.Unlock()
s.log.Info("synthetic: signal registered", "name", def.Name) s.log.Info("synthetic: signal registered", "name", def.Name)
return nil return nil
} }
// runPipeline executes all nodes in sequence. The output of node N becomes // defToMetadata converts a SignalDef into a datasource.Metadata. outType is the
// input[0] of node N+1. For the first node, inputs is the full upstream slice. // compiled graph's best-effort output type; an array output is reported as a
func runPipeline(nodes []dsp.Node, states []map[string]any, inputs []float64) (float64, error) { // waveform (TypeFloat64Array) so widgets can pick a compatible view.
if len(nodes) == 0 { func defToMetadata(def SignalDef, outType dsp.ValType) datasource.Metadata {
if len(inputs) == 0 { dt := datasource.TypeFloat64
return 0, nil if outType == dsp.ValArray {
dt = datasource.TypeFloat64Array
} }
return inputs[0], nil
}
// First node receives all upstream inputs.
cur := inputs
var result float64
var err error
for i, node := range nodes {
result, err = node.Process(cur, states[i])
if err != nil {
return 0, fmt.Errorf("node %d (%s): %w", i, node.Type(), err)
}
// Subsequent nodes receive only the single output of the previous node.
cur = []float64{result}
}
return result, nil
}
// upstreamRefs returns the broker.SignalRef list for a SignalDef.
// If Inputs is set, those take precedence; otherwise DS+Signal is used.
func upstreamRefs(def SignalDef) []broker.SignalRef {
if len(def.Inputs) > 0 {
refs := make([]broker.SignalRef, len(def.Inputs))
for i, inp := range def.Inputs {
refs[i] = broker.SignalRef{DS: inp.DS, Name: inp.Signal}
}
return refs
}
if def.DS != "" && def.Signal != "" {
return []broker.SignalRef{{DS: def.DS, Name: def.Signal}}
}
return nil
}
// defToMetadata converts a SignalDef into a datasource.Metadata.
func defToMetadata(def SignalDef) datasource.Metadata {
return datasource.Metadata{ return datasource.Metadata{
Name: def.Name, Name: def.Name,
Type: datasource.TypeFloat64, Type: dt,
Unit: def.Meta.Unit, Unit: def.Meta.Unit,
Description: def.Meta.Description, Description: def.Meta.Description,
DisplayLow: def.Meta.DisplayLow, DisplayLow: def.Meta.DisplayLow,
@@ -482,7 +454,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 { func toFloat64(v any) float64 {
switch val := v.(type) { switch val := v.(type) {
case float64: case float64:
@@ -508,6 +511,6 @@ func toFloat64(v any) float64 {
// indexedUpdate carries a value from one upstream goroutine to the pipeline runner. // indexedUpdate carries a value from one upstream goroutine to the pipeline runner.
type indexedUpdate struct { type indexedUpdate struct {
idx int idx int
val float64 val dsp.Sample
ts time.Time ts time.Time
} }
@@ -0,0 +1,154 @@
package synthetic
import (
"context"
"log/slog"
"os"
"testing"
"time"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/datasource"
)
// seqSource is a test DataSource that emits a fixed sequence of values, each
// carrying its own timestamp, so tests can control upstream sample times.
type seqSource struct {
name string
seq []datasource.Value
}
func (s *seqSource) Name() string { return s.name }
func (s *seqSource) Connect(context.Context) error { return nil }
func (s *seqSource) ListSignals(context.Context) ([]datasource.Metadata, error) { return nil, nil }
func (s *seqSource) GetMetadata(context.Context, string) (datasource.Metadata, error) {
return datasource.Metadata{Name: "x", Type: datasource.TypeFloat64}, nil
}
func (s *seqSource) Write(context.Context, string, any) error { return datasource.ErrNotWritable }
func (s *seqSource) History(context.Context, string, time.Time, time.Time, int) ([]datasource.Value, error) {
return nil, datasource.ErrHistoryUnavailable
}
func (s *seqSource) Subscribe(ctx context.Context, _ string, ch chan<- datasource.Value) (datasource.CancelFunc, error) {
go func() {
for _, v := range s.seq {
select {
case ch <- v:
case <-ctx.Done():
return
}
time.Sleep(8 * time.Millisecond)
}
}()
return func() {}, nil
}
// TestSubscribePreservesUpstreamTimestamp verifies a single-source synthetic
// emits each computed value with the upstream sample's timestamp.
func TestSubscribePreservesUpstreamTimestamp(t *testing.T) {
log := slog.New(slog.NewTextHandler(os.Stderr, nil))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
base := time.Date(2026, 6, 19, 10, 0, 0, 0, time.UTC)
src := &seqSource{name: "src", seq: []datasource.Value{
{Timestamp: base.Add(1 * time.Second), Data: 1.0, Quality: datasource.QualityGood},
{Timestamp: base.Add(2 * time.Second), Data: 2.0, Quality: datasource.QualityGood},
{Timestamp: base.Add(3 * time.Second), Data: 3.0, Quality: datasource.QualityGood},
}}
brk := broker.New(ctx, log)
brk.Register(src)
syn := New(t.TempDir(), brk, log)
if err := syn.Connect(ctx); err != nil {
t.Fatal(err)
}
if err := syn.AddSignal(SignalDef{
Name: "g", DS: "src", Signal: "x",
Pipeline: []NodeDef{{Type: "gain", Params: map[string]any{"gain": 10.0}}},
}); err != nil {
t.Fatal(err)
}
ch := make(chan datasource.Value, 8)
if _, err := syn.Subscribe(ctx, "g", ch); err != nil {
t.Fatal(err)
}
want := []time.Time{base.Add(1 * time.Second), base.Add(2 * time.Second), base.Add(3 * time.Second)}
for i, w := range want {
select {
case v := <-ch:
if !v.Timestamp.Equal(w) {
t.Errorf("emit #%d timestamp: want %s, got %s", i, w, v.Timestamp)
}
case <-time.After(2 * time.Second):
t.Fatalf("timeout waiting for emit #%d", i)
}
}
}
// TestSubscribeMultiSourceUsesLatestTimestamp verifies that a synthetic combining
// two independent sources stamps each output with the MOST RECENT contributing
// sample time — not the timestamp of whichever source happened to trigger the
// computation. A slow source carrying a stale timestamp must not drag the output
// backwards in time (which previously produced wrong/non-monotonic plot points).
func TestSubscribeMultiSourceUsesLatestTimestamp(t *testing.T) {
log := slog.New(slog.NewTextHandler(os.Stderr, nil))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
now := time.Date(2026, 6, 19, 12, 0, 0, 0, time.UTC)
// Fast source A with current timestamps.
a := &seqSource{name: "A", seq: []datasource.Value{
{Timestamp: now.Add(10 * time.Second), Data: 1.0, Quality: datasource.QualityGood},
{Timestamp: now.Add(11 * time.Second), Data: 2.0, Quality: datasource.QualityGood},
{Timestamp: now.Add(12 * time.Second), Data: 3.0, Quality: datasource.QualityGood},
{Timestamp: now.Add(13 * time.Second), Data: 4.0, Quality: datasource.QualityGood},
}}
// Slow source B: a single sample with a much older timestamp.
b := &seqSource{name: "B", seq: []datasource.Value{
{Timestamp: now.Add(1 * time.Second), Data: 100.0, Quality: datasource.QualityGood},
}}
brk := broker.New(ctx, log)
brk.Register(a)
brk.Register(b)
syn := New(t.TempDir(), brk, log)
if err := syn.Connect(ctx); err != nil {
t.Fatal(err)
}
if err := syn.AddSignal(SignalDef{
Name: "diff",
Graph: &Graph{Output: "out", Nodes: []GraphNode{
{ID: "sa", Kind: "source", DS: "A", Signal: "x"},
{ID: "sb", Kind: "source", DS: "B", Signal: "x"},
{ID: "sub", Kind: "op", Op: "subtract", Inputs: []string{"sa", "sb"}},
{ID: "out", Kind: "output", Inputs: []string{"sub"}},
}},
}); err != nil {
t.Fatal(err)
}
ch := make(chan datasource.Value, 16)
if _, err := syn.Subscribe(ctx, "diff", ch); err != nil {
t.Fatal(err)
}
var last time.Time
for i := 0; i < 4; i++ {
select {
case v := <-ch:
// The stale source-B timestamp (t=1s) must never be used: every output
// is stamped with the newest input time, so emits stay monotonic.
if v.Timestamp.Equal(now.Add(1 * time.Second)) {
t.Errorf("emit #%d used the stale source-B timestamp %s", i, v.Timestamp)
}
if !last.IsZero() && v.Timestamp.Before(last) {
t.Errorf("emit #%d went backwards: %s before previous %s", i, v.Timestamp, last)
}
last = v.Timestamp
case <-time.After(2 * time.Second):
t.Fatalf("timeout waiting for emit #%d", i)
}
}
}
+174
View File
@@ -0,0 +1,174 @@
package synthetic
import (
"encoding/json"
"fmt"
"hash/fnv"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"unicode"
"github.com/uopi/uopi/internal/datasource"
)
// VersionMeta describes a single persisted revision of a synthetic signal,
// mirroring storage.VersionMeta so the frontend treats every versioned document
// type uniformly.
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"`
}
func (s *Synthetic) versionsDir() string {
return filepath.Join(s.storePath, "synthetic_versions")
}
// slugForFile maps an arbitrary signal name to a filesystem-safe stem. A short
// hash of the full name is appended so distinct names that sanitise to the same
// stem (e.g. "A:B" and "A_B") never share backup files.
func slugForFile(name string) string {
var b strings.Builder
for _, r := range name {
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' || r == '_' {
b.WriteRune(r)
} else {
b.WriteByte('_')
}
}
h := fnv.New32a()
_, _ = h.Write([]byte(name))
return fmt.Sprintf("%s-%08x", b.String(), h.Sum32())
}
func (s *Synthetic) versionPath(name string, version int) string {
return filepath.Join(s.versionsDir(), fmt.Sprintf("%s.v%d.json", slugForFile(name), version))
}
// backupVersion writes a single signal revision to the versions directory.
func (s *Synthetic) backupVersion(def SignalDef) error {
if err := os.MkdirAll(s.versionsDir(), 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(def, "", " ")
if err != nil {
return err
}
return os.WriteFile(s.versionPath(def.Name, def.Version), data, 0o644)
}
// Versions returns metadata for every persisted revision of the named signal,
// newest first. The live revision is flagged Current.
func (s *Synthetic) Versions(name string) ([]VersionMeta, error) {
cur, err := s.GetSignal(name)
if err != nil {
return nil, err
}
curV := cur.Version
if curV < 1 {
curV = 1
}
var savedAt time.Time
if info, err := os.Stat(s.defsFilePath()); err == nil {
savedAt = info.ModTime()
}
out := []VersionMeta{{Version: curV, Name: cur.Name, Tag: cur.Tag, Current: true, SavedAt: savedAt}}
entries, err := os.ReadDir(s.versionsDir())
if err != nil && !os.IsNotExist(err) {
return nil, err
}
prefix := slugForFile(name) + ".v"
for _, e := range entries {
fn := e.Name()
if e.IsDir() || !strings.HasPrefix(fn, prefix) || !strings.HasSuffix(fn, ".json") {
continue
}
vStr := strings.TrimSuffix(strings.TrimPrefix(fn, prefix), ".json")
v, err := strconv.Atoi(vStr)
if err != nil {
continue
}
def, err := s.readVersion(name, v)
if err != nil {
continue
}
info, err := e.Info()
if err != nil {
continue
}
out = append(out, VersionMeta{Version: v, Name: def.Name, Tag: def.Tag, SavedAt: info.ModTime()})
}
sort.Slice(out, func(i, j int) bool { return out[i].Version > out[j].Version })
return out, nil
}
// GetVersion returns a specific revision of the named signal. The current
// revision comes from the live store; older revisions from their backup file.
func (s *Synthetic) GetVersion(name string, version int) (SignalDef, error) {
cur, err := s.GetSignal(name)
if err != nil {
return SignalDef{}, err
}
curV := cur.Version
if curV < 1 {
curV = 1
}
if version == curV {
return cur, nil
}
return s.readVersion(name, version)
}
func (s *Synthetic) readVersion(name string, version int) (SignalDef, error) {
data, err := os.ReadFile(s.versionPath(name, version))
if os.IsNotExist(err) {
return SignalDef{}, datasource.ErrNotFound
}
if err != nil {
return SignalDef{}, err
}
var def SignalDef
if err := json.Unmarshal(data, &def); err != nil {
return SignalDef{}, fmt.Errorf("parse revision: %w", err)
}
return def, nil
}
// PromoteVersion makes a past revision current by re-saving it on top of
// history (non-destructive). Returns the resulting current definition.
func (s *Synthetic) PromoteVersion(name string, version int) (SignalDef, error) {
def, err := s.GetVersion(name, version)
if err != nil {
return SignalDef{}, err
}
def.Tag = fmt.Sprintf("restored from v%d", version)
if err := s.UpdateSignal(def); err != nil {
return SignalDef{}, err
}
return s.GetSignal(name)
}
// ForkVersion creates a brand-new synthetic signal from a specific revision,
// assigning a fresh unique name and resetting its version to 1.
func (s *Synthetic) ForkVersion(name string, version int) (SignalDef, error) {
def, err := s.GetVersion(name, version)
if err != nil {
return SignalDef{}, err
}
def.Name = fmt.Sprintf("%s_fork_%d", name, time.Now().UnixMilli())
def.Version = 1
def.Tag = ""
if err := s.AddSignal(def); err != nil {
return SignalDef{}, err
}
return def, nil
}
@@ -0,0 +1,87 @@
package synthetic
import (
"context"
"testing"
)
func TestSyntheticVersioning(t *testing.T) {
syn, _, cancel := newTestSynthetic(t)
defer cancel()
if err := syn.Connect(context.Background()); err != nil {
t.Fatal(err)
}
// defWithGain builds a fresh def each time, mirroring how the REST handler
// decodes a new SignalDef per request (no aliasing of slices/maps).
defWithGain := func(g float64) SignalDef {
return SignalDef{Name: "wf", DS: "stub", Signal: "sine_1hz",
Pipeline: []NodeDef{{Type: "gain", Params: map[string]any{"gain": g}}}}
}
if err := syn.AddSignal(defWithGain(1.0)); err != nil {
t.Fatalf("AddSignal: %v", err)
}
cur, err := syn.GetSignal("wf")
if err != nil || cur.Version != 1 {
t.Fatalf("after add: version=%d err=%v", cur.Version, err)
}
// Two edits → v2, v3.
if err := syn.UpdateSignal(defWithGain(2.0)); err != nil {
t.Fatalf("UpdateSignal: %v", err)
}
if err := syn.UpdateSignal(defWithGain(3.0)); err != nil {
t.Fatalf("UpdateSignal: %v", err)
}
cur, _ = syn.GetSignal("wf")
if cur.Version != 3 {
t.Fatalf("want current v3, got v%d", cur.Version)
}
versions, err := syn.Versions("wf")
if err != nil {
t.Fatalf("Versions: %v", err)
}
if len(versions) != 3 {
t.Fatalf("want 3 versions, got %d", len(versions))
}
if !versions[0].Current || versions[0].Version != 3 {
t.Errorf("newest should be current v3: %+v", versions[0])
}
// v1 backup retrievable with original gain.
v1, err := syn.GetVersion("wf", 1)
if err != nil {
t.Fatalf("GetVersion v1: %v", err)
}
if v1.Pipeline[0].Params["gain"] != 1.0 {
t.Errorf("v1 gain: want 1.0, got %v", v1.Pipeline[0].Params["gain"])
}
// Promote v1 → becomes v4 (non-destructive).
promoted, err := syn.PromoteVersion("wf", 1)
if err != nil {
t.Fatalf("Promote: %v", err)
}
if promoted.Version != 4 || promoted.Pipeline[0].Params["gain"] != 1.0 {
t.Errorf("promote: version=%d gain=%v", promoted.Version, promoted.Pipeline[0].Params["gain"])
}
// Fork v3 → fresh signal, version 1.
forked, err := syn.ForkVersion("wf", 3)
if err != nil {
t.Fatalf("Fork: %v", err)
}
if forked.Version != 1 || forked.Name == "wf" {
t.Errorf("fork: name=%q version=%d", forked.Name, forked.Version)
}
if forked.Pipeline[0].Params["gain"] != 3.0 {
t.Errorf("fork gain: want 3.0, got %v", forked.Pipeline[0].Params["gain"])
}
if _, err := syn.GetSignal(forked.Name); err != nil {
t.Errorf("forked signal not registered: %v", err)
}
}
+230
View File
@@ -0,0 +1,230 @@
package dsp
import (
"errors"
"fmt"
"math"
)
// This file holds ArrayNode ops: those that operate natively on waveform
// (float64 array) Samples — reductions (array→scalar), producers (array→array),
// and element access. Each also implements the legacy scalar Node interface
// (treating a scalar as a single-element array) so it remains usable from the
// scalar eval path.
// reductionProcess adapts a scalar Process call to a reduction ArrayNode.
func reductionProcess(n ArrayNode, in []float64, st map[string]any) (float64, error) {
s, err := n.ProcessSample(scalarInputs(in), st)
if err != nil {
return 0, err
}
return s.F, nil
}
// ── IndexNode ───────────────────────────────────────────────────────────────
// IndexNode extracts element I of an array input (array→scalar).
type IndexNode struct{ I int }
func (n *IndexNode) Type() string { return "index" }
func (n *IndexNode) Process(in []float64, st map[string]any) (float64, error) {
return reductionProcess(n, in, st)
}
func (n *IndexNode) ProcessSample(in []Sample, _ map[string]any) (Sample, error) {
if len(in) == 0 {
return Sample{}, errors.New("index: no inputs")
}
arr := in[0].AsArray()
if n.I < 0 || n.I >= len(arr) {
return Sample{}, fmt.Errorf("index: %d out of range [0,%d)", n.I, len(arr))
}
return Scalar(arr[n.I]), nil
}
// ── SliceNode ───────────────────────────────────────────────────────────────
// SliceNode returns a sub-range [Start,End) of an array input (array→array),
// clamped to the array bounds. End <= 0 means "to the end".
type SliceNode struct{ Start, End int }
func (n *SliceNode) Type() string { return "slice" }
func (n *SliceNode) Process(in []float64, st map[string]any) (float64, error) {
s, err := n.ProcessSample(scalarInputs(in), st)
if err != nil {
return 0, err
}
if len(s.Arr) == 0 {
return 0, nil
}
return s.Arr[0], nil
}
func (n *SliceNode) ProcessSample(in []Sample, _ map[string]any) (Sample, error) {
if len(in) == 0 {
return Sample{}, errors.New("slice: no inputs")
}
arr := in[0].AsArray()
start := n.Start
if start < 0 {
start = 0
}
if start > len(arr) {
start = len(arr)
}
end := n.End
if end <= 0 || end > len(arr) {
end = len(arr)
}
if end < start {
end = start
}
out := make([]float64, end-start)
copy(out, arr[start:end])
return Array(out), nil
}
// ── reductions ────────────────────────────────────────────────────────────────
// SumNode sums an array input (array→scalar).
type SumNode struct{}
func (n *SumNode) Type() string { return "sum" }
func (n *SumNode) Process(in []float64, st map[string]any) (float64, error) {
return reductionProcess(n, in, st)
}
func (n *SumNode) ProcessSample(in []Sample, _ map[string]any) (Sample, error) {
if len(in) == 0 {
return Sample{}, errors.New("sum: no inputs")
}
var s float64
for _, v := range in[0].AsArray() {
s += v
}
return Scalar(s), nil
}
// MeanNode averages an array input (array→scalar).
type MeanNode struct{}
func (n *MeanNode) Type() string { return "mean" }
func (n *MeanNode) Process(in []float64, st map[string]any) (float64, error) {
return reductionProcess(n, in, st)
}
func (n *MeanNode) ProcessSample(in []Sample, _ map[string]any) (Sample, error) {
if len(in) == 0 {
return Sample{}, errors.New("mean: no inputs")
}
arr := in[0].AsArray()
if len(arr) == 0 {
return Scalar(0), nil
}
var s float64
for _, v := range arr {
s += v
}
return Scalar(s / float64(len(arr))), nil
}
// MinNode returns the minimum element of an array input (array→scalar).
type MinNode struct{}
func (n *MinNode) Type() string { return "min" }
func (n *MinNode) Process(in []float64, st map[string]any) (float64, error) {
return reductionProcess(n, in, st)
}
func (n *MinNode) ProcessSample(in []Sample, _ map[string]any) (Sample, error) {
if len(in) == 0 {
return Sample{}, errors.New("min: no inputs")
}
arr := in[0].AsArray()
if len(arr) == 0 {
return Scalar(0), nil
}
m := arr[0]
for _, v := range arr[1:] {
if v < m {
m = v
}
}
return Scalar(m), nil
}
// MaxNode returns the maximum element of an array input (array→scalar).
type MaxNode struct{}
func (n *MaxNode) Type() string { return "max" }
func (n *MaxNode) Process(in []float64, st map[string]any) (float64, error) {
return reductionProcess(n, in, st)
}
func (n *MaxNode) ProcessSample(in []Sample, _ map[string]any) (Sample, error) {
if len(in) == 0 {
return Sample{}, errors.New("max: no inputs")
}
arr := in[0].AsArray()
if len(arr) == 0 {
return Scalar(0), nil
}
m := arr[0]
for _, v := range arr[1:] {
if v > m {
m = v
}
}
return Scalar(m), nil
}
// LengthNode returns the element count of an array input (array→scalar).
type LengthNode struct{}
func (n *LengthNode) Type() string { return "length" }
func (n *LengthNode) Process(in []float64, st map[string]any) (float64, error) {
return reductionProcess(n, in, st)
}
func (n *LengthNode) ProcessSample(in []Sample, _ map[string]any) (Sample, error) {
if len(in) == 0 {
return Sample{}, errors.New("length: no inputs")
}
return Scalar(float64(len(in[0].AsArray()))), nil
}
// ── FFTNode ────────────────────────────────────────────────────────────────
// FFTNode computes the magnitude spectrum of an array input (array→array). The
// input is zero-padded to the next power of two; the output has that length and
// holds |X[k]| for each frequency bin.
type FFTNode struct{}
func (n *FFTNode) Type() string { return "fft" }
func (n *FFTNode) Process(in []float64, st map[string]any) (float64, error) {
s, err := n.ProcessSample(scalarInputs(in), st)
if err != nil {
return 0, err
}
if len(s.Arr) == 0 {
return 0, nil
}
return s.Arr[0], nil
}
func (n *FFTNode) ProcessSample(in []Sample, _ map[string]any) (Sample, error) {
if len(in) == 0 {
return Sample{}, errors.New("fft: no inputs")
}
return Array(fftMagnitude(in[0].AsArray())), nil
}
// fftMagnitude returns the magnitude spectrum of x, zero-padded to the next
// power of two. Returns an empty slice for empty input.
func fftMagnitude(x []float64) []float64 {
if len(x) == 0 {
return nil
}
n := nextPow2(len(x))
re := make([]float64, n)
im := make([]float64, n)
copy(re, x)
fftRadix2(re, im)
mag := make([]float64, n)
for i := range mag {
mag[i] = math.Hypot(re[i], im[i])
}
return mag
}
+213
View File
@@ -0,0 +1,213 @@
package dsp
import (
"math"
"testing"
)
func TestSampleRoundTrip(t *testing.T) {
s := Scalar(3.5)
if s.IsArray {
t.Error("Scalar should not be an array")
}
if s.Type() != ValScalar {
t.Errorf("Scalar type: want ValScalar, got %v", s.Type())
}
if s.AsAny() != 3.5 {
t.Errorf("Scalar AsAny: want 3.5, got %v", s.AsAny())
}
if got := s.AsArray(); len(got) != 1 || got[0] != 3.5 {
t.Errorf("Scalar AsArray: want [3.5], got %v", got)
}
a := Array([]float64{1, 2, 3})
if !a.IsArray {
t.Error("Array should be an array")
}
if a.Type() != ValArray {
t.Errorf("Array type: want ValArray, got %v", a.Type())
}
got, ok := a.AsAny().([]float64)
if !ok || len(got) != 3 {
t.Errorf("Array AsAny: want []float64 len 3, got %v", a.AsAny())
}
}
func TestApplyElementwiseAllScalar(t *testing.T) {
n := &AddNode{}
out, err := ApplyElementwise(n, []Sample{Scalar(2), Scalar(3)}, map[string]any{})
if err != nil {
t.Fatal(err)
}
if out.IsArray || out.F != 5 {
t.Errorf("all-scalar add: want scalar 5, got %v", out)
}
}
func TestApplyElementwiseBroadcast(t *testing.T) {
// array ⊕ scalar: scalar is a constant broadcast across the array.
n := &AddNode{}
out, err := ApplyElementwise(n, []Sample{Array([]float64{1, 2, 3}), Scalar(10)}, map[string]any{})
if err != nil {
t.Fatal(err)
}
if !out.IsArray {
t.Fatalf("array+scalar: want array, got %v", out)
}
want := []float64{11, 12, 13}
for i, v := range want {
if out.Arr[i] != v {
t.Errorf("array+scalar[%d]: want %v, got %v", i, v, out.Arr[i])
}
}
}
func TestApplyElementwiseArrayArray(t *testing.T) {
n := &MultiplyNode{}
out, err := ApplyElementwise(n, []Sample{Array([]float64{1, 2, 3}), Array([]float64{4, 5, 6})}, map[string]any{})
if err != nil {
t.Fatal(err)
}
want := []float64{4, 10, 18}
for i, v := range want {
if out.Arr[i] != v {
t.Errorf("array*array[%d]: want %v, got %v", i, v, out.Arr[i])
}
}
}
func TestApplyElementwiseLengthMismatch(t *testing.T) {
n := &AddNode{}
_, err := ApplyElementwise(n, []Sample{Array([]float64{1, 2}), Array([]float64{1, 2, 3})}, map[string]any{})
if err == nil {
t.Error("expected length-mismatch error")
}
}
func TestReductionNodes(t *testing.T) {
arr := []Sample{Array([]float64{2, 4, 6, 8})}
cases := []struct {
name string
node ArrayNode
want float64
}{
{"sum", &SumNode{}, 20},
{"mean", &MeanNode{}, 5},
{"min", &MinNode{}, 2},
{"max", &MaxNode{}, 8},
{"length", &LengthNode{}, 4},
{"index", &IndexNode{I: 2}, 6},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
out, err := tc.node.ProcessSample(arr, map[string]any{})
if err != nil {
t.Fatal(err)
}
if out.IsArray || out.F != tc.want {
t.Errorf("%s: want scalar %v, got %v", tc.name, tc.want, out)
}
})
}
}
func TestIndexNodeOutOfRange(t *testing.T) {
n := &IndexNode{I: 9}
_, err := n.ProcessSample([]Sample{Array([]float64{1, 2, 3})}, map[string]any{})
if err == nil {
t.Error("expected out-of-range error")
}
}
func TestSliceNode(t *testing.T) {
n := &SliceNode{Start: 1, End: 3}
out, err := n.ProcessSample([]Sample{Array([]float64{10, 20, 30, 40})}, map[string]any{})
if err != nil {
t.Fatal(err)
}
want := []float64{20, 30}
if len(out.Arr) != len(want) {
t.Fatalf("slice: want len %d, got %d", len(want), len(out.Arr))
}
for i, v := range want {
if out.Arr[i] != v {
t.Errorf("slice[%d]: want %v, got %v", i, v, out.Arr[i])
}
}
}
func TestFFTMagnitude(t *testing.T) {
// A constant signal has all energy in bin 0 (the DC term equals the sum).
x := []float64{1, 1, 1, 1}
mag := fftMagnitude(x)
if len(mag) != 4 {
t.Fatalf("fft len: want 4, got %d", len(mag))
}
if math.Abs(mag[0]-4) > 1e-9 {
t.Errorf("fft DC bin: want 4, got %v", mag[0])
}
for k := 1; k < len(mag); k++ {
if math.Abs(mag[k]) > 1e-9 {
t.Errorf("fft bin %d: want ~0, got %v", k, mag[k])
}
}
}
func TestFFTSingleTone(t *testing.T) {
// One full cycle of a cosine over 8 samples → energy in bins 1 and N-1.
n := 8
x := make([]float64, n)
for i := range x {
x[i] = math.Cos(2 * math.Pi * float64(i) / float64(n))
}
mag := fftMagnitude(x)
if math.Abs(mag[1]-float64(n)/2) > 1e-6 {
t.Errorf("fft tone bin 1: want %v, got %v", float64(n)/2, mag[1])
}
if math.Abs(mag[n-1]-float64(n)/2) > 1e-6 {
t.Errorf("fft tone bin %d: want %v, got %v", n-1, float64(n)/2, mag[n-1])
}
}
func TestOpOutputType(t *testing.T) {
cases := []struct {
op string
in []ValType
want ValType
wantErr bool
}{
// reductions → scalar regardless of input
{"sum", []ValType{ValArray}, ValScalar, false},
{"mean", []ValType{ValScalar}, ValScalar, false},
{"index", []ValType{ValUnknown}, ValScalar, false},
// array producers require array, yield array
{"fft", []ValType{ValArray}, ValArray, false},
{"slice", []ValType{ValUnknown}, ValArray, false},
{"fft", []ValType{ValScalar}, ValUnknown, true},
// scalar-only reject arrays
{"moving_average", []ValType{ValScalar}, ValScalar, false},
{"lua", []ValType{ValArray}, ValUnknown, true},
{"rms", []ValType{ValUnknown}, ValScalar, false},
// elementwise: array if any array, scalar if all scalar, else unknown
{"add", []ValType{ValScalar, ValScalar}, ValScalar, false},
{"add", []ValType{ValArray, ValScalar}, ValArray, false},
{"gain", []ValType{ValUnknown}, ValUnknown, false},
{"expr", []ValType{ValArray, ValScalar}, ValArray, false},
}
for _, tc := range cases {
got, err := OpOutputType(tc.op, tc.in)
if tc.wantErr {
if err == nil {
t.Errorf("OpOutputType(%q,%v): expected error", tc.op, tc.in)
}
continue
}
if err != nil {
t.Errorf("OpOutputType(%q,%v): unexpected error %v", tc.op, tc.in, err)
continue
}
if got != tc.want {
t.Errorf("OpOutputType(%q,%v): want %v, got %v", tc.op, tc.in, tc.want, got)
}
}
}
+58
View File
@@ -0,0 +1,58 @@
package dsp
import "math"
// nextPow2 returns the smallest power of two >= n (and at least 1).
func nextPow2(n int) int {
p := 1
for p < n {
p <<= 1
}
return p
}
// fftRadix2 computes the in-place iterative radix-2 Cooley-Tukey FFT of the
// complex signal held in re/im. len(re) == len(im) must be a power of two. The
// transform overwrites re/im with the frequency-domain result. This is a small
// self-contained implementation (no external dependency) used by the synthetic
// fft op.
func fftRadix2(re, im []float64) {
n := len(re)
if n <= 1 {
return
}
// Bit-reversal permutation.
for i, j := 1, 0; i < n; i++ {
bit := n >> 1
for ; j&bit != 0; bit >>= 1 {
j ^= bit
}
j ^= bit
if i < j {
re[i], re[j] = re[j], re[i]
im[i], im[j] = im[j], im[i]
}
}
// Danielson-Lanczos butterflies.
for length := 2; length <= n; length <<= 1 {
ang := -2 * math.Pi / float64(length)
wReal, wImag := math.Cos(ang), math.Sin(ang)
for i := 0; i < n; i += length {
curReal, curImag := 1.0, 0.0
half := length >> 1
for k := 0; k < half; k++ {
a := i + k
b := i + k + half
tReal := curReal*re[b] - curImag*im[b]
tImag := curReal*im[b] + curImag*re[b]
re[b] = re[a] - tReal
im[b] = im[a] - tImag
re[a] += tReal
im[a] += tImag
curReal, curImag = curReal*wReal-curImag*wImag, curReal*wImag+curImag*wReal
}
}
}
}
+21 -10
View File
@@ -277,16 +277,28 @@ func (n *ThresholdNode) Process(inputs []float64, _ map[string]any) (float64, er
// ── ExprNode ────────────────────────────────────────────────────────────────── // ── ExprNode ──────────────────────────────────────────────────────────────────
// ExprNode evaluates a simple arithmetic expression with variables a, b, c, d // ExprNode evaluates a simple arithmetic expression. Inputs are bound to named
// bound to inputs[0..3]. It uses a hand-written recursive descent parser. // variables: Vars[i] -> inputs[i]. When Vars is empty it defaults to a, b, c, d
// (bound to inputs[0..3]) for backward compatibility. It uses a hand-written
// recursive descent parser.
type ExprNode struct { type ExprNode struct {
Expr string Expr string
Vars []string
}
// defaultVarNames returns the variable names for an expr/lua node: the explicit
// list when set, otherwise the legacy a,b,c,d.
func defaultVarNames(vars []string) []string {
if len(vars) > 0 {
return vars
}
return []string{"a", "b", "c", "d"}
} }
func (n *ExprNode) Type() string { return "expr" } func (n *ExprNode) Type() string { return "expr" }
func (n *ExprNode) Process(inputs []float64, _ map[string]any) (float64, error) { func (n *ExprNode) Process(inputs []float64, _ map[string]any) (float64, error) {
vars := map[string]float64{} vars := map[string]float64{}
names := []string{"a", "b", "c", "d"} names := defaultVarNames(n.Vars)
for i, name := range names { for i, name := range names {
if i < len(inputs) { if i < len(inputs) {
vars[name] = inputs[i] vars[name] = inputs[i]
@@ -507,13 +519,10 @@ func (p *exprParser) parseCall() (float64, error) {
} }
} }
// Not a function call — must be a single-letter variable. // Not a function call — must be a declared input variable.
if len(name) != 1 {
return 0, fmt.Errorf("unknown identifier %q (use ad for variables, or a known function name)", name)
}
val, ok := p.vars[name] val, ok := p.vars[name]
if !ok { if !ok {
return 0, fmt.Errorf("unknown variable %q (allowed: a, b, c, d)", name) return 0, fmt.Errorf("unknown variable %q (declare it as a named input)", name)
} }
return val, nil return val, nil
} }
@@ -628,10 +637,12 @@ func (n *LowPassNode) Process(inputs []float64, state map[string]any) (float64,
// ── LuaNode ─────────────────────────────────────────────────────────────────── // ── LuaNode ───────────────────────────────────────────────────────────────────
// LuaNode runs a Lua script in a sandboxed gopher-lua VM. // LuaNode runs a Lua script in a sandboxed gopher-lua VM.
// Inputs are bound to globals a, b, c, d. The script's return value is the output. // Inputs are bound to globals named by Vars (Vars[i] -> inputs[i]); when Vars is
// empty it defaults to a, b, c, d. The script's return value is the output.
// The os, io, package, and debug libraries are disabled. // The os, io, package, and debug libraries are disabled.
type LuaNode struct { type LuaNode struct {
Script string Script string
Vars []string
} }
func (n *LuaNode) Type() string { return "lua" } func (n *LuaNode) Type() string { return "lua" }
@@ -682,7 +693,7 @@ func (n *LuaNode) Process(inputs []float64, state map[string]any) (result float6
L.SetTop(0) L.SetTop(0)
// Bind inputs. // Bind inputs.
names := []string{"a", "b", "c", "d"} names := defaultVarNames(n.Vars)
for i, name := range names { for i, name := range names {
if i < len(inputs) { if i < len(inputs) {
L.SetGlobal(name, lua.LNumber(inputs[i])) L.SetGlobal(name, lua.LNumber(inputs[i]))
+151
View File
@@ -0,0 +1,151 @@
package dsp
import "fmt"
// ValType is the data type of a Sample: a scalar float, a float array
// (waveform), or — at graph-compile time, before a source's real type is
// known — unknown.
type ValType uint8
const (
ValUnknown ValType = iota
ValScalar
ValArray
)
func (t ValType) String() string {
switch t {
case ValScalar:
return "scalar"
case ValArray:
return "array"
default:
return "unknown"
}
}
// Sample is a value flowing through the synthetic DSP graph: either a scalar
// float64 or a float64 array (waveform). It is the array-aware counterpart of
// the bare float64 the legacy scalar Node interface uses.
type Sample struct {
F float64
Arr []float64
IsArray bool
}
// Scalar wraps a float64 as a scalar Sample.
func Scalar(f float64) Sample { return Sample{F: f} }
// Array wraps a []float64 as an array Sample.
func Array(a []float64) Sample { return Sample{Arr: a, IsArray: true} }
// Type reports whether the sample is a scalar or an array.
func (s Sample) Type() ValType {
if s.IsArray {
return ValArray
}
return ValScalar
}
// AsAny returns the value in the form datasource.Value.Data expects: a
// []float64 for arrays, a float64 for scalars.
func (s Sample) AsAny() any {
if s.IsArray {
return s.Arr
}
return s.F
}
// AsArray returns the sample's data as a slice: the array itself, or a
// single-element slice for a scalar. Used by reductions that accept either.
func (s Sample) AsArray() []float64 {
if s.IsArray {
return s.Arr
}
return []float64{s.F}
}
// ArrayNode is an optional extension of Node implemented by ops that operate
// natively on Samples (reductions array→scalar, producers array→array, etc.).
// eval prefers ProcessSample when a node implements it.
type ArrayNode interface {
Node
ProcessSample(inputs []Sample, state map[string]any) (Sample, error)
}
// statelessElementwise lists scalar ops that are safe to broadcast element-wise
// over array inputs: they hold no per-evaluation state, so running the legacy
// Process once per array lane is well-defined. Stateful ops (moving_average,
// rms, lowpass, derivative, integrate) and lua are excluded — a single shared
// state map cannot be meaningfully split across lanes.
var statelessElementwise = map[string]bool{
"gain": true, "offset": true, "add": true, "subtract": true,
"multiply": true, "divide": true, "clamp": true, "threshold": true,
"expr": true,
}
// StatelessElementwise reports whether a scalar op type may be broadcast over
// array inputs via ApplyElementwise.
func StatelessElementwise(nodeType string) bool { return statelessElementwise[nodeType] }
// scalarInputs wraps a legacy float64 input slice as scalar Samples.
func scalarInputs(in []float64) []Sample {
out := make([]Sample, len(in))
for i, v := range in {
out[i] = Scalar(v)
}
return out
}
// ApplyElementwise runs a stateless scalar Node over Sample inputs. If every
// input is scalar it calls Process once and wraps the result. If any input is
// an array it broadcasts: scalar inputs act as constants, all array inputs must
// share a common length (else an error), and Process is invoked once per index.
//
// The node MUST be stateless (see StatelessElementwise) — a shared state map
// cannot be split across array lanes.
func ApplyElementwise(n Node, inputs []Sample, state map[string]any) (Sample, error) {
// Determine the array length, if any input is an array.
length := -1
for _, s := range inputs {
if !s.IsArray {
continue
}
if length == -1 {
length = len(s.Arr)
} else if len(s.Arr) != length {
return Sample{}, fmt.Errorf("%s: array length mismatch (%d vs %d)", n.Type(), length, len(s.Arr))
}
}
if length == -1 {
// All scalar — single legacy call.
row := make([]float64, len(inputs))
for i, s := range inputs {
row[i] = s.F
}
r, err := n.Process(row, state)
if err != nil {
return Sample{}, err
}
return Scalar(r), nil
}
out := make([]float64, length)
row := make([]float64, len(inputs))
for i := 0; i < length; i++ {
for j, s := range inputs {
if s.IsArray {
row[j] = s.Arr[i]
} else {
row[j] = s.F
}
}
r, err := n.Process(row, state)
if err != nil {
return Sample{}, err
}
out[i] = r
}
return Array(out), nil
}
+74
View File
@@ -0,0 +1,74 @@
package dsp
import "fmt"
// Op type categories for static (compile-time / editor) type propagation.
// These mirror the runtime dispatch in the synthetic graph evaluator and the
// frontend's inferNodeTypes (web/src/lib/synthTypes.ts) — keep the three in
// sync; a parity test guards the Go/TS pair.
var (
// reductionOps collapse an array (or scalar) to a single scalar.
reductionOps = map[string]bool{
"index": true, "length": true, "sum": true,
"mean": true, "min": true, "max": true,
}
// arrayProducerOps require an array input and yield an array.
arrayProducerOps = map[string]bool{
"fft": true, "slice": true,
}
// scalarOnlyOps reject array inputs and yield a scalar. Stateful filters
// plus lua (whose state/closure cannot be broadcast per array lane).
scalarOnlyOps = map[string]bool{
"moving_average": true, "rms": true, "lowpass": true,
"derivative": true, "integrate": true, "lua": true,
}
)
// OpOutputType reports the output ValType of an op given its input types, and
// an error if the inputs are definitely incompatible with the op. Inputs may be
// ValUnknown (a source whose real type is not yet known at compile time); such
// inputs never trigger an error — runtime Sample typing is authoritative.
func OpOutputType(op string, in []ValType) (ValType, error) {
switch {
case reductionOps[op]:
return ValScalar, nil
case arrayProducerOps[op]:
for _, t := range in {
if t == ValScalar {
return ValUnknown, fmt.Errorf("%s requires an array input", op)
}
}
return ValArray, nil
case scalarOnlyOps[op]:
for _, t := range in {
if t == ValArray {
return ValUnknown, fmt.Errorf("%s does not accept an array input", op)
}
}
return ValScalar, nil
default:
// Elementwise stateless ops (gain, offset, add, subtract, multiply,
// divide, clamp, threshold, expr): array if any input is an array,
// scalar if all inputs are definitely scalar, otherwise unknown.
anyArray, anyUnknown := false, false
for _, t := range in {
switch t {
case ValArray:
anyArray = true
case ValUnknown:
anyUnknown = true
}
}
switch {
case anyArray:
return ValArray, nil
case anyUnknown:
return ValUnknown, nil
default:
return ValScalar, nil
}
}
}
+199
View File
@@ -0,0 +1,199 @@
package server
import (
"context"
"encoding/json"
"log/slog"
"strconv"
"strings"
"sync"
"github.com/uopi/uopi/internal/access"
"github.com/uopi/uopi/internal/audit"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/controllogic"
"github.com/uopi/uopi/internal/datasource"
)
// DialogHub fans control-logic dialog requests out to connected WebSocket
// clients whose identity matches the dialog's user/group filter, and routes
// input responses back to the dialog's target server variable.
//
// It implements controllogic.Notifier; the engine calls Notify when an
// action.dialog node runs. Input dialogs are remembered as pending so a later
// dialogResponse can be correlated by id and validated against its recipient
// filter — this is why panels can write the response target even though direct
// srv writes are otherwise gated to control-logic editors.
type DialogHub struct {
broker *broker.Broker
policy *access.Policy
audit audit.Recorder
log *slog.Logger
mu sync.Mutex
clients map[*wsClient]struct{}
pending map[string]controllogic.Dialog // input dialogs awaiting a response
}
// NewDialogHub builds an empty hub. rec is never nil after construction.
func NewDialogHub(brk *broker.Broker, policy *access.Policy, rec audit.Recorder, log *slog.Logger) *DialogHub {
if rec == nil {
rec = audit.Nop()
}
return &DialogHub{
broker: brk,
policy: policy,
audit: rec,
log: log,
clients: map[*wsClient]struct{}{},
pending: map[string]controllogic.Dialog{},
}
}
func (h *DialogHub) add(c *wsClient) {
h.mu.Lock()
h.clients[c] = struct{}{}
h.mu.Unlock()
}
func (h *DialogHub) remove(c *wsClient) {
h.mu.Lock()
delete(h.clients, c)
h.mu.Unlock()
}
// dialogOut is the client-bound JSON form of a dialog request.
type dialogOut struct {
Type string `json:"type"` // always "dialog"
ID string `json:"id"`
Kind string `json:"kind"` // "info" | "error" | "input"
Title string `json:"title,omitempty"`
Message string `json:"message,omitempty"`
}
// Notify implements controllogic.Notifier: serialise the dialog and push it to
// every connected client matching its user/group filter.
func (h *DialogHub) Notify(d controllogic.Dialog) {
b, err := json.Marshal(dialogOut{
Type: "dialog",
ID: d.ID,
Kind: d.Kind,
Title: d.Title,
Message: d.Message,
})
if err != nil {
return
}
h.mu.Lock()
if d.Kind == "input" && strings.TrimSpace(d.Target) != "" {
h.pending[d.ID] = d
}
var targets []*wsClient
for c := range h.clients {
if h.matches(c.user, d) {
targets = append(targets, c)
}
}
h.mu.Unlock()
for _, c := range targets {
select {
case c.outCh <- b:
default:
}
}
}
// matches reports whether user is a recipient of d. An empty user+group filter
// targets everyone; otherwise the user must be named or in a named group.
func (h *DialogHub) matches(user string, d controllogic.Dialog) bool {
if len(d.Users) == 0 && len(d.Groups) == 0 {
return true
}
if h.policy != nil {
user = h.policy.ResolveUser(user)
}
for _, u := range d.Users {
if u == user {
return true
}
}
if len(d.Groups) > 0 && h.policy != nil {
groups := map[string]bool{}
for _, g := range h.policy.GroupsOf(user) {
groups[g] = true
}
for _, g := range d.Groups {
if groups[g] {
return true
}
}
}
return false
}
// cancel drops a pending input dialog without writing (user dismissed it).
func (h *DialogHub) cancel(id string) {
h.mu.Lock()
delete(h.pending, id)
h.mu.Unlock()
}
// respond writes an input dialog's response to its target server variable. The
// dialog must be pending and the responding client must have been a recipient;
// this gate replaces the usual srv write-permission check for sanctioned
// control-logic responses.
func (h *DialogHub) respond(ctx context.Context, c *wsClient, id string, value float64) {
h.mu.Lock()
d, ok := h.pending[id]
if ok {
delete(h.pending, id)
}
h.mu.Unlock()
if !ok || !h.matches(c.user, d) {
return
}
ds, name, ok := parseDialogTarget(d.Target)
if !ok || ds == "local" {
return
}
src, ok := h.broker.Source(ds)
if !ok {
h.log.Warn("dialog response: unknown data source", "ds", ds, "target", d.Target)
return
}
ev := audit.Event{
Actor: c.user,
ActorType: audit.ActorUser,
Action: "signal.write",
DS: ds,
Signal: name,
Value: strconv.FormatFloat(value, 'g', -1, 64),
Detail: "control logic dialog response",
IP: c.ip,
Outcome: audit.OutcomeOK,
}
wctx := datasource.WithUser(ctx, c.user)
if err := src.Write(wctx, name, value); err != nil {
h.log.Warn("dialog response: write failed", "ds", ds, "signal", name, "err", err)
ev.Outcome = audit.OutcomeError
ev.Error = err.Error()
}
h.audit.Record(ev)
}
// parseDialogTarget splits a "ds:name" dialog target on the first ':'. A bare
// name (no ':') defaults to the persistent server-variable source "srv".
func parseDialogTarget(t string) (ds, name string, ok bool) {
t = strings.TrimSpace(t)
if t == "" {
return "", "", false
}
if i := strings.IndexByte(t, ':'); i >= 0 {
return t[:i], t[i+1:], true
}
return "srv", t, true
}
+8 -3
View File
@@ -10,7 +10,9 @@ import (
"github.com/uopi/uopi/internal/access" "github.com/uopi/uopi/internal/access"
"github.com/uopi/uopi/internal/api" "github.com/uopi/uopi/internal/api"
"github.com/uopi/uopi/internal/audit"
"github.com/uopi/uopi/internal/broker" "github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/confmgr"
"github.com/uopi/uopi/internal/controllogic" "github.com/uopi/uopi/internal/controllogic"
"github.com/uopi/uopi/internal/datasource/synthetic" "github.com/uopi/uopi/internal/datasource/synthetic"
"github.com/uopi/uopi/internal/metrics" "github.com/uopi/uopi/internal/metrics"
@@ -27,7 +29,10 @@ type Server struct {
// New creates the HTTP server, registers all routes, and returns a ready-to-start Server. // New creates the HTTP server, registers all routes, and returns a ready-to-start Server.
// synth may be nil if the synthetic data source is not enabled. // synth may be nil if the synthetic data source is not enabled.
func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, policy *access.Policy, acl *panelacl.Store, ctrlLogic *controllogic.Store, ctrlEngine *controllogic.Engine, channelFinderURL, archiverURL, trustedUserHeader string, log *slog.Logger) *Server { func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, cfgStore *confmgr.Store, policy *access.Policy, acl *panelacl.Store, ctrlLogic *controllogic.Store, ctrlEngine *controllogic.Engine, dialogs *DialogHub, rec audit.Recorder, channelFinderURL, archiverURL, trustedUserHeader string, log *slog.Logger) *Server {
if rec == nil {
rec = audit.Nop()
}
mux := http.NewServeMux() mux := http.NewServeMux()
// Health check // Health check
@@ -37,7 +42,7 @@ func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Syntheti
}) })
// WebSocket endpoint // WebSocket endpoint
mux.Handle("/ws", &wsHandler{broker: brk, log: log, userHeader: trustedUserHeader, policy: policy}) mux.Handle("/ws", &wsHandler{broker: brk, log: log, userHeader: trustedUserHeader, policy: policy, audit: rec, dialogs: dialogs})
// Prometheus-format metrics // Prometheus-format metrics
mux.HandleFunc("/metrics", metrics.Handler(brk.ActiveSubscriptions)) mux.HandleFunc("/metrics", metrics.Handler(brk.ActiveSubscriptions))
@@ -45,7 +50,7 @@ func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Syntheti
// REST API — registered on a dedicated mux so it can be wrapped with the // REST API — registered on a dedicated mux so it can be wrapped with the
// access-control middleware (identity resolution + global level enforcement). // access-control middleware (identity resolution + global level enforcement).
apiMux := http.NewServeMux() apiMux := http.NewServeMux()
api.New(brk, synth, store, policy, acl, ctrlLogic, ctrlEngine, channelFinderURL, archiverURL, log).Register(apiMux, apiPrefix) api.New(brk, synth, store, cfgStore, policy, acl, ctrlLogic, ctrlEngine, rec, channelFinderURL, archiverURL, log).Register(apiMux, apiPrefix)
mux.Handle(apiPrefix+"/", accessMiddleware(policy, trustedUserHeader, apiMux)) mux.Handle(apiPrefix+"/", accessMiddleware(policy, trustedUserHeader, apiMux))
// Embedded frontend — must be last (catch-all) // Embedded frontend — must be last (catch-all)
+119
View File
@@ -5,7 +5,9 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"log/slog" "log/slog"
"net"
"net/http" "net/http"
"strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -13,6 +15,7 @@ import (
"github.com/coder/websocket" "github.com/coder/websocket"
"github.com/uopi/uopi/internal/access" "github.com/uopi/uopi/internal/access"
"github.com/uopi/uopi/internal/audit"
"github.com/uopi/uopi/internal/broker" "github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/datasource" "github.com/uopi/uopi/internal/datasource"
"github.com/uopi/uopi/internal/metrics" "github.com/uopi/uopi/internal/metrics"
@@ -31,6 +34,9 @@ type inMsg struct {
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
Value json.RawMessage `json:"value,omitempty"` Value json.RawMessage `json:"value,omitempty"`
// dialogResponse — id of the control-logic dialog being answered.
ID string `json:"id,omitempty"`
// history // history
Start time.Time `json:"start"` Start time.Time `json:"start"`
End time.Time `json:"end"` End time.Time `json:"end"`
@@ -53,6 +59,8 @@ type outMsg struct {
TS string `json:"ts,omitempty"` TS string `json:"ts,omitempty"`
Value any `json:"value,omitempty"` Value any `json:"value,omitempty"`
Quality string `json:"quality,omitempty"` Quality string `json:"quality,omitempty"`
Severity int `json:"severity,omitempty"` // raw EPICS alarm severity (0=NO_ALARM)
Status int `json:"status,omitempty"` // raw EPICS alarm status
// meta // meta
Meta *metaPayload `json:"meta,omitempty"` Meta *metaPayload `json:"meta,omitempty"`
@@ -94,6 +102,10 @@ type wsHandler struct {
userHeader string userHeader string
// policy enforces the global access level (blacklist) on signal writes. // policy enforces the global access level (blacklist) on signal writes.
policy *access.Policy policy *access.Policy
// audit records signal writes (never nil; audit.Nop when disabled).
audit audit.Recorder
// dialogs fans control-logic dialogs to clients and routes responses.
dialogs *DialogHub
} }
func (h *wsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (h *wsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -103,6 +115,12 @@ func (h *wsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if h.userHeader != "" { if h.userHeader != "" {
user = strings.TrimSpace(r.Header.Get(h.userHeader)) user = strings.TrimSpace(r.Header.Get(h.userHeader))
} }
// Resolve to the configured default_user when the header is absent so the
// session carries a real identity (used for audit + EPICS write attribution).
if h.policy != nil {
user = h.policy.ResolveUser(user)
}
clientIP := clientIP(r)
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{ conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
// Permissive origin check for LAN / SSH-tunnel use; tighten when auth lands. // Permissive origin check for LAN / SSH-tunnel use; tighten when auth lands.
@@ -118,17 +136,31 @@ func (h *wsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithCancel(r.Context()) ctx, cancel := context.WithCancel(r.Context())
defer cancel() defer cancel()
rec := h.audit
if rec == nil {
rec = audit.Nop()
}
c := &wsClient{ c := &wsClient{
conn: conn, conn: conn,
broker: h.broker, broker: h.broker,
user: user, user: user,
ip: clientIP,
policy: h.policy, policy: h.policy,
audit: rec,
dialogs: h.dialogs,
outCh: make(chan []byte, 512), outCh: make(chan []byte, 512),
updateCh: make(chan broker.Update, 1024), updateCh: make(chan broker.Update, 1024),
subs: make(map[broker.SignalRef]func()), subs: make(map[broker.SignalRef]func()),
log: h.log, log: h.log,
} }
// Register for control-logic dialog pushes for this client's identity.
if h.dialogs != nil {
h.dialogs.add(c)
defer h.dialogs.remove(c)
}
var wg sync.WaitGroup var wg sync.WaitGroup
wg.Add(3) wg.Add(3)
go func() { defer wg.Done(); c.readLoop(ctx, cancel) }() go func() { defer wg.Done(); c.readLoop(ctx, cancel) }()
@@ -153,7 +185,10 @@ type wsClient struct {
broker *broker.Broker broker *broker.Broker
log *slog.Logger log *slog.Logger
user string // end-user identity from the trusted proxy header ("" if none) user string // end-user identity from the trusted proxy header ("" if none)
ip string // client address, for audit attribution
policy *access.Policy // global access-level enforcement policy *access.Policy // global access-level enforcement
audit audit.Recorder // signal-write audit recorder (never nil)
dialogs *DialogHub // control-logic dialog fan-out (nil if disabled)
outCh chan []byte // serialised outgoing messages outCh chan []byte // serialised outgoing messages
updateCh chan broker.Update // raw updates from the broker updateCh chan broker.Update // raw updates from the broker
@@ -196,6 +231,8 @@ func (c *wsClient) dispatchLoop(ctx context.Context) {
TS: u.Value.Timestamp.UTC().Format(time.RFC3339Nano), TS: u.Value.Timestamp.UTC().Format(time.RFC3339Nano),
Value: u.Value.Data, Value: u.Value.Data,
Quality: u.Value.Quality.String(), Quality: u.Value.Quality.String(),
Severity: u.Value.Severity,
Status: u.Value.Status,
} }
b, err := json.Marshal(msg) b, err := json.Marshal(msg)
if err != nil { if err != nil {
@@ -248,6 +285,8 @@ func (c *wsClient) handleMessage(ctx context.Context, data []byte) {
c.handleWrite(ctx, msg) c.handleWrite(ctx, msg)
case "history": case "history":
c.handleHistory(ctx, msg) c.handleHistory(ctx, msg)
case "dialogResponse":
c.handleDialogResponse(ctx, msg)
default: default:
c.sendError(ctx, "UNKNOWN_TYPE", "unknown message type: "+msg.Type) c.sendError(ctx, "UNKNOWN_TYPE", "unknown message type: "+msg.Type)
} }
@@ -303,6 +342,14 @@ func (c *wsClient) handleWrite(ctx context.Context, msg inMsg) {
return return
} }
// Server variables are read-any but write only for control-logic editors, so a
// panel cannot mutate sequence state unless its user owns control-logic access.
if msg.DS == "srv" && c.policy != nil && !c.policy.CanEditLogic(c.policy.ResolveUser(c.user)) {
c.log.Warn("write: server variable denied", "name", msg.Name, "user", c.user)
c.sendError(ctx, "ACCESS_DENIED", "writing server variables requires control-logic access")
return
}
ds, ok := c.broker.Source(msg.DS) ds, ok := c.broker.Source(msg.DS)
if !ok { if !ok {
c.log.Warn("write: unknown data source", "ds", msg.DS, "name", msg.Name) c.log.Warn("write: unknown data source", "ds", msg.DS, "name", msg.Name)
@@ -336,10 +383,44 @@ func (c *wsClient) handleWrite(ctx context.Context, msg inMsg) {
// Attribute the write to the connecting end-user so data sources (EPICS) can // Attribute the write to the connecting end-user so data sources (EPICS) can
// act under that identity rather than the server's. // act under that identity rather than the server's.
ctx = datasource.WithUser(ctx, c.user) ctx = datasource.WithUser(ctx, c.user)
ev := audit.Event{
Actor: c.user,
ActorType: audit.ActorUser,
Action: "signal.write",
DS: msg.DS,
Signal: msg.Name,
Value: formatAuditValue(value),
IP: c.ip,
Outcome: audit.OutcomeOK,
}
if err := ds.Write(ctx, msg.Name, value); err != nil { if err := ds.Write(ctx, msg.Name, value); err != nil {
c.log.Warn("write: ds.Write failed", "ds", msg.DS, "name", msg.Name, "err", err) c.log.Warn("write: ds.Write failed", "ds", msg.DS, "name", msg.Name, "err", err)
ev.Outcome = audit.OutcomeError
ev.Error = err.Error()
c.audit.Record(ev)
c.sendError(ctx, "WRITE_ERROR", err.Error()) c.sendError(ctx, "WRITE_ERROR", err.Error())
return
} }
c.audit.Record(ev)
}
// handleDialogResponse routes the answer to a control-logic input dialog. An
// empty/null value means the user dismissed the dialog (drop it without
// writing); otherwise the numeric value is written to the dialog's target.
func (c *wsClient) handleDialogResponse(ctx context.Context, msg inMsg) {
if c.dialogs == nil || msg.ID == "" {
return
}
if len(msg.Value) == 0 || string(msg.Value) == "null" {
c.dialogs.cancel(msg.ID)
return
}
var num float64
if err := json.Unmarshal(msg.Value, &num); err != nil {
c.dialogs.cancel(msg.ID)
return
}
c.dialogs.respond(ctx, c, msg.ID, num)
} }
func (c *wsClient) handleHistory(ctx context.Context, msg inMsg) { func (c *wsClient) handleHistory(ctx context.Context, msg inMsg) {
@@ -416,6 +497,44 @@ func (c *wsClient) sendError(ctx context.Context, code, message string) {
// ── Helpers ─────────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────────
// clientIP returns the best-effort client address for audit attribution,
// preferring the X-Forwarded-For / X-Real-IP headers set by a reverse proxy and
// falling back to the TCP peer address.
func clientIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if i := strings.IndexByte(xff, ','); i >= 0 {
return strings.TrimSpace(xff[:i])
}
return strings.TrimSpace(xff)
}
if xr := r.Header.Get("X-Real-IP"); xr != "" {
return strings.TrimSpace(xr)
}
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
return host
}
return r.RemoteAddr
}
// formatAuditValue renders a written value as a compact string for the audit log.
func formatAuditValue(v any) string {
switch x := v.(type) {
case string:
return x
case float64:
return strconv.FormatFloat(x, 'g', -1, 64)
case bool:
return strconv.FormatBool(x)
case nil:
return ""
default:
if b, err := json.Marshal(v); err == nil {
return string(b)
}
return ""
}
}
func metadataToPayload(m datasource.Metadata) *metaPayload { func metadataToPayload(m datasource.Metadata) *metaPayload {
return &metaPayload{ return &metaPayload{
Type: dataTypeName(m.Type), Type: dataTypeName(m.Type),
+29 -2
View File
@@ -474,18 +474,45 @@ func setXMLAttribute(data []byte, attr, value string) ([]byte, error) {
return []byte(s[:valStart] + value + s[valStart+valEnd:]), nil return []byte(s[:valStart] + value + s[valStart+valEnd:]), nil
} }
// Delete removes the interface with the given ID. // Delete moves the interface with the given ID — and all of its version
// backups — into a timestamped trash folder so it can be recovered if needed.
// Nothing is destroyed; recovery is a matter of moving the files back.
func (s *Store) Delete(id string) error { func (s *Store) Delete(id string) error {
if err := validateID(id); err != nil { if err := validateID(id); err != nil {
return ErrNotFound return ErrNotFound
} }
err := os.Remove(s.filePath(id)) if _, err := os.Stat(s.filePath(id)); err != nil {
if errors.Is(err, os.ErrNotExist) { if errors.Is(err, os.ErrNotExist) {
return ErrNotFound return ErrNotFound
} }
return err return err
} }
trashDir := filepath.Join(s.rootDir, "trash", "interfaces", fmt.Sprintf("%s.%d", id, time.Now().UnixMilli()))
if err := os.MkdirAll(trashDir, 0o755); err != nil {
return fmt.Errorf("create trash dir: %w", err)
}
// Move the current file plus every versioned backup (id.vN.xml), preserving
// their original names so a restore is a straight move-back.
entries, err := os.ReadDir(s.dir)
if err != nil {
return err
}
for _, e := range entries {
name := e.Name()
if e.IsDir() {
continue
}
if name == id+".xml" || (strings.HasPrefix(name, id+".v") && isVersioned(name)) {
if err := os.Rename(filepath.Join(s.dir, name), filepath.Join(trashDir, name)); err != nil {
return fmt.Errorf("move %s to trash: %w", name, err)
}
}
}
return nil
}
// Clone duplicates an interface, assigning a new unique ID. // Clone duplicates an interface, assigning a new unique ID.
func (s *Store) Clone(srcID string) (string, error) { func (s *Store) Clone(srcID string) (string, error) {
if err := validateID(srcID); err != nil { if err := validateID(srcID); err != nil {
+2
View File
@@ -4,6 +4,7 @@ import { wsClient } from './lib/ws';
import ViewMode from './ViewMode'; import ViewMode from './ViewMode';
import EditMode from './EditMode'; import EditMode from './EditMode';
import FullscreenMode from './FullscreenMode'; import FullscreenMode from './FullscreenMode';
import ControlDialogs from './ControlDialogs';
import { applyZoom, getStoredZoom } from './ZoomControl'; import { applyZoom, getStoredZoom } from './ZoomControl';
import { AuthContext, DEFAULT_ME, fetchMe, canRead } from './lib/auth'; import { AuthContext, DEFAULT_ME, fetchMe, canRead } from './lib/auth';
import type { Interface, Me } from './lib/types'; import type { Interface, Me } from './lib/types';
@@ -69,6 +70,7 @@ export default function App() {
return ( return (
<AuthContext.Provider value={me}> <AuthContext.Provider value={me}>
<div class="app-root"> <div class="app-root">
<ControlDialogs />
{wsStatus === 'disconnected' && ( {wsStatus === 'disconnected' && (
<div class="connection-banner">WebSocket disconnected reconnecting</div> <div class="connection-banner">WebSocket disconnected reconnecting</div>
)} )}
+169
View File
@@ -0,0 +1,169 @@
import { h } from 'preact';
import { useState, useEffect, useCallback } from 'preact/hooks';
// AuditEvent mirrors the JSON returned by GET /api/v1/audit (internal/audit.Event).
interface AuditEvent {
time: string;
actor: string;
actorType: string; // "user" | "system"
action: string;
ds?: string;
signal?: string;
value?: string;
detail?: string;
ip?: string;
outcome: string; // "ok" | "error"
error?: string;
}
interface Props { onClose: () => void; }
/** Convert an RFC3339/ISO timestamp to a compact local datetime string. */
function fmtTime(iso: string): string {
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
/** Convert a datetime-local input value (local time) to an RFC3339 string. */
function localToRFC3339(v: string): string {
if (!v) return '';
const d = new Date(v);
return isNaN(d.getTime()) ? '' : d.toISOString();
}
export default function AuditViewer({ onClose }: Props) {
const [events, setEvents] = useState<AuditEvent[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Filters
const [user, setUser] = useState('');
const [action, setAction] = useState('');
const [signal, setSignal] = useState('');
const [start, setStart] = useState('');
const [end, setEnd] = useState('');
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const q = new URLSearchParams();
if (user.trim()) q.set('user', user.trim());
if (action) q.set('action', action);
if (signal.trim()) q.set('signal', signal.trim());
const s = localToRFC3339(start);
const e = localToRFC3339(end);
if (s) q.set('start', s);
if (e) q.set('end', e);
const res = await fetch(`/api/v1/audit?${q.toString()}`);
if (res.status === 403) throw new Error('You are not permitted to view the audit log.');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
setEvents(Array.isArray(data) ? data : []);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setEvents([]);
} finally {
setLoading(false);
}
}, [user, action, signal, start, end]);
// Initial load.
useEffect(() => { load(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
function resetFilters() {
setUser(''); setAction(''); setSignal(''); setStart(''); setEnd('');
}
return (
<div class="cl-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div class="cl-modal audit-modal">
<header class="cl-header">
<span class="cl-title">Audit log</span>
<span class="hint cl-subtitle">Every system-affecting action (signal writes, control-logic and interface changes).</span>
<div class="cl-header-actions">
<button class="panel-btn" disabled={loading} onClick={load}>{loading ? 'Loading…' : 'Refresh'}</button>
<button class="panel-btn" onClick={onClose}>Close</button>
</div>
</header>
<div class="audit-filters">
<input class="audit-filter" type="text" placeholder="User" value={user}
onInput={(e) => setUser((e.target as HTMLInputElement).value)} />
<select class="audit-filter" value={action}
onChange={(e) => setAction((e.target as HTMLSelectElement).value)}>
<option value="">All actions</option>
<option value="signal.write">signal.write</option>
<option value="interface.create">interface.create</option>
<option value="interface.update">interface.update</option>
<option value="interface.delete">interface.delete</option>
<option value="controllogic.create">controllogic.create</option>
<option value="controllogic.update">controllogic.update</option>
<option value="controllogic.delete">controllogic.delete</option>
</select>
<input class="audit-filter" type="text" placeholder="Signal contains…" value={signal}
onInput={(e) => setSignal((e.target as HTMLInputElement).value)} />
<label class="audit-filter-label">From
<input class="audit-filter" type="datetime-local" value={start}
onInput={(e) => setStart((e.target as HTMLInputElement).value)} />
</label>
<label class="audit-filter-label">To
<input class="audit-filter" type="datetime-local" value={end}
onInput={(e) => setEnd((e.target as HTMLInputElement).value)} />
</label>
<button class="panel-btn" disabled={loading} onClick={load}>Apply</button>
<button class="panel-btn" onClick={resetFilters}>Reset</button>
</div>
{error && <div class="cl-error">{error}</div>}
<div class="audit-body">
{!error && events.length === 0 && !loading && (
<div class="hint audit-empty">No matching audit entries.</div>
)}
{events.length > 0 && (
<table class="audit-table">
<thead>
<tr>
<th>Time</th>
<th>Actor</th>
<th>Action</th>
<th>Source</th>
<th>Signal / target</th>
<th>Value</th>
<th>Detail</th>
<th>Client</th>
<th>Result</th>
</tr>
</thead>
<tbody>
{events.map((ev, i) => (
<tr key={i} class={ev.outcome === 'error' ? 'audit-row-error' : ''}>
<td class="audit-time">{fmtTime(ev.time)}</td>
<td>
<span class={`audit-actor-tag audit-actor-${ev.actorType}`}>{ev.actorType}</span>
{' '}{ev.actor || '—'}
</td>
<td>{ev.action}</td>
<td>{ev.ds || '—'}</td>
<td class="audit-signal" title={ev.signal}>{ev.signal || '—'}</td>
<td class="audit-value" title={ev.value}>{ev.value ?? '—'}</td>
<td class="audit-detail" title={ev.detail}>{ev.detail || '—'}</td>
<td>{ev.ip || '—'}</td>
<td>
{ev.outcome === 'error'
? <span class="audit-result-err" title={ev.error}>error</span>
: <span class="audit-result-ok">ok</span>}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
</div>
);
}
+3 -1
View File
@@ -16,6 +16,7 @@ import Button from './widgets/Button';
import PlotWidget from './widgets/PlotWidget'; import PlotWidget from './widgets/PlotWidget';
import ImageWidget from './widgets/ImageWidget'; import ImageWidget from './widgets/ImageWidget';
import LinkWidget from './widgets/LinkWidget'; import LinkWidget from './widgets/LinkWidget';
import ConfigSelect from './widgets/ConfigSelect';
import ContextMenu from './ContextMenu'; import ContextMenu from './ContextMenu';
import InfoPanel from './InfoPanel'; import InfoPanel from './InfoPanel';
import SplitLayout from './SplitLayout'; import SplitLayout from './SplitLayout';
@@ -34,6 +35,7 @@ const COMPONENTS: Record<string, any> = {
plot: PlotWidget, plot: PlotWidget,
image: ImageWidget, image: ImageWidget,
link: LinkWidget, link: LinkWidget,
configselect: ConfigSelect,
}; };
interface CtxState { interface CtxState {
@@ -155,7 +157,7 @@ export default function Canvas({ iface, onNavigate, timeRange }: Props) {
return ( return (
<div class="canvas-container"> <div class="canvas-container">
<div class="canvas-area" style={`width:${iface.w}px;height:${iface.h}px;`}> <div class="canvas-area canvas-view-bare" style={`width:${iface.w}px;height:${iface.h}px;`}>
{iface.widgets.map(widget => { {iface.widgets.map(widget => {
const Comp = COMPONENTS[widget.type]; const Comp = COMPONENTS[widget.type];
const inner = Comp const inner = Comp
File diff suppressed because it is too large Load Diff
-95
View File
@@ -1,95 +0,0 @@
import { h } from 'preact';
import { useState, useRef, useEffect } from 'preact/hooks';
interface Props {
mode: 'view' | 'edit';
onOpenManual: (section?: string) => void;
}
const TIPS: Record<string, Array<{ text: string; section?: string }>> = {
view: [
{ text: 'Click any interface in the left panel to load it on the canvas.', section: 'view' },
{ text: 'Right-click a widget to view signal info or export data as CSV.', section: 'view' },
{ text: 'The ● Live button shows live streaming is active. Use the date pickers to load historical data.', section: 'history' },
{ text: 'Click Edit (top-right) to open the drag-and-drop panel builder.', section: 'edit' },
{ text: 'Use the Share button on a panel to grant access to users or groups.', section: 'sharing' },
{ text: 'The ⚙ Control logic button opens server-side automation that runs even with no panel open.', section: 'control' },
{ text: 'The dot in the status chip turns green when the WebSocket is connected.', section: 'view' },
],
edit: [
{ text: 'Drag a signal from the left tree onto the canvas to create a widget.', section: 'edit' },
{ text: 'Click a widget to select it, then adjust its properties on the right.', section: 'edit' },
{ text: 'Ctrl+click to select multiple widgets; then use the align toolbar.', section: 'edit' },
{ text: 'Ctrl+Z to undo, Ctrl+Y to redo. Up to 50 steps are remembered.', section: 'shortcuts' },
{ text: 'Use "+ Synthetic" in the signal tree to create computed/filtered signals.', section: 'signals' },
{ text: 'Switch to the Logic tab to add interactive behaviour with a node-graph flow editor.', section: 'logic' },
{ text: 'Add Local variables for set-points and counters that live inside the panel.', section: 'edit' },
{ text: 'Arrow keys nudge the selected widget by 1 px (Shift for grid size).', section: 'shortcuts' },
],
};
export default function ContextualHelp({ mode, onOpenManual }: Props) {
const [open, setOpen] = useState(false);
const [tipIndex, setTipIndex] = useState(0);
const ref = useRef<HTMLDivElement>(null);
const tips = TIPS[mode];
// Close on outside click
useEffect(() => {
if (!open) return;
function handler(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open]);
// Cycle to a new random tip each time the popover opens
function handleOpen() {
setTipIndex(t => (t + 1) % tips.length);
setOpen(o => !o);
}
const tip = tips[tipIndex];
return (
<div class="ctx-help" ref={ref}>
<button
class="ctx-help-btn"
onClick={handleOpen}
title="Contextual help"
aria-label="Open contextual help"
aria-expanded={open}
>
?
</button>
{open && (
<div class="ctx-help-popover" role="tooltip">
<div class="ctx-help-tip">
<span class="ctx-help-icon">💡</span>
<span>{tip.text}</span>
</div>
<div class="ctx-help-footer">
<button
class="ctx-help-nav"
onClick={() => setTipIndex(t => (t + 1) % tips.length)}
title="Next tip"
>
Next tip
</button>
<button
class="ctx-help-link"
onClick={() => { setOpen(false); onOpenManual(tip.section); }}
>
Open manual
</button>
</div>
</div>
)}
</div>
);
}
+60
View File
@@ -0,0 +1,60 @@
import { h, Fragment } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import { wsClient } from './lib/ws';
import { controlDialogs, dismissControlDialog, type ControlDialog } from './lib/controldialogs';
// Renders dialogs pushed by server-side control-logic action.dialog nodes over
// the WebSocket. Mounted once globally (App) since these are addressed to the
// connected user, independent of which panel is open. info/error dialogs are
// acknowledgements; input dialogs send a numeric response back to the server.
export default function ControlDialogs() {
const [reqs, setReqs] = useState<ControlDialog[]>([]);
useEffect(() => controlDialogs.subscribe(setReqs), []);
if (reqs.length === 0) return null;
return <Fragment>{reqs.map(r => <ControlDialogModal key={r.id} req={r} />)}</Fragment>;
}
function ControlDialogModal({ req }: { req: ControlDialog; key?: string }) {
const isInput = req.kind === 'input';
const [val, setVal] = useState('');
function ok() {
if (isInput) {
const n = parseFloat(val);
wsClient.sendDialogResponse(req.id, Number.isFinite(n) ? n : null);
}
dismissControlDialog(req.id);
}
function cancel() {
if (isInput) wsClient.sendDialogResponse(req.id, null);
dismissControlDialog(req.id);
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Enter') { e.preventDefault(); ok(); }
if (e.key === 'Escape') { e.preventDefault(); cancel(); }
}
return (
<div class="logic-dialog-backdrop" onKeyDown={onKey}>
<div class={`logic-dialog logic-dialog-${req.kind}`} role="dialog">
<div class="logic-dialog-header">
{req.title || (req.kind === 'error' ? 'Error' : isInput ? 'Input required' : 'Info')}
</div>
{req.message && <div class="logic-dialog-message">{req.message}</div>}
{isInput && (
<div class="logic-dialog-field">
<input class="prop-input logic-dialog-input" type="number"
autoFocus value={val}
onInput={(e) => setVal((e.target as HTMLInputElement).value)}
onKeyDown={onKey} />
</div>
)}
<div class="logic-dialog-actions">
{isInput && <button class="panel-btn" onClick={cancel}>Cancel</button>}
<button class="panel-btn panel-btn-primary" onClick={ok}>OK</button>
</div>
</div>
</div>
);
}
+318 -48
View File
@@ -1,8 +1,9 @@
import { h, Fragment } from 'preact'; import { h, Fragment } from 'preact';
import { useState, useEffect, useRef } from 'preact/hooks'; import { useState, useEffect, useRef } from 'preact/hooks';
import SearchableSelect from './SearchableSelect'; import SignalPicker, { SignalOption } from './SignalPicker';
import LuaEditor from './LuaEditor'; import LuaEditor from './LuaEditor';
import { checkExpr } from './lib/expr'; import { checkExpr } from './lib/expr';
import { VersionTree, DiffViewer } from './VersionHistory';
// ── Types (mirror internal/controllogic/model.go) ──────────────────────────── // ── Types (mirror internal/controllogic/model.go) ────────────────────────────
@@ -18,7 +19,13 @@ type CLNodeKind =
| 'action.write' | 'action.write'
| 'action.delay' | 'action.delay'
| 'action.log' | 'action.log'
| 'action.lua'; | 'action.lua'
| 'action.dialog'
| 'action.config.apply'
| 'action.config.read'
| 'action.config.write'
| 'action.config.create'
| 'action.config.snapshot';
interface CLNode { interface CLNode {
id: string; id: string;
@@ -55,6 +62,11 @@ const NODE_W = 11.5 * REM;
const PORT_TOP = 2 * REM; const PORT_TOP = 2 * REM;
const PORT_GAP = 1.375 * REM; const PORT_GAP = 1.375 * REM;
const PORT_R = 0.375 * REM; const PORT_R = 0.375 * REM;
// Ports sit inside the node's padding box, offset from the node border-box origin
// by the node borders (3px colored top accent, 1px sides). Anchors add the same
// offsets so wires meet the port centers, not their top edges.
const BORDER = 1;
const BORDER_TOP = 3;
interface PaletteEntry { kind: CLNodeKind; label: string; params: Record<string, string>; } interface PaletteEntry { kind: CLNodeKind; label: string; params: Record<string, string>; }
@@ -71,6 +83,12 @@ const PALETTE: PaletteEntry[] = [
{ kind: 'action.delay', label: 'Delay', params: { ms: '500' } }, { kind: 'action.delay', label: 'Delay', params: { ms: '500' } },
{ kind: 'action.log', label: 'Log', params: { expr: '', label: '' } }, { kind: 'action.log', label: 'Log', params: { expr: '', label: '' } },
{ kind: 'action.lua', label: 'Lua script', params: { script: '-- get("ds:name") reads, set("ds:name", v) writes,\n-- log("msg") logs to the server.\n' } }, { kind: 'action.lua', label: 'Lua script', params: { script: '-- get("ds:name") reads, set("ds:name", v) writes,\n-- log("msg") logs to the server.\n' } },
{ kind: 'action.dialog', label: 'Dialog', params: { kind: 'info', title: '', message: '', users: '', groups: '', target: '' } },
{ kind: 'action.config.apply', label: 'Apply config', params: { instance: '' } },
{ kind: 'action.config.read', label: 'Read config', params: { instance: '', key: '', target: '' } },
{ kind: 'action.config.write', label: 'Write config', params: { instance: '', key: '', expr: '' } },
{ kind: 'action.config.create', label: 'Create config', params: { set: '', name: 'auto', from: '' } },
{ kind: 'action.config.snapshot', label: 'Snapshot config', params: { set: '', name: '' } },
]; ];
const KIND_LABEL: Record<CLNodeKind, string> = { const KIND_LABEL: Record<CLNodeKind, string> = {
@@ -86,6 +104,12 @@ const KIND_LABEL: Record<CLNodeKind, string> = {
'action.delay': 'Delay', 'action.delay': 'Delay',
'action.log': 'Log', 'action.log': 'Log',
'action.lua': 'Lua script', 'action.lua': 'Lua script',
'action.dialog': 'Dialog',
'action.config.apply': 'Apply config',
'action.config.read': 'Read config',
'action.config.write': 'Write config',
'action.config.create': 'Create config',
'action.config.snapshot': 'Snapshot config',
}; };
const THRESHOLD_OPS = ['>', '<', '>=', '<=', '==', '!='] as const; const THRESHOLD_OPS = ['>', '<', '>=', '<=', '==', '!='] as const;
@@ -117,11 +141,11 @@ function nodeHeight(kind: CLNodeKind): number { return PORT_TOP + outputs(kind).
function genId(): string { return 'n_' + Math.random().toString(36).slice(2, 9); } function genId(): string { return 'n_' + Math.random().toString(36).slice(2, 9); }
function inAnchor(n: CLNode) { return { x: n.x, y: n.y + PORT_TOP }; } function inAnchor(n: CLNode) { return { x: n.x + BORDER, y: n.y + BORDER_TOP + PORT_TOP }; }
function outAnchor(n: CLNode, port: string) { function outAnchor(n: CLNode, port: string) {
const ports = outputs(n.kind); const ports = outputs(n.kind);
const i = Math.max(0, ports.findIndex(p => p.id === port)); const i = Math.max(0, ports.findIndex(p => p.id === port));
return { x: n.x + NODE_W, y: n.y + PORT_TOP + i * PORT_GAP }; return { x: n.x + NODE_W - BORDER, y: n.y + BORDER_TOP + PORT_TOP + i * PORT_GAP };
} }
function wirePathStr(x1: number, y1: number, x2: number, y2: number): string { function wirePathStr(x1: number, y1: number, x2: number, y2: number): string {
const dx = Math.max(40, Math.abs(x2 - x1) / 2); const dx = Math.max(40, Math.abs(x2 - x1) / 2);
@@ -139,6 +163,20 @@ export default function ControlLogicEditor({ onClose }: Props) {
const [dirty, setDirty] = useState(false); const [dirty, setDirty] = useState(false);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [showCloseConfirm, setShowCloseConfirm] = useState(false);
const [showVersions, setShowVersions] = useState(false);
const [viewingVersion, setViewingVersion] = useState<number | null>(null);
const [diff, setDiff] = useState<{ av: number; bv: number } | null>(null);
const [verReload, setVerReload] = useState(0);
// Closing with unsaved changes prompts instead of silently discarding them.
function requestClose() {
if (dirty) { setShowCloseConfirm(true); return; }
onClose();
}
async function saveAndClose() {
if (await saveGraph()) { setShowCloseConfirm(false); onClose(); }
}
async function reload() { async function reload() {
try { try {
@@ -180,8 +218,8 @@ export default function ControlLogicEditor({ onClose }: Props) {
} }
} }
async function saveGraph() { async function saveGraph(): Promise<boolean> {
if (!graph) return; if (!graph) return false;
setBusy(true); setBusy(true);
setError(null); setError(null);
try { try {
@@ -191,8 +229,10 @@ export default function ControlLogicEditor({ onClose }: Props) {
if (!res.ok) throw new Error(`HTTP ${res.status}`); if (!res.ok) throw new Error(`HTTP ${res.status}`);
setDirty(false); setDirty(false);
await reload(); await reload();
return true;
} catch (err) { } catch (err) {
setError(`Save failed: ${err instanceof Error ? err.message : err}`); setError(`Save failed: ${err instanceof Error ? err.message : err}`);
return false;
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -239,16 +279,35 @@ export default function ControlLogicEditor({ onClose }: Props) {
setDirty(true); setDirty(true);
} }
// Load a past revision's content into the editor as unsaved edits; saving it
// promotes it to a new current version (non-destructive).
async function viewVersion(version: number) {
if (!graph) return;
if (dirty && !confirm('Discard unsaved changes to load this version?')) return;
try {
const res = await fetch(`/api/v1/controllogic/${encodeURIComponent(graph.id)}/versions/${version}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const g: CLGraph = await res.json();
setGraph(g);
setViewingVersion(version);
setDirty(false);
} catch (err) {
setError(`Load version failed: ${err instanceof Error ? err.message : err}`);
}
}
return ( return (
<div class="cl-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}> <div class="cl-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) requestClose(); }}>
<div class="cl-modal"> <div class="cl-modal cl-modal-full">
<header class="cl-header"> <header class="cl-header">
<span class="cl-title">Control logic</span> <span class="cl-title">Control logic</span>
<span class="hint cl-subtitle">Server-side flows run continuously, independent of any panel.</span> <span class="hint cl-subtitle">Server-side flows run continuously, independent of any panel.</span>
<div class="cl-header-actions"> <div class="cl-header-actions">
{graph && dirty && <span class="cl-dirty">unsaved</span>} {graph && dirty && <span class="cl-dirty">unsaved</span>}
{graph && <button class="panel-btn" disabled={busy || !dirty} onClick={saveGraph}>Save</button>} {graph && <button class="panel-btn" disabled={busy || !dirty} onClick={saveGraph}>Save</button>}
<button class="panel-btn" onClick={onClose}>Close</button> {graph && <button class={`panel-btn${showVersions ? ' panel-btn-primary' : ''}`}
onClick={() => setShowVersions(v => !v)}>History</button>}
<button class="panel-btn" onClick={requestClose}>Close</button>
</div> </div>
</header> </header>
@@ -293,12 +352,50 @@ export default function ControlLogicEditor({ onClose }: Props) {
</label> </label>
<span class="hint">Saving a disabled graph stops it; enabling + saving starts it.</span> <span class="hint">Saving a disabled graph stops it; enabling + saving starts it.</span>
</div> </div>
<div class="cl-editor-main">
<FlowEditor graph={graph} <FlowEditor graph={graph}
onChange={(g) => { setGraph({ ...graph, nodes: g.nodes, wires: g.wires }); setDirty(true); }} /> onChange={(g) => { setGraph({ ...graph, nodes: g.nodes, wires: g.wires }); setDirty(true); }} />
{showVersions && (
<div class="ver-pane">
<div class="ver-pane-head">Version history</div>
<VersionTree
base={`/api/v1/controllogic/${encodeURIComponent(graph.id)}`}
viewing={viewingVersion}
dirty={dirty}
reloadKey={verReload}
onView={viewVersion}
onForked={async (newId) => { await reload(); setSelectedId(newId); const r = await fetch(`/api/v1/controllogic/${encodeURIComponent(newId)}`); if (r.ok) { setGraph(await r.json()); setViewingVersion(null); setDirty(false); } }}
onPromoted={async () => { await reload(); const r = await fetch(`/api/v1/controllogic/${encodeURIComponent(graph.id)}`); if (r.ok) { setGraph(await r.json()); setViewingVersion(null); setDirty(false); } setVerReload(k => k + 1); }}
onCompare={(av, bv) => setDiff({ av, bv })}
onError={setError} />
</div>
)}
</div>
</Fragment> </Fragment>
)} )}
</div> </div>
</div> </div>
{diff && graph && (
<DiffViewer base={`/api/v1/controllogic/${encodeURIComponent(graph.id)}`}
av={diff.av} bv={diff.bv} title={`${graph.name} — v${diff.av} → v${diff.bv}`}
onClose={() => setDiff(null)} />
)}
{showCloseConfirm && (
<div class="cl-confirm-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) setShowCloseConfirm(false); }}>
<div class="cl-confirm">
<div class="cl-confirm-title">Unsaved changes</div>
<p class="hint">This graph has unsaved changes. What would you like to do?</p>
<div class="cl-confirm-actions">
<button class="panel-btn" onClick={() => setShowCloseConfirm(false)}>Cancel</button>
<button class="panel-btn" disabled={busy}
onClick={() => { setShowCloseConfirm(false); onClose(); }}>Close without saving</button>
<button class="panel-btn panel-btn-primary" disabled={busy} onClick={saveAndClose}>Save and close</button>
</div>
</div>
</div>
)}
</div> </div>
</div> </div>
); );
@@ -317,6 +414,8 @@ function FlowEditor({ graph, onChange }: {
const [selectedWire, setSelectedWire] = useState<number | null>(null); const [selectedWire, setSelectedWire] = useState<number | null>(null);
const [dataSources, setDataSources] = useState<string[]>([]); const [dataSources, setDataSources] = useState<string[]>([]);
const [dsSignals, setDsSignals] = useState<Record<string, string[]>>({}); const [dsSignals, setDsSignals] = useState<Record<string, string[]>>({});
const [configInstances, setConfigInstances] = useState<{ id: string; name: string }[]>([]);
const [configSets, setConfigSets] = useState<{ id: string; name: string }[]>([]);
const canvasRef = useRef<HTMLDivElement>(null); const canvasRef = useRef<HTMLDivElement>(null);
const dragNode = useRef<{ id: string; dx: number; dy: number } | null>(null); const dragNode = useRef<{ id: string; dx: number; dy: number } | null>(null);
@@ -329,6 +428,14 @@ function FlowEditor({ graph, onChange }: {
.then(r => r.ok ? r.json() : []) .then(r => r.ok ? r.json() : [])
.then((ds: DataSource[]) => setDataSources(ds.map(d => d.name))) .then((ds: DataSource[]) => setDataSources(ds.map(d => d.name)))
.catch(() => {}); .catch(() => {});
fetch('/api/v1/config/instances')
.then(r => r.ok ? r.json() : [])
.then((list: { id: string; name: string }[]) => setConfigInstances(list ?? []))
.catch(() => {});
fetch('/api/v1/config/sets')
.then(r => r.ok ? r.json() : [])
.then((list: { id: string; name: string }[]) => setConfigSets(list ?? []))
.catch(() => {});
}, []); }, []);
async function loadSignals(ds: string) { async function loadSignals(ds: string) {
@@ -341,12 +448,15 @@ function FlowEditor({ graph, onChange }: {
} catch {} } catch {}
} }
// Bare-name write targets are graph-local variables; offer them under 'local'. // Graph-local write targets (bare name, or an explicit "local:" prefix) become
// the suggestions offered when the target data source is 'local'.
const localNames = Array.from(new Set( const localNames = Array.from(new Set(
nodes.filter(n => n.kind === 'action.write') nodes.filter(n => n.kind === 'action.write')
.map(n => n.params.target ?? '') .map(n => n.params.target ?? '')
.map(t => t.startsWith('local:') ? t.slice('local:'.length) : t)
.filter(t => t && !t.includes(':')) .filter(t => t && !t.includes(':'))
)); ));
// 'srv' is a registered data source, so it already appears in dataSources.
const dsOptions = ['local', 'sys', ...dataSources]; const dsOptions = ['local', 'sys', ...dataSources];
function signalOptions(ds: string): string[] { function signalOptions(ds: string): string[] {
if (ds === 'local') return localNames; if (ds === 'local') return localNames;
@@ -354,6 +464,15 @@ function FlowEditor({ graph, onChange }: {
return dsSignals[ds] ?? []; return dsSignals[ds] ?? [];
} }
// Flatten every known ds:name into a single list for the fuzzy SignalPicker,
// and a hook to lazily load all data sources' signals when a picker opens.
function allSignalOptions(): SignalOption[] {
const out: SignalOption[] = [];
for (const ds of dsOptions) for (const name of signalOptions(ds)) out.push({ ds, name });
return out;
}
function openAllSignals() { dsOptions.forEach(ds => loadSignals(ds)); }
const selected = nodes.find(n => n.id === selectedNode) ?? null; const selected = nodes.find(n => n.id === selectedNode) ?? null;
useEffect(() => { useEffect(() => {
@@ -573,19 +692,13 @@ function FlowEditor({ graph, onChange }: {
<div class="wizard-section-title">{KIND_LABEL[selected.kind]}</div> <div class="wizard-section-title">{KIND_LABEL[selected.kind]}</div>
{(selected.kind === 'trigger.threshold' || selected.kind === 'trigger.change' || selected.kind === 'trigger.alarm') && (() => { {(selected.kind === 'trigger.threshold' || selected.kind === 'trigger.change' || selected.kind === 'trigger.alarm') && (() => {
const { ds, name } = splitRef(selected.params.signal ?? '');
return ( return (
<Fragment> <Fragment>
<div class="wizard-field">
<label>Data source</label>
<SearchableSelect value={ds} options={dataSources}
onSelect={(newDs) => { loadSignals(newDs); patchParams(selected.id, { signal: `${newDs}:` }); }} />
</div>
<div class="wizard-field"> <div class="wizard-field">
<label>Signal</label> <label>Signal</label>
<SearchableSelect value={name} options={signalOptions(ds)} <SignalPicker value={selected.params.signal ?? ''} options={allSignalOptions()}
onSelect={(sig) => patchParams(selected.id, { signal: `${ds}:${sig}` })} onOpen={openAllSignals}
placeholder={ds ? 'Search signals…' : 'Select data source first'} /> onChange={(ref) => patchParams(selected.id, { signal: ref })} />
</div> </div>
{selected.kind === 'trigger.threshold' && ( {selected.kind === 'trigger.threshold' && (
<div class="wizard-field wizard-field-row"> <div class="wizard-field wizard-field-row">
@@ -661,7 +774,7 @@ function FlowEditor({ graph, onChange }: {
<ExprField label="Condition" value={selected.params.cond ?? ''} <ExprField label="Condition" value={selected.params.cond ?? ''}
onChange={(v) => patchParams(selected.id, { cond: v })} onChange={(v) => patchParams(selected.id, { cond: v })}
onInsert={(ds, sig) => insertRef(selected.id, 'cond', ds, sig)} onInsert={(ds, sig) => insertRef(selected.id, 'cond', ds, sig)}
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals} allSignals={allSignalOptions()} onOpenSignals={openAllSignals}
hint="True → 'then' port, false → 'else'. e.g. {stub:level} > 5 && temp < 100. {sys:time}=epoch seconds, {sys:dt}=seconds since the trigger last fired." /> hint="True → 'then' port, false → 'else'. e.g. {stub:level} > 5 && temp < 100. {sys:time}=epoch seconds, {sys:dt}=seconds since the trigger last fired." />
)} )}
@@ -685,36 +798,30 @@ function FlowEditor({ graph, onChange }: {
<ExprField label="While condition" value={selected.params.cond ?? ''} <ExprField label="While condition" value={selected.params.cond ?? ''}
onChange={(v) => patchParams(selected.id, { cond: v })} onChange={(v) => patchParams(selected.id, { cond: v })}
onInsert={(ds, sig) => insertRef(selected.id, 'cond', ds, sig)} onInsert={(ds, sig) => insertRef(selected.id, 'cond', ds, sig)}
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals} allSignals={allSignalOptions()} onOpenSignals={openAllSignals}
hint="Loops the 'body' port while true (capped). e.g. {stub:level} < 90" /> hint="Loops the 'body' port while true (capped). e.g. {stub:level} < 90" />
)} )}
<p class="hint">Runs the <b>body</b> port repeatedly, then continues on <b>done</b>.</p> <p class="hint">Runs the <b>body</b> port repeatedly, then continues on <b>done</b>.</p>
</Fragment> </Fragment>
)} )}
{selected.kind === 'action.write' && (() => { {selected.kind === 'action.write' && (
const { ds, name } = splitRef(selected.params.target ?? '');
return (
<Fragment> <Fragment>
<div class="wizard-field"> <div class="wizard-field">
<label>Target data source</label> <label>Target signal / variable</label>
<SearchableSelect value={ds} options={dsOptions} <SignalPicker value={selected.params.target ?? ''} options={allSignalOptions()}
onSelect={(newDs) => { loadSignals(newDs); patchParams(selected.id, { target: `${newDs}:` }); }} /> onOpen={openAllSignals} allowFreeform
</div> onChange={(ref) => patchParams(selected.id, { target: ref })} />
<div class="wizard-field"> <p class="hint">Pick a signal, or type <code>local:name</code> for a graph-local var
<label>Target signal</label> or <code>srv:name</code> for a server variable that panels can read.</p>
<SearchableSelect value={name} options={signalOptions(ds)}
onSelect={(sig) => patchParams(selected.id, { target: `${ds}:${sig}` })}
placeholder={ds ? 'Search signals' : 'Select data source first'} />
</div> </div>
<ExprField label="Value (expression)" value={selected.params.expr ?? ''} <ExprField label="Value (expression)" value={selected.params.expr ?? ''}
onChange={(v) => patchParams(selected.id, { expr: v })} onChange={(v) => patchParams(selected.id, { expr: v })}
onInsert={(ds2, sig) => insertRef(selected.id, 'expr', ds2, sig)} onInsert={(ds2, sig) => insertRef(selected.id, 'expr', ds2, sig)}
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals} allSignals={allSignalOptions()} onOpenSignals={openAllSignals}
hint="Write to a data source signal, or a bare name to store a graph-local var. e.g. ({stub:a} - {stub:b}) / {sys:dt}." /> hint="Write to a data source signal, a bare local var, or srv:name for a server variable panels can read. e.g. ({stub:a} - {stub:b}) / {sys:dt}." />
</Fragment> </Fragment>
); )}
})()}
{selected.kind === 'action.delay' && ( {selected.kind === 'action.delay' && (
<div class="wizard-field"> <div class="wizard-field">
@@ -735,7 +842,7 @@ function FlowEditor({ graph, onChange }: {
<ExprField label="Value (expression)" value={selected.params.expr ?? ''} <ExprField label="Value (expression)" value={selected.params.expr ?? ''}
onChange={(v) => patchParams(selected.id, { expr: v })} onChange={(v) => patchParams(selected.id, { expr: v })}
onInsert={(ds2, sig) => insertRef(selected.id, 'expr', ds2, sig)} onInsert={(ds2, sig) => insertRef(selected.id, 'expr', ds2, sig)}
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals} allSignals={allSignalOptions()} onOpenSignals={openAllSignals}
hint="Logs this value to the server log — handy for debugging a flow." /> hint="Logs this value to the server log — handy for debugging a flow." />
</Fragment> </Fragment>
)} )}
@@ -753,6 +860,172 @@ function FlowEditor({ graph, onChange }: {
</div> </div>
)} )}
{selected.kind === 'action.dialog' && (
<Fragment>
<div class="wizard-field">
<label>Type</label>
<select class="prop-input" value={selected.params.kind ?? 'info'}
onChange={(e) => patchParams(selected.id, { kind: (e.target as HTMLSelectElement).value })}>
<option value="info">Info</option>
<option value="error">Error</option>
<option value="input">Input (ask for a value)</option>
</select>
</div>
<div class="wizard-field">
<label>Title</label>
<input class="prop-input" value={selected.params.title ?? ''}
placeholder="dialog title"
onInput={(e) => patchParams(selected.id, { title: (e.target as HTMLInputElement).value })} />
</div>
<div class="wizard-field">
<label>Message</label>
<textarea class="prop-input" rows={3} value={selected.params.message ?? ''}
placeholder="shown to the user"
onInput={(e) => patchParams(selected.id, { message: (e.target as HTMLTextAreaElement).value })} />
</div>
{selected.params.kind === 'input' && (
<div class="wizard-field">
<label>Response variable</label>
<input class="prop-input" value={selected.params.target ?? ''}
placeholder="srv:approved"
onInput={(e) => patchParams(selected.id, { target: (e.target as HTMLInputElement).value })} />
<p class="hint">The user's number is written here (e.g. <code>srv:approved</code>); read it on a later activation.</p>
</div>
)}
<div class="wizard-field">
<label>Users (optional)</label>
<input class="prop-input" value={selected.params.users ?? ''}
placeholder="alice, bob"
onInput={(e) => patchParams(selected.id, { users: (e.target as HTMLInputElement).value })} />
</div>
<div class="wizard-field">
<label>Groups (optional)</label>
<input class="prop-input" value={selected.params.groups ?? ''}
placeholder="operators"
onInput={(e) => patchParams(selected.id, { groups: (e.target as HTMLInputElement).value })} />
<p class="hint">Comma-separated. Leave both empty to show the dialog to every connected user.</p>
</div>
</Fragment>
)}
{selected.kind === 'action.config.apply' && (
<div class="wizard-field">
<label>Config instance</label>
<select class="prop-select" value={selected.params.instance ?? ''}
onChange={(e) => patchParams(selected.id, { instance: (e.target as HTMLSelectElement).value })}>
<option value="">choose</option>
{configInstances.map(ci => <option key={ci.id} value={ci.id}>{ci.name}</option>)}
</select>
<p class="hint">Applies the instance's current values to every bound signal (like the
Apply button in the config manager). Each write is audited.</p>
</div>
)}
{selected.kind === 'action.config.read' && (
<Fragment>
<div class="wizard-field">
<label>Config instance</label>
<select class="prop-select" value={selected.params.instance ?? ''}
onChange={(e) => patchParams(selected.id, { instance: (e.target as HTMLSelectElement).value })}>
<option value="">choose…</option>
{configInstances.map(ci => <option key={ci.id} value={ci.id}>{ci.name}</option>)}
</select>
</div>
<div class="wizard-field">
<label>Parameter key</label>
<input class="prop-input" value={selected.params.key ?? ''}
placeholder="parameter key"
onInput={(e) => patchParams(selected.id, { key: (e.target as HTMLInputElement).value })} />
</div>
<div class="wizard-field">
<label>Target signal / variable</label>
<SignalPicker value={selected.params.target ?? ''} options={allSignalOptions()}
onOpen={openAllSignals} allowFreeform
onChange={(ref) => patchParams(selected.id, { target: ref })} />
<p class="hint">Reads the named parameter's value and writes it to this target. Only
numeric values are supported.</p>
</div>
</Fragment>
)}
{selected.kind === 'action.config.write' && (
<Fragment>
<div class="wizard-field">
<label>Config instance</label>
<select class="prop-select" value={selected.params.instance ?? ''}
onChange={(e) => patchParams(selected.id, { instance: (e.target as HTMLSelectElement).value })}>
<option value="">choose</option>
{configInstances.map(ci => <option key={ci.id} value={ci.id}>{ci.name}</option>)}
</select>
</div>
<div class="wizard-field">
<label>Parameter key</label>
<input class="prop-input" value={selected.params.key ?? ''}
placeholder="parameter key"
onInput={(e) => patchParams(selected.id, { key: (e.target as HTMLInputElement).value })} />
</div>
<div class="wizard-field">
<label>Value expression</label>
<input class="prop-input" value={selected.params.expr ?? ''}
placeholder="e.g. {ds:signal} * 2"
onInput={(e) => patchParams(selected.id, { expr: (e.target as HTMLInputElement).value })} />
<p class="hint">Evaluates the expression and stores it into the named parameter, creating a
new instance revision. Only numeric values are supported; the value is coerced to the
parameter's declared type.</p>
</div>
</Fragment>
)}
{selected.kind === 'action.config.create' && (
<Fragment>
<div class="wizard-field">
<label>Config set</label>
<select class="prop-select" value={selected.params.set ?? ''}
onChange={(e) => patchParams(selected.id, { set: (e.target as HTMLSelectElement).value })}>
<option value="">choose…</option>
{configSets.map(cs => <option key={cs.id} value={cs.id}>{cs.name}</option>)}
</select>
</div>
<div class="wizard-field">
<label>New instance name</label>
<input class="prop-input" value={selected.params.name ?? ''}
placeholder="auto"
onInput={(e) => patchParams(selected.id, { name: (e.target as HTMLInputElement).value })} />
</div>
<div class="wizard-field">
<label>Copy values from</label>
<select class="prop-select" value={selected.params.from ?? ''}
onChange={(e) => patchParams(selected.id, { from: (e.target as HTMLSelectElement).value })}>
<option value="">defaults (empty)</option>
{configInstances.map(ci => <option key={ci.id} value={ci.id}>{ci.name}</option>)}
</select>
<p class="hint">Creates a new instance for the chosen set, optionally seeded from an existing
instance's values. The new id is logged to the audit trail.</p>
</div>
</Fragment>
)}
{selected.kind === 'action.config.snapshot' && (
<Fragment>
<div class="wizard-field">
<label>Config set</label>
<select class="prop-select" value={selected.params.set ?? ''}
onChange={(e) => patchParams(selected.id, { set: (e.target as HTMLSelectElement).value })}>
<option value="">choose</option>
{configSets.map(cs => <option key={cs.id} value={cs.id}>{cs.name}</option>)}
</select>
</div>
<div class="wizard-field">
<label>New instance name</label>
<input class="prop-input" value={selected.params.name ?? ''}
placeholder="(auto)"
onInput={(e) => patchParams(selected.id, { name: (e.target as HTMLInputElement).value })} />
<p class="hint">Reads the current live value of every target signal of the set and stores
them as a new instance. The new id is logged to the audit trail.</p>
</div>
</Fragment>
)}
<button class="panel-btn" style="margin-top:1rem;" onClick={() => deleteNode(selected.id)}>Delete node</button> <button class="panel-btn" style="margin-top:1rem;" onClick={() => deleteNode(selected.id)}>Delete node</button>
</Fragment> </Fragment>
)} )}
@@ -774,17 +1047,15 @@ function FlowEditor({ graph, onChange }: {
} }
} }
function ExprField({ label, value, onChange, onInsert, dsOptions, signalOptions, loadSignals, hint }: { function ExprField({ label, value, onChange, onInsert, allSignals, onOpenSignals, hint }: {
label: string; label: string;
value: string; value: string;
onChange: (v: string) => void; onChange: (v: string) => void;
onInsert: (ds: string, sig: string) => void; onInsert: (ds: string, sig: string) => void;
dsOptions: string[]; allSignals: SignalOption[];
signalOptions: (ds: string) => string[]; onOpenSignals: () => void;
loadSignals: (ds: string) => void;
hint: string; hint: string;
}) { }) {
const [insDs, setInsDs] = useState('');
const err = checkExpr(value); const err = checkExpr(value);
return ( return (
<div class="wizard-field"> <div class="wizard-field">
@@ -794,11 +1065,9 @@ function ExprField({ label, value, onChange, onInsert, dsOptions, signalOptions,
onInput={(e) => onChange((e.target as HTMLInputElement).value)} /> onInput={(e) => onChange((e.target as HTMLInputElement).value)} />
{err && <p class="wizard-error">{err}</p>} {err && <p class="wizard-error">{err}</p>}
<div class="flow-expr-insert"> <div class="flow-expr-insert">
<SearchableSelect value={insDs} options={dsOptions} <SignalPicker value="" options={allSignals} onOpen={onOpenSignals}
onSelect={(ds) => { setInsDs(ds); loadSignals(ds); }} placeholder="ds…" /> placeholder="+ insert signal"
<SearchableSelect value="" options={signalOptions(insDs)} onChange={(ref) => { const s = splitRef(ref); onInsert(s.ds, s.name); }} />
onSelect={(sig) => onInsert(insDs, sig)}
placeholder={insDs ? '+ insert signal' : 'pick ds first'} />
</div> </div>
<p class="hint">{hint}</p> <p class="hint">{hint}</p>
</div> </div>
@@ -821,6 +1090,7 @@ function nodeSummary(n: CLNode): string {
case 'action.delay': return `wait ${n.params.ms || '0'} ms`; case 'action.delay': return `wait ${n.params.ms || '0'} ms`;
case 'action.log': return `log ${n.params.label ? n.params.label + ': ' : ''}${n.params.expr || ''}`; case 'action.log': return `log ${n.params.label ? n.params.label + ': ' : ''}${n.params.expr || ''}`;
case 'action.lua': return 'Lua script'; case 'action.lua': return 'Lua script';
case 'action.dialog': return `${n.params.kind || 'info'} dialog${n.params.title ? ': ' + n.params.title : ''}`;
default: return ''; default: return '';
} }
} }
+259
View File
@@ -0,0 +1,259 @@
import { h } from 'preact';
import { useRef, useState } from 'preact/hooks';
// ── Tokenizer ─────────────────────────────────────────────────────────────────
type TT = 'kw' | 'type' | 'str' | 'cmt' | 'num' | 'attr' | 'text';
const CUE_KEYWORDS = new Set([
'package', 'import', 'for', 'in', 'if', 'let', 'true', 'false', 'null',
]);
const CUE_TYPES = new Set([
'number', 'int', 'float', 'string', 'bytes', 'bool', 'uint', 'rune',
]);
// Identifiers that make sensible always-available completions.
const CUE_COMPLETION_WORDS = [...CUE_KEYWORDS, ...CUE_TYPES];
interface Tok { t: TT; s: string; }
function isAlpha(c: string) { return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c === '_' || c === '#'; }
function isAlNum(c: string) { return isAlpha(c) || (c >= '0' && c <= '9'); }
function isDigit(c: string) { return c >= '0' && c <= '9'; }
function tokenize(src: string): Tok[] {
const out: Tok[] = [];
let i = 0;
while (i < src.length) {
const c = src[i];
// Line comment
if (c === '/' && src[i + 1] === '/') {
const end = src.indexOf('\n', i);
const stop = end < 0 ? src.length : end;
out.push({ t: 'cmt', s: src.slice(i, stop) });
i = stop;
continue;
}
// Multiline string """...""" or '''...'''
if ((c === '"' || c === "'") && src[i + 1] === c && src[i + 2] === c) {
const q = c + c + c;
const end = src.indexOf(q, i + 3);
const stop = end < 0 ? src.length : end + 3;
out.push({ t: 'str', s: src.slice(i, stop) });
i = stop;
continue;
}
// String "..." or '...'
if (c === '"' || c === "'") {
let j = i + 1;
while (j < src.length) {
if (src[j] === '\\') { j += 2; continue; }
if (src[j] === c || src[j] === '\n') { j++; break; }
j++;
}
out.push({ t: 'str', s: src.slice(i, j) });
i = j;
continue;
}
// Number
if (isDigit(c) || (c === '.' && isDigit(src[i + 1] ?? ''))) {
let j = i;
while (j < src.length && (isDigit(src[j]) || src[j] === '.' || src[j] === '_')) j++;
if (j < src.length && (src[j] === 'e' || src[j] === 'E')) {
j++;
if (src[j] === '+' || src[j] === '-') j++;
while (j < src.length && isDigit(src[j])) j++;
}
out.push({ t: 'num', s: src.slice(i, j) });
i = j;
continue;
}
// Identifier / keyword / field label
if (isAlpha(c)) {
let j = i;
while (j < src.length && isAlNum(src[j])) j++;
const word = src.slice(i, j);
// Peek past spaces for a ':' => this identifier is a field label.
let k = j;
while (k < src.length && (src[k] === ' ' || src[k] === '\t')) k++;
let t: TT = 'text';
if (CUE_KEYWORDS.has(word)) t = 'kw';
else if (CUE_TYPES.has(word)) t = 'type';
else if (src[k] === ':') t = 'attr';
out.push({ t, s: word });
i = j;
continue;
}
out.push({ t: 'text', s: c });
i++;
}
return out;
}
function renderTokens(src: string) {
return tokenize(src).map((tok, idx) =>
tok.t === 'text'
? <span key={idx}>{tok.s}</span>
: <span key={idx} class={`cue-${tok.t}`}>{tok.s}</span>
);
}
// ── Caret coordinates (mirror technique) ──────────────────────────────────────
function caretXY(ta: HTMLTextAreaElement): { left: number; top: number; lineHeight: number } {
const style = getComputedStyle(ta);
const div = document.createElement('div');
const props = [
'boxSizing', 'width', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft',
'borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth',
'fontFamily', 'fontSize', 'fontWeight', 'lineHeight', 'letterSpacing', 'tabSize',
] as const;
for (const p of props) (div.style as any)[p] = (style as any)[p];
div.style.position = 'absolute';
div.style.visibility = 'hidden';
div.style.whiteSpace = 'pre-wrap';
div.style.wordWrap = 'break-word';
div.style.overflow = 'hidden';
div.textContent = ta.value.slice(0, ta.selectionStart);
const marker = document.createElement('span');
marker.textContent = ta.value.slice(ta.selectionStart) || '.';
div.appendChild(marker);
document.body.appendChild(div);
const lineHeight = parseFloat(style.lineHeight) || (parseFloat(style.fontSize) * 1.4);
const top = marker.offsetTop - ta.scrollTop;
const left = marker.offsetLeft - ta.scrollLeft;
document.body.removeChild(div);
return { left, top, lineHeight };
}
// ── Component ─────────────────────────────────────────────────────────────────
interface Props {
value: string;
onChange: (v: string) => void;
rows?: number;
/** Parameter/signal names offered alongside the CUE vocabulary. */
completions?: string[];
}
interface AC { items: string[]; index: number; from: number; left: number; top: number; }
export default function CueEditor({ value, onChange, rows = 14, completions = [] }: Props) {
const preRef = useRef<HTMLPreElement>(null);
const taRef = useRef<HTMLTextAreaElement>(null);
const [ac, setAc] = useState<AC | null>(null);
const vocab = [...new Set([...completions, ...CUE_COMPLETION_WORDS])];
function syncScroll(e: Event) {
const ta = e.target as HTMLTextAreaElement;
if (preRef.current) {
preRef.current.scrollTop = ta.scrollTop;
preRef.current.scrollLeft = ta.scrollLeft;
}
}
function wordPrefix(ta: HTMLTextAreaElement): { word: string; from: number } {
const caret = ta.selectionStart;
let from = caret;
while (from > 0 && /[A-Za-z0-9_#]/.test(ta.value[from - 1])) from--;
return { word: ta.value.slice(from, caret), from };
}
function refreshAC(ta: HTMLTextAreaElement) {
const { word, from } = wordPrefix(ta);
if (word.length < 1) { setAc(null); return; }
const lower = word.toLowerCase();
const items = vocab.filter((w) => w.toLowerCase().startsWith(lower) && w !== word).slice(0, 8);
if (items.length === 0) { setAc(null); return; }
const { left, top, lineHeight } = caretXY(ta);
const rect = ta.getBoundingClientRect();
setAc({ items, index: 0, from, left: rect.left + left, top: rect.top + top + lineHeight });
}
function accept(item: string) {
const ta = taRef.current;
if (!ta || !ac) return;
const caret = ta.selectionStart;
const next = value.slice(0, ac.from) + item + value.slice(caret);
onChange(next);
setAc(null);
const pos = ac.from + item.length;
requestAnimationFrame(() => {
ta.focus();
ta.setSelectionRange(pos, pos);
});
}
function onKeyDown(e: KeyboardEvent) {
if (!ac) {
// Ctrl+Space forces the completion popup open.
if (e.key === ' ' && e.ctrlKey) {
e.preventDefault();
refreshAC(e.target as HTMLTextAreaElement);
}
return;
}
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setAc({ ...ac, index: (ac.index + 1) % ac.items.length });
break;
case 'ArrowUp':
e.preventDefault();
setAc({ ...ac, index: (ac.index - 1 + ac.items.length) % ac.items.length });
break;
case 'Enter':
case 'Tab':
e.preventDefault();
accept(ac.items[ac.index]);
break;
case 'Escape':
e.preventDefault();
setAc(null);
break;
}
}
return (
<div class="cue-editor" style={`height:${rows * 1.5}em;`}>
<pre ref={preRef} class="cue-pre" aria-hidden="true">
{renderTokens(value)}
{'\n'}
</pre>
<textarea
ref={taRef}
class="cue-textarea"
value={value}
spellcheck={false}
autocomplete="off"
onInput={(e) => { onChange((e.target as HTMLTextAreaElement).value); refreshAC(e.target as HTMLTextAreaElement); }}
onKeyDown={onKeyDown}
onScroll={syncScroll}
onBlur={() => setTimeout(() => setAc(null), 120)}
/>
{ac && (
<ul class="cue-ac" style={`left:${ac.left}px;top:${ac.top}px;`}>
{ac.items.map((it, i) => (
<li
key={it}
class={i === ac.index ? 'cue-ac-item active' : 'cue-ac-item'}
onMouseDown={(e) => { e.preventDefault(); accept(it); }}
>
{it}
</li>
))}
</ul>
)}
</div>
);
}
+1
View File
@@ -35,6 +35,7 @@ export const DEFAULT_SIZES: Record<string, [number, number]> = {
plot: [400, 200], plot: [400, 200],
image: [200, 150], image: [200, 150],
link: [140, 50], link: [140, 50],
configselect: [220, 50],
}; };
// Widget types that support multiple signals (signal can be appended) // Widget types that support multiple signals (signal can be appended)
+40 -112
View File
@@ -8,24 +8,16 @@ import EditCanvas, { genWidgetId, DEFAULT_SIZES } from './EditCanvas';
import PlotPanelCanvas from './PlotPanelCanvas'; import PlotPanelCanvas from './PlotPanelCanvas';
import LogicEditor from './LogicEditor'; import LogicEditor from './LogicEditor';
import PropertiesPane from './PropertiesPane'; import PropertiesPane from './PropertiesPane';
import ContextualHelp from './ContextualHelp';
import HelpModal from './HelpModal'; import HelpModal from './HelpModal';
import ZoomControl from './ZoomControl'; import ZoomControl from './ZoomControl';
import { useAuth, canWrite } from './lib/auth'; import { useAuth, canWrite } from './lib/auth';
import { VersionTree, DiffViewer } from './VersionHistory';
interface Props { interface Props {
initial: Interface | null; initial: Interface | null;
onDone: (iface: Interface | null) => void; onDone: (iface: Interface | null) => void;
} }
interface VersionMeta {
version: number;
name: string;
tag?: string;
current: boolean;
savedAt: string;
}
function blankInterface(): Interface { function blankInterface(): Interface {
return { id: '', name: 'New Interface', version: 1, w: 1200, h: 800, widgets: [] }; return { id: '', name: 'New Interface', version: 1, w: 1200, h: 800, widgets: [] };
} }
@@ -84,6 +76,7 @@ const STATIC_WIDGET_TYPES = [
{ type: 'button', label: 'Action Button' }, { type: 'button', label: 'Action Button' },
{ type: 'image', label: 'Image' }, { type: 'image', label: 'Image' },
{ type: 'link', label: 'Link' }, { type: 'link', label: 'Link' },
{ type: 'configselect', label: 'Config Selector' },
]; ];
export default function EditMode({ initial, onDone }: Props) { export default function EditMode({ initial, onDone }: Props) {
@@ -106,8 +99,9 @@ export default function EditMode({ initial, onDone }: Props) {
// History panel // History panel
const [showHistory, setShowHistory] = useState(false); const [showHistory, setShowHistory] = useState(false);
const [versions, setVersions] = useState<VersionMeta[]>([]); const [viewingVersion, setViewingVersion] = useState<number | null>(null);
const [historyLoading, setHistoryLoading] = useState(false); const [diff, setDiff] = useState<{ av: number; bv: number } | null>(null);
const [verReload, setVerReload] = useState(0);
const [tag, setTag] = useState(''); const [tag, setTag] = useState('');
// Bumped whenever the editor content is replaced wholesale (version load / // Bumped whenever the editor content is replaced wholesale (version load /
// promote) to force a full canvas remount, so no stale widget state lingers. // promote) to force a full canvas remount, so no stale widget state lingers.
@@ -329,6 +323,7 @@ export default function EditMode({ initial, onDone }: Props) {
options: options:
type === 'textlabel' ? { label: 'Label' } : type === 'textlabel' ? { label: 'Label' } :
type === 'button' ? { label: 'Button', mode: 'oneshot' } : type === 'button' ? { label: 'Button', mode: 'oneshot' } :
type === 'configselect' ? { label: 'Config', set: '', output: '', instances: '' } :
{}, {},
}; };
handleWidgetsChange([...(iface.widgets || []), newWidget]); handleWidgetsChange([...(iface.widgets || []), newWidget]);
@@ -360,7 +355,8 @@ export default function EditMode({ initial, onDone }: Props) {
setDirty(false); setDirty(false);
setTag(''); setTag('');
window.dispatchEvent(new CustomEvent('uopi:refresh-interfaces')); window.dispatchEvent(new CustomEvent('uopi:refresh-interfaces'));
if (showHistory) loadVersions(); setViewingVersion(null);
setVerReload(k => k + 1);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : String(err)); setError(err instanceof Error ? err.message : String(err));
} finally { } finally {
@@ -371,26 +367,12 @@ export default function EditMode({ initial, onDone }: Props) {
// ── Version history ──────────────────────────────────────────────────────── // ── Version history ────────────────────────────────────────────────────────
const loadVersions = useCallback(async () => {
if (!iface.id) { setVersions([]); return; }
setHistoryLoading(true);
try {
const res = await fetch(`/api/v1/interfaces/${encodeURIComponent(iface.id)}/versions`);
if (!res.ok) throw new Error(`Load versions failed (${res.status})`);
setVersions(await res.json());
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setHistoryLoading(false);
}
}, [iface.id]);
const toggleHistory = useCallback(() => { const toggleHistory = useCallback(() => {
setShowHistory(s => { setShowHistory(s => {
if (!s) loadVersions(); if (!s) setVerReload(k => k + 1);
return !s; return !s;
}); });
}, [loadVersions]); }, []);
// Load a past revision into the editor non-destructively: the current id is // Load a past revision into the editor non-destructively: the current id is
// preserved, so saving the restored content creates a new revision on top. // preserved, so saving the restored content creates a new revision on top.
@@ -404,45 +386,26 @@ export default function EditMode({ initial, onDone }: Props) {
restored.id = iface.id; // keep editing the same interface restored.id = iface.id; // keep editing the same interface
setIface(restored); setIface(restored);
setSelectedIds([]); setSelectedIds([]);
setDirty(true); // Viewing a past revision is not itself a change — only subsequent edits
// dirty the editor (saving then promotes it to a new revision).
setDirty(false);
setCanvasNonce(n => n + 1); setCanvasNonce(n => n + 1);
undoStack.current = []; undoStack.current = [];
redoStack.current = []; redoStack.current = [];
setHistoryLen(0); setHistoryLen(0);
setViewingVersion(version);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : String(err)); setError(err instanceof Error ? err.message : String(err));
} }
}, [iface.id, dirty]); }, [iface.id, dirty]);
// Edit (or clear) the label of an existing revision in place. // Reload the now-current interface content into the editor (after a promote,
const editVersionTag = useCallback(async (version: number, currentTag: string) => { // which the VersionTree performs server-side).
const reloadCurrentIface = useCallback(async () => {
if (!iface.id) return; if (!iface.id) return;
const next = prompt('Label for this version (leave empty to clear):', currentTag ?? '');
if (next === null) return;
try { try {
const res = await fetch(
`/api/v1/interfaces/${encodeURIComponent(iface.id)}/versions/${version}/tag?tag=${encodeURIComponent(next)}`,
{ method: 'PUT' },
);
if (!res.ok) throw new Error(`Tag update failed (${res.status})`);
loadVersions();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
}, [iface.id, loadVersions]);
// Make a past revision the current one (saved as a new revision on top).
const promoteVersion = useCallback(async (version: number) => {
if (!iface.id) return;
if (dirty && !confirm('You have unsaved changes that will be discarded. Set this version as current?')) return;
try {
const res = await fetch(
`/api/v1/interfaces/${encodeURIComponent(iface.id)}/versions/${version}/promote`,
{ method: 'POST' },
);
if (!res.ok) throw new Error(`Promote failed (${res.status})`);
// Reload the now-current interface content into the editor.
const cur = await fetch(`/api/v1/interfaces/${encodeURIComponent(iface.id)}`); const cur = await fetch(`/api/v1/interfaces/${encodeURIComponent(iface.id)}`);
if (!cur.ok) throw new Error(`Reload failed (${cur.status})`);
const reloaded = parseInterface(await cur.text()); const reloaded = parseInterface(await cur.text());
setIface(reloaded); setIface(reloaded);
setSelectedIds([]); setSelectedIds([]);
@@ -451,25 +414,9 @@ export default function EditMode({ initial, onDone }: Props) {
undoStack.current = []; undoStack.current = [];
redoStack.current = []; redoStack.current = [];
setHistoryLen(0); setHistoryLen(0);
setViewingVersion(null);
window.dispatchEvent(new CustomEvent('uopi:refresh-interfaces')); window.dispatchEvent(new CustomEvent('uopi:refresh-interfaces'));
loadVersions(); setVerReload(k => k + 1);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
}, [iface.id, dirty, loadVersions]);
// Fork a revision into a brand-new interface.
const forkVersion = useCallback(async (version: number) => {
if (!iface.id) return;
try {
const res = await fetch(
`/api/v1/interfaces/${encodeURIComponent(iface.id)}/versions/${version}/fork`,
{ method: 'POST' },
);
if (!res.ok) throw new Error(`Fork failed (${res.status})`);
const json = await res.json();
window.dispatchEvent(new CustomEvent('uopi:refresh-interfaces'));
alert(`Forked into new interface "${json.id}". Open it from the interface list.`);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : String(err)); setError(err instanceof Error ? err.message : String(err));
} }
@@ -626,7 +573,6 @@ export default function EditMode({ initial, onDone }: Props) {
<div class="toolbar-right"> <div class="toolbar-right">
<ZoomControl /> <ZoomControl />
<ContextualHelp mode="edit" onOpenManual={openHelp} />
<button class="icon-btn help-manual-btn" onClick={() => openHelp('edit')} title="Open user manual">📖</button> <button class="icon-btn help-manual-btn" onClick={() => openHelp('edit')} title="Open user manual">📖</button>
<button class="toolbar-btn" onClick={handleImport}>Import</button> <button class="toolbar-btn" onClick={handleImport}>Import</button>
<button class="toolbar-btn" onClick={handleExport}>Export</button> <button class="toolbar-btn" onClick={handleExport}>Export</button>
@@ -735,49 +681,31 @@ export default function EditMode({ initial, onDone }: Props) {
Save snapshot Save snapshot
</button> </button>
</div> </div>
<div class="history-list"> {!iface.id ? (
{historyLoading && <div class="history-empty">Loading</div>} <div class="history-empty">Save the interface first to enable history.</div>
{!historyLoading && versions.length === 0 && ( ) : (
<div class="history-empty">No saved versions yet.</div> <VersionTree
base={`/api/v1/interfaces/${encodeURIComponent(iface.id)}`}
viewing={viewingVersion}
dirty={dirty}
canWrite={writable}
reloadKey={verReload}
onView={loadVersion}
onForked={() => window.dispatchEvent(new CustomEvent('uopi:refresh-interfaces'))}
onPromoted={reloadCurrentIface}
onCompare={(av, bv) => setDiff({ av, bv })}
onError={setError} />
)} )}
{!historyLoading && versions.map(v => (
<div
key={v.version}
class={`history-item${v.current ? ' history-item-current' : ''}`}
>
<div class="history-item-main">
<span class="history-version">v{v.version}</span>
{v.tag && <span class="history-tag">{v.tag}</span>}
{v.current && <span class="history-current-badge">current</span>}
</div>
<div class="history-item-meta">
{new Date(v.savedAt).toLocaleString()}
</div>
<div class="history-item-actions">
{!v.current && (
<button class="history-action-btn" onClick={() => loadVersion(v.version)} title="Load this version into the editor (does not change history)">
Load
</button>
)}
{!v.current && (
<button class="history-action-btn" onClick={() => promoteVersion(v.version)} title="Make this version the current one">
Set current
</button>
)}
<button class="history-action-btn" onClick={() => forkVersion(v.version)} title="Create a new interface from this version">
Fork
</button>
<button class="history-action-btn" onClick={() => editVersionTag(v.version, v.tag ?? '')} title="Edit this version's label">
Label
</button>
</div>
</div>
))}
</div>
</div> </div>
)} )}
</div> </div>
{diff && iface.id && (
<DiffViewer base={`/api/v1/interfaces/${encodeURIComponent(iface.id)}`}
av={diff.av} bv={diff.bv} title={`${iface.name} — v${diff.av} → v${diff.bv}`}
onClose={() => setDiff(null)} />
)}
{showHelp && ( {showHelp && (
<HelpModal initialSection={helpSection} onClose={() => setShowHelp(false)} /> <HelpModal initialSection={helpSection} onClose={() => setShowHelp(false)} />
)} )}
+107 -5
View File
@@ -403,6 +403,7 @@ function DiagramSignalTree() {
const SECTIONS = [ const SECTIONS = [
{ id: 'start', label: 'Getting Started' }, { id: 'start', label: 'Getting Started' },
{ id: 'tips', label: 'Quick Tips' },
{ id: 'view', label: 'View Mode' }, { id: 'view', label: 'View Mode' },
{ id: 'edit', label: 'Edit Mode' }, { id: 'edit', label: 'Edit Mode' },
{ id: 'widgets', label: 'Widgets' }, { id: 'widgets', label: 'Widgets' },
@@ -411,10 +412,68 @@ const SECTIONS = [
{ id: 'logic', label: 'Panel Logic' }, { id: 'logic', label: 'Panel Logic' },
{ id: 'control', label: 'Control Logic' }, { id: 'control', label: 'Control Logic' },
{ id: 'sharing', label: 'Sharing & Access' }, { id: 'sharing', label: 'Sharing & Access' },
{ id: 'audit', label: 'Audit Log' },
{ id: 'history', label: 'Historical Data' }, { id: 'history', label: 'Historical Data' },
{ id: 'shortcuts', label: 'Keyboard Shortcuts' }, { id: 'shortcuts', label: 'Keyboard Shortcuts' },
]; ];
// Quick tips, grouped by the mode they apply to. Each links to the manual
// section that covers it in depth. (Previously surfaced via a floating
// contextual-help popover; now consolidated here in the manual.)
const TIPS: Record<'view' | 'edit', Array<{ text: string; section: string }>> = {
view: [
{ text: 'Click any interface in the left panel to load it on the canvas.', section: 'view' },
{ text: 'Right-click a widget to view signal info or export data as CSV.', section: 'view' },
{ text: 'The ● Live button shows live streaming is active. Use the date pickers to load historical data.', section: 'history' },
{ text: 'Click Edit (top-right) to open the drag-and-drop panel builder.', section: 'edit' },
{ text: 'Use the Share button on a panel to grant access to users or groups.', section: 'sharing' },
{ text: 'The ⚙ Control logic button opens server-side automation that runs even with no panel open.', section: 'control' },
{ text: 'The 🛡 Audit button (for permitted users) opens the log of system-affecting actions.', section: 'audit' },
{ text: 'The status chip turns green and shows your username when the WebSocket is connected.', section: 'view' },
],
edit: [
{ text: 'Drag a signal from the left tree onto the canvas to create a widget.', section: 'edit' },
{ text: 'Click a widget to select it, then adjust its properties on the right.', section: 'edit' },
{ text: 'Ctrl+click to select multiple widgets; then use the align toolbar.', section: 'edit' },
{ text: 'Ctrl+Z to undo, Ctrl+Y to redo. Up to 50 steps are remembered.', section: 'shortcuts' },
{ text: 'Use "+ Synthetic" in the signal tree to create computed/filtered signals.', section: 'signals' },
{ text: 'Switch to the Logic tab to add interactive behaviour with a node-graph flow editor.', section: 'logic' },
{ text: 'Add Local variables for set-points and counters that live inside the panel.', section: 'edit' },
{ text: 'Arrow keys nudge the selected widget by 1 px (Shift for grid size).', section: 'shortcuts' },
],
};
function SectionTips({ onNavigate }: { onNavigate: (section: string) => void }) {
const groups: Array<['view' | 'edit', string]> = [
['view', 'In View mode'],
['edit', 'In Edit mode'],
];
return (
<div>
<p class="help-lead">
A handful of quick pointers to get the most out of uopi. Click any tip to jump to the
section that explains it in detail.
</p>
{groups.map(([mode, title]) => (
<div key={mode}>
<h3 class="help-h3">{title}</h3>
<ul class="help-tips-list">
{TIPS[mode].map(tip => (
<li key={tip.text}>
<button class="help-tip-item" onClick={() => onNavigate(tip.section)}>
<span class="help-tip-icon">💡</span>
<span class="help-tip-text">{tip.text}</span>
<span class="help-tip-go"></span>
</button>
</li>
))}
</ul>
</div>
))}
</div>
);
}
function SectionStart() { function SectionStart() {
return ( return (
<div> <div>
@@ -481,8 +540,10 @@ function SectionView() {
<h3 class="help-h3">Connection status</h3> <h3 class="help-h3">Connection status</h3>
<p> <p>
The coloured chip in the top-right shows the WebSocket connection state. The coloured chip in the top-right shows the WebSocket connection state. When connected it
A red <em>"disconnected"</em> banner appears at the top while reconnecting live values will resume automatically. reads <em>"connected as &lt;your username&gt;"</em>, so you can confirm the identity your
actions are attributed to in the audit log. A red <em>"disconnected"</em> banner appears at
the top while reconnecting live values will resume automatically.
</p> </p>
</div> </div>
); );
@@ -677,6 +738,44 @@ function SectionSharing() {
); );
} }
function SectionAudit() {
return (
<div>
<p class="help-lead">
When the server is configured with <code>[audit] enabled = true</code>, every
<strong> system-affecting action</strong> is appended to an append-only log so it can be
reviewed later. Permitted users open it with the <strong>🛡 Audit</strong> button in the
view-mode toolbar.
</p>
<h3 class="help-h3">What gets recorded</h3>
<ul class="help-list">
<li><strong>Signal writes</strong> — both user-initiated (set-value widgets, buttons, panel
logic) and automated writes from the control-logic engine.</li>
<li><strong>Interface changes</strong> — panels created, updated, or deleted.</li>
<li><strong>Control-logic changes</strong> — graphs created, updated, or deleted.</li>
</ul>
<p>Each entry records the time, the actor (username, or the graph name for automated
actions), the action, the affected data source / signal and value, the client address, and
whether it succeeded.</p>
<h3 class="help-h3">Viewing the log</h3>
<ul class="help-list">
<li>The viewer lists the most recent events first.</li>
<li>Filter by user, action, signal, or a start/end time range.</li>
<li>System (automated) actions are tagged distinctly from user actions.</li>
</ul>
<p class="help-callout">
Access to the audit log is gated by an allowlist (<code>audit.readers</code> in the config),
following the same model as logic editing: an empty list means every user may view it,
otherwise only the listed users or groups can. The 🛡 Audit button is hidden for users who
are not permitted.
</p>
</div>
);
}
function SectionWidgets() { function SectionWidgets() {
return ( return (
<div> <div>
@@ -858,8 +957,9 @@ function SectionShortcuts() {
); );
} }
const SECTION_COMPONENTS: Record<string, () => JSX.Element> = { const SECTION_COMPONENTS: Record<string, (props: { onNavigate: (section: string) => void }) => JSX.Element> = {
start: SectionStart, start: SectionStart,
tips: SectionTips,
view: SectionView, view: SectionView,
edit: SectionEdit, edit: SectionEdit,
widgets: SectionWidgets, widgets: SectionWidgets,
@@ -868,6 +968,7 @@ const SECTION_COMPONENTS: Record<string, () => JSX.Element> = {
logic: SectionLogic, logic: SectionLogic,
control: SectionControl, control: SectionControl,
sharing: SectionSharing, sharing: SectionSharing,
audit: SectionAudit,
history: SectionHistory, history: SectionHistory,
shortcuts: SectionShortcuts, shortcuts: SectionShortcuts,
}; };
@@ -877,7 +978,8 @@ const SECTION_COMPONENTS: Record<string, () => JSX.Element> = {
export default function HelpModal({ initialSection = 'start', onClose }: Props) { export default function HelpModal({ initialSection = 'start', onClose }: Props) {
const [active, setActive] = useState(initialSection); const [active, setActive] = useState(initialSection);
const Content = SECTION_COMPONENTS[active] ?? SectionStart; const Content: (props: { onNavigate: (section: string) => void }) => JSX.Element =
SECTION_COMPONENTS[active] ?? SectionStart;
// Close on backdrop click // Close on backdrop click
function handleBackdrop(e: MouseEvent) { function handleBackdrop(e: MouseEvent) {
@@ -915,7 +1017,7 @@ export default function HelpModal({ initialSection = 'start', onClose }: Props)
{/* Content area */} {/* Content area */}
<div class="help-content"> <div class="help-content">
<h2 class="help-h2">{SECTIONS.find(s => s.id === active)?.label}</h2> <h2 class="help-h2">{SECTIONS.find(s => s.id === active)?.label}</h2>
<Content /> <Content onNavigate={setActive} />
</div> </div>
</div> </div>
</div> </div>
+213 -53
View File
@@ -1,6 +1,6 @@
import { h, Fragment } from 'preact'; import { h, Fragment } from 'preact';
import { useState, useEffect, useRef } from 'preact/hooks'; import { useState, useEffect, useRef } from 'preact/hooks';
import SearchableSelect from './SearchableSelect'; import SignalPicker, { SignalOption } from './SignalPicker';
import { checkExpr } from './lib/expr'; import { checkExpr } from './lib/expr';
import type { LogicGraph, LogicNode, LogicNodeKind, LogicWire, StateVar, Widget } from './lib/types'; import type { LogicGraph, LogicNode, LogicNodeKind, LogicWire, StateVar, Widget } from './lib/types';
@@ -37,6 +37,11 @@ const NODE_W = 11.5 * REM; // node width
const PORT_TOP = 2 * REM; // y of the first port row, relative to node top const PORT_TOP = 2 * REM; // y of the first port row, relative to node top
const PORT_GAP = 1.375 * REM; // vertical spacing between stacked output ports const PORT_GAP = 1.375 * REM; // vertical spacing between stacked output ports
const PORT_R = 0.375 * REM; // port radius (half the 0.75rem dot) const PORT_R = 0.375 * REM; // port radius (half the 0.75rem dot)
// Ports sit inside the node's padding box, offset from the node border-box origin
// by the node borders (3px colored top accent, 1px sides). Anchors add the same
// offsets so wires meet the port centers, not their top edges.
const BORDER = 1;
const BORDER_TOP = 3;
interface PaletteEntry { interface PaletteEntry {
kind: LogicNodeKind; kind: LogicNodeKind;
@@ -65,6 +70,11 @@ const PALETTE: PaletteEntry[] = [
{ kind: 'action.dialog.info', label: 'Info dialog', params: { title: 'Info', message: '' } }, { kind: 'action.dialog.info', label: 'Info dialog', params: { title: 'Info', message: '' } },
{ kind: 'action.dialog.error', label: 'Error dialog', params: { title: 'Error', message: '' } }, { kind: 'action.dialog.error', label: 'Error dialog', params: { title: 'Error', message: '' } },
{ kind: 'action.dialog.setpoint', label: 'Set-point dialog', params: { title: 'Set value', message: '', fields: '[{"type":"input","label":"","target":"","default":""}]' } }, { kind: 'action.dialog.setpoint', label: 'Set-point dialog', params: { title: 'Set value', message: '', fields: '[{"type":"input","label":"","target":"","default":""}]' } },
{ kind: 'action.config.apply', label: 'Apply config', params: { instance: '', instanceSource: 'fixed', instanceVar: '' } },
{ kind: 'action.config.read', label: 'Read config', params: { instance: '', instanceSource: 'fixed', instanceVar: '', key: '', target: '' } },
{ kind: 'action.config.write', label: 'Write config', params: { instance: '', instanceSource: 'fixed', instanceVar: '', key: '', expr: '' } },
{ kind: 'action.config.create', label: 'Create config', params: { set: '', name: 'auto', from: '', target: '' } },
{ kind: 'action.config.snapshot', label: 'Snapshot config', params: { set: '', name: '', target: '' } },
]; ];
const KIND_LABEL: Record<LogicNodeKind, string> = { const KIND_LABEL: Record<LogicNodeKind, string> = {
@@ -88,6 +98,11 @@ const KIND_LABEL: Record<LogicNodeKind, string> = {
'action.dialog.info': 'Info dialog', 'action.dialog.info': 'Info dialog',
'action.dialog.error': 'Error dialog', 'action.dialog.error': 'Error dialog',
'action.dialog.setpoint': 'Set-point dialog', 'action.dialog.setpoint': 'Set-point dialog',
'action.config.apply': 'Apply config',
'action.config.read': 'Read config',
'action.config.write': 'Write config',
'action.config.create': 'Create config',
'action.config.snapshot': 'Snapshot config',
}; };
const THRESHOLD_OPS = ['>', '<', '>=', '<=', '==', '!='] as const; const THRESHOLD_OPS = ['>', '<', '>=', '<=', '==', '!='] as const;
@@ -114,11 +129,11 @@ function nodeHeight(kind: LogicNodeKind): number {
function genId(): string { return 'n_' + Math.random().toString(36).slice(2, 9); } function genId(): string { return 'n_' + Math.random().toString(36).slice(2, 9); }
// Port anchors in canvas coordinates. // Port anchors in canvas coordinates.
function inAnchor(n: LogicNode) { return { x: n.x, y: n.y + PORT_TOP }; } function inAnchor(n: LogicNode) { return { x: n.x + BORDER, y: n.y + BORDER_TOP + PORT_TOP }; }
function outAnchor(n: LogicNode, port: string) { function outAnchor(n: LogicNode, port: string) {
const ports = outputs(n.kind); const ports = outputs(n.kind);
const i = Math.max(0, ports.findIndex(p => p.id === port)); const i = Math.max(0, ports.findIndex(p => p.id === port));
return { x: n.x + NODE_W, y: n.y + PORT_TOP + i * PORT_GAP }; return { x: n.x + NODE_W - BORDER, y: n.y + BORDER_TOP + PORT_TOP + i * PORT_GAP };
} }
function wirePathStr(x1: number, y1: number, x2: number, y2: number): string { function wirePathStr(x1: number, y1: number, x2: number, y2: number): string {
@@ -135,6 +150,8 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
const [dataSources, setDataSources] = useState<string[]>([]); const [dataSources, setDataSources] = useState<string[]>([]);
const [dsSignals, setDsSignals] = useState<Record<string, string[]>>({}); const [dsSignals, setDsSignals] = useState<Record<string, string[]>>({});
const [configInstances, setConfigInstances] = useState<{ id: string; name: string }[]>([]);
const [configSets, setConfigSets] = useState<{ id: string; name: string }[]>([]);
const canvasRef = useRef<HTMLDivElement>(null); const canvasRef = useRef<HTMLDivElement>(null);
const dragNode = useRef<{ id: string; dx: number; dy: number; pushed: boolean } | null>(null); const dragNode = useRef<{ id: string; dx: number; dy: number; pushed: boolean } | null>(null);
@@ -160,6 +177,14 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
.then(r => r.ok ? r.json() : []) .then(r => r.ok ? r.json() : [])
.then((ds: DataSource[]) => setDataSources(ds.map(d => d.name))) .then((ds: DataSource[]) => setDataSources(ds.map(d => d.name)))
.catch(() => {}); .catch(() => {});
fetch('/api/v1/config/instances')
.then(r => r.ok ? r.json() : [])
.then((list: { id: string; name: string }[]) => setConfigInstances(list ?? []))
.catch(() => {});
fetch('/api/v1/config/sets')
.then(r => r.ok ? r.json() : [])
.then((list: { id: string; name: string }[]) => setConfigSets(list ?? []))
.catch(() => {});
}, []); }, []);
async function loadSignals(ds: string) { async function loadSignals(ds: string) {
@@ -180,6 +205,54 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
return dsSignals[ds] ?? []; return dsSignals[ds] ?? [];
} }
// Flatten every known ds:name into a single list for the fuzzy SignalPicker,
// and a hook to lazily load all data sources' signals when a picker opens.
function allSignalOptions(): SignalOption[] {
const out: SignalOption[] = [];
for (const ds of dsOptions) for (const name of signalOptions(ds)) out.push({ ds, name });
return out;
}
function openAllSignals() { dsOptions.forEach(ds => loadSignals(ds)); }
// The config-instance selector shared by the apply/read/write nodes. The
// instance can be fixed (chosen here at design time) or read at run time from
// a panel variable (instanceSource='var') — this is how a config-selector
// widget feeds these nodes the operator's current choice.
function instanceField(node: LogicNode) {
const src = node.params.instanceSource ?? 'fixed';
return (
<Fragment>
<div class="wizard-field">
<label>Instance source</label>
<select class="prop-select" value={src}
onChange={(e) => patchParams(node.id, { instanceSource: (e.target as HTMLSelectElement).value })}>
<option value="fixed">Fixed instance</option>
<option value="var">From variable</option>
</select>
</div>
{src === 'var' ? (
<div class="wizard-field">
<label>Instance-id variable</label>
<SignalPicker value={node.params.instanceVar ?? ''} options={allSignalOptions()}
onOpen={openAllSignals} allowFreeform
onChange={(ref) => patchParams(node.id, { instanceVar: ref })} />
<p class="hint">Reads the instance id (a string) from this panel variable e.g. the
output of a Config selector widget.</p>
</div>
) : (
<div class="wizard-field">
<label>Config instance</label>
<select class="prop-select" value={node.params.instance ?? ''}
onChange={(e) => patchParams(node.id, { instance: (e.target as HTMLSelectElement).value })}>
<option value="">choose</option>
{configInstances.map(ci => <option key={ci.id} value={ci.id}>{ci.name}</option>)}
</select>
</div>
)}
</Fragment>
);
}
const selected = nodes.find(n => n.id === selectedNode) ?? null; const selected = nodes.find(n => n.id === selectedNode) ?? null;
useEffect(() => { useEffect(() => {
@@ -520,19 +593,13 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
)} )}
{(selected.kind === 'trigger.threshold' || selected.kind === 'trigger.change') && (() => { {(selected.kind === 'trigger.threshold' || selected.kind === 'trigger.change') && (() => {
const { ds, name } = splitRef(selected.params.signal ?? '');
return ( return (
<Fragment> <Fragment>
<div class="wizard-field">
<label>Data source</label>
<SearchableSelect value={ds} options={dsOptions}
onSelect={(newDs) => { loadSignals(newDs); patchParams(selected.id, { signal: `${newDs}:` }); }} />
</div>
<div class="wizard-field"> <div class="wizard-field">
<label>Signal</label> <label>Signal</label>
<SearchableSelect value={name} options={signalOptions(ds)} <SignalPicker value={selected.params.signal ?? ''} options={allSignalOptions()}
onSelect={(sig) => patchParams(selected.id, { signal: `${ds}:${sig}` })} onOpen={openAllSignals}
placeholder={ds ? 'Search signals' : 'Select data source first'} /> onChange={(ref) => patchParams(selected.id, { signal: ref })} />
</div> </div>
{selected.kind === 'trigger.threshold' && ( {selected.kind === 'trigger.threshold' && (
<div class="wizard-field wizard-field-row"> <div class="wizard-field wizard-field-row">
@@ -573,7 +640,7 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
<ExprField label="Condition" value={selected.params.cond ?? ''} <ExprField label="Condition" value={selected.params.cond ?? ''}
onChange={(v) => patchParams(selected.id, { cond: v })} onChange={(v) => patchParams(selected.id, { cond: v })}
onInsert={(ds, sig) => insertRef(selected.id, 'cond', ds, sig)} onInsert={(ds, sig) => insertRef(selected.id, 'cond', ds, sig)}
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals} allSignals={allSignalOptions()} onOpenSignals={openAllSignals}
hint="True → 'then' port, false → 'else' port. e.g. {stub:level} > 5 && temp < 100. {sys:time}=epoch seconds, {sys:dt}=seconds since the trigger last fired." /> hint="True → 'then' port, false → 'else' port. e.g. {stub:level} > 5 && temp < 100. {sys:time}=epoch seconds, {sys:dt}=seconds since the trigger last fired." />
)} )}
@@ -597,36 +664,29 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
<ExprField label="While condition" value={selected.params.cond ?? ''} <ExprField label="While condition" value={selected.params.cond ?? ''}
onChange={(v) => patchParams(selected.id, { cond: v })} onChange={(v) => patchParams(selected.id, { cond: v })}
onInsert={(ds, sig) => insertRef(selected.id, 'cond', ds, sig)} onInsert={(ds, sig) => insertRef(selected.id, 'cond', ds, sig)}
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals} allSignals={allSignalOptions()} onOpenSignals={openAllSignals}
hint="Loops the 'body' port while true (capped). e.g. {stub:level} < 90" /> hint="Loops the 'body' port while true (capped). e.g. {stub:level} < 90" />
)} )}
<p class="hint">Runs the <b>body</b> port repeatedly, then continues on <b>done</b>.</p> <p class="hint">Runs the <b>body</b> port repeatedly, then continues on <b>done</b>.</p>
</Fragment> </Fragment>
)} )}
{selected.kind === 'action.write' && (() => { {selected.kind === 'action.write' && (
const { ds, name } = splitRef(selected.params.target ?? '');
return (
<Fragment> <Fragment>
<div class="wizard-field"> <div class="wizard-field">
<label>Target data source</label> <label>Target signal / variable</label>
<SearchableSelect value={ds} options={dsOptions} <SignalPicker value={selected.params.target ?? ''} options={allSignalOptions()}
onSelect={(newDs) => { loadSignals(newDs); patchParams(selected.id, { target: `${newDs}:` }); }} /> onOpen={openAllSignals} allowFreeform
</div> onChange={(ref) => patchParams(selected.id, { target: ref })} />
<div class="wizard-field"> <p class="hint">Pick a signal, or type <code>local:name</code> to write a panel-local variable.</p>
<label>Target signal</label>
<SearchableSelect value={name} options={signalOptions(ds)}
onSelect={(sig) => patchParams(selected.id, { target: `${ds}:${sig}` })}
placeholder={ds ? 'Search signals…' : 'Select data source first'} />
</div> </div>
<ExprField label="Value (expression)" value={selected.params.expr ?? ''} <ExprField label="Value (expression)" value={selected.params.expr ?? ''}
onChange={(v) => patchParams(selected.id, { expr: v })} onChange={(v) => patchParams(selected.id, { expr: v })}
onInsert={(ds2, sig) => insertRef(selected.id, 'expr', ds2, sig)} onInsert={(ds2, sig) => insertRef(selected.id, 'expr', ds2, sig)}
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals} allSignals={allSignalOptions()} onOpenSignals={openAllSignals}
hint="e.g. ({stub:a} - {stub:b}) / {sys:dt} for a rate — or a constant like 42. {sys:time}=epoch seconds, {sys:dt}=seconds since the trigger last fired." /> hint="e.g. ({stub:a} - {stub:b}) / {sys:dt} for a rate — or a constant like 42. {sys:time}=epoch seconds, {sys:dt}=seconds since the trigger last fired." />
</Fragment> </Fragment>
); )}
})()}
{selected.kind === 'action.delay' && ( {selected.kind === 'action.delay' && (
<div class="wizard-field"> <div class="wizard-field">
@@ -636,6 +696,114 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
</div> </div>
)} )}
{selected.kind === 'action.config.apply' && (
<Fragment>
{instanceField(selected)}
<p class="hint">Applies the instance's current values to every bound signal (like the
Apply button in the config manager).</p>
</Fragment>
)}
{selected.kind === 'action.config.read' && (
<Fragment>
{instanceField(selected)}
<div class="wizard-field">
<label>Parameter key</label>
<input class="prop-input" value={selected.params.key ?? ''}
placeholder="parameter key"
onInput={(e) => patchParams(selected.id, { key: (e.target as HTMLInputElement).value })} />
</div>
<div class="wizard-field">
<label>Target signal / variable</label>
<SignalPicker value={selected.params.target ?? ''} options={allSignalOptions()}
onOpen={openAllSignals} allowFreeform
onChange={(ref) => patchParams(selected.id, { target: ref })} />
<p class="hint">Reads the named parameter's value and writes it to this target
(numeric values only).</p>
</div>
</Fragment>
)}
{selected.kind === 'action.config.write' && (
<Fragment>
{instanceField(selected)}
<div class="wizard-field">
<label>Parameter key</label>
<input class="prop-input" value={selected.params.key ?? ''}
placeholder="parameter key"
onInput={(e) => patchParams(selected.id, { key: (e.target as HTMLInputElement).value })} />
</div>
<ExprField label="Value (expression)" value={selected.params.expr ?? ''}
onChange={(v) => patchParams(selected.id, { expr: v })}
onInsert={(ds2, sig) => insertRef(selected.id, 'expr', ds2, sig)}
allSignals={allSignalOptions()} onOpenSignals={openAllSignals}
hint="Stores the value into the named parameter, creating a new instance revision (numeric values only)." />
</Fragment>
)}
{selected.kind === 'action.config.create' && (
<Fragment>
<div class="wizard-field">
<label>Config set</label>
<select class="prop-select" value={selected.params.set ?? ''}
onChange={(e) => patchParams(selected.id, { set: (e.target as HTMLSelectElement).value })}>
<option value="">choose</option>
{configSets.map(cs => <option key={cs.id} value={cs.id}>{cs.name}</option>)}
</select>
</div>
<div class="wizard-field">
<label>New instance name</label>
<input class="prop-input" value={selected.params.name ?? ''}
placeholder="auto"
onInput={(e) => patchParams(selected.id, { name: (e.target as HTMLInputElement).value })} />
</div>
<div class="wizard-field">
<label>Copy values from</label>
<select class="prop-select" value={selected.params.from ?? ''}
onChange={(e) => patchParams(selected.id, { from: (e.target as HTMLSelectElement).value })}>
<option value="">defaults (empty)</option>
{configInstances.map(ci => <option key={ci.id} value={ci.id}>{ci.name}</option>)}
</select>
</div>
<div class="wizard-field">
<label>Write new id to variable</label>
<SignalPicker value={selected.params.target ?? ''} options={allSignalOptions()}
onOpen={openAllSignals} allowFreeform
onChange={(ref) => patchParams(selected.id, { target: ref })} />
<p class="hint">Creates a new instance for the chosen set (optionally seeded from another
instance) and writes its id to this panel variable, ready to feed apply/read/write
nodes set to "From variable".</p>
</div>
</Fragment>
)}
{selected.kind === 'action.config.snapshot' && (
<Fragment>
<div class="wizard-field">
<label>Config set</label>
<select class="prop-select" value={selected.params.set ?? ''}
onChange={(e) => patchParams(selected.id, { set: (e.target as HTMLSelectElement).value })}>
<option value="">choose</option>
{configSets.map(cs => <option key={cs.id} value={cs.id}>{cs.name}</option>)}
</select>
</div>
<div class="wizard-field">
<label>New instance name</label>
<input class="prop-input" value={selected.params.name ?? ''}
placeholder="(auto)"
onInput={(e) => patchParams(selected.id, { name: (e.target as HTMLInputElement).value })} />
</div>
<div class="wizard-field">
<label>Write new id to variable</label>
<SignalPicker value={selected.params.target ?? ''} options={allSignalOptions()}
onOpen={openAllSignals} allowFreeform
onChange={(ref) => patchParams(selected.id, { target: ref })} />
<p class="hint">Reads the current live value of every target signal of the set and stores
them as a new instance, then writes the new instance id to this panel variable.</p>
</div>
</Fragment>
)}
{selected.kind === 'action.accumulate' && ( {selected.kind === 'action.accumulate' && (
<Fragment> <Fragment>
<div class="wizard-field"> <div class="wizard-field">
@@ -647,7 +815,7 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
<ExprField label="Value (expression)" value={selected.params.expr ?? ''} <ExprField label="Value (expression)" value={selected.params.expr ?? ''}
onChange={(v) => patchParams(selected.id, { expr: v })} onChange={(v) => patchParams(selected.id, { expr: v })}
onInsert={(ds2, sig) => insertRef(selected.id, 'expr', ds2, sig)} onInsert={(ds2, sig) => insertRef(selected.id, 'expr', ds2, sig)}
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals} allSignals={allSignalOptions()} onOpenSignals={openAllSignals}
hint="Appends this value (with a timestamp) to the array. e.g. {stub:level}, or {sys:time} for the clock." /> hint="Appends this value (with a timestamp) to the array. e.g. {stub:level}, or {sys:time} for the clock." />
</Fragment> </Fragment>
)} )}
@@ -683,7 +851,7 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
<ExprField label="Value (expression)" value={selected.params.expr ?? ''} <ExprField label="Value (expression)" value={selected.params.expr ?? ''}
onChange={(v) => patchParams(selected.id, { expr: v })} onChange={(v) => patchParams(selected.id, { expr: v })}
onInsert={(ds2, sig) => insertRef(selected.id, 'expr', ds2, sig)} onInsert={(ds2, sig) => insertRef(selected.id, 'expr', ds2, sig)}
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals} allSignals={allSignalOptions()} onOpenSignals={openAllSignals}
hint="Logs this value to the browser console — handy for debugging a flow." /> hint="Logs this value to the browser console — handy for debugging a flow." />
</Fragment> </Fragment>
)} )}
@@ -747,7 +915,7 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
<DialogFieldsEditor allowInput={false} <DialogFieldsEditor allowInput={false}
fields={parseDialogFields(selected)} fields={parseDialogFields(selected)}
onChange={(f) => patchParams(selected.id, { fields: JSON.stringify(f) })} onChange={(f) => patchParams(selected.id, { fields: JSON.stringify(f) })}
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals} /> allSignals={allSignalOptions()} onOpenSignals={openAllSignals} />
</Fragment> </Fragment>
)} )}
@@ -768,7 +936,7 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
<DialogFieldsEditor allowInput={true} <DialogFieldsEditor allowInput={true}
fields={parseDialogFields(selected)} fields={parseDialogFields(selected)}
onChange={(f) => patchParams(selected.id, { fields: JSON.stringify(f) })} onChange={(f) => patchParams(selected.id, { fields: JSON.stringify(f) })}
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals} /> allSignals={allSignalOptions()} onOpenSignals={openAllSignals} />
<p class="hint">Each <b>input</b> prompts the operator and writes the entered number to its <p class="hint">Each <b>input</b> prompts the operator and writes the entered number to its
target on OK; each <b>display</b> shows a live value. Cancel aborts the flow.</p> target on OK; each <b>display</b> shows a live value. Cancel aborts the flow.</p>
</Fragment> </Fragment>
@@ -796,17 +964,15 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
} }
// Expression input with a signal-insert helper and live validation. // Expression input with a signal-insert helper and live validation.
function ExprField({ label, value, onChange, onInsert, dsOptions, signalOptions, loadSignals, hint }: { function ExprField({ label, value, onChange, onInsert, allSignals, onOpenSignals, hint }: {
label: string; label: string;
value: string; value: string;
onChange: (v: string) => void; onChange: (v: string) => void;
onInsert: (ds: string, sig: string) => void; onInsert: (ds: string, sig: string) => void;
dsOptions: string[]; allSignals: SignalOption[];
signalOptions: (ds: string) => string[]; onOpenSignals: () => void;
loadSignals: (ds: string) => void;
hint: string; hint: string;
}) { }) {
const [insDs, setInsDs] = useState('');
const err = checkExpr(value); const err = checkExpr(value);
return ( return (
<div class="wizard-field"> <div class="wizard-field">
@@ -816,11 +982,9 @@ function ExprField({ label, value, onChange, onInsert, dsOptions, signalOptions,
onInput={(e) => onChange((e.target as HTMLInputElement).value)} /> onInput={(e) => onChange((e.target as HTMLInputElement).value)} />
{err && <p class="wizard-error">{err}</p>} {err && <p class="wizard-error">{err}</p>}
<div class="flow-expr-insert"> <div class="flow-expr-insert">
<SearchableSelect value={insDs} options={dsOptions} <SignalPicker value="" options={allSignals} onOpen={onOpenSignals}
onSelect={(ds) => { setInsDs(ds); loadSignals(ds); }} placeholder="ds…" /> placeholder="+ insert signal"
<SearchableSelect value="" options={signalOptions(insDs)} onChange={(ref) => { const s = splitRef(ref); onInsert(s.ds, s.name); }} />
onSelect={(sig) => onInsert(insDs, sig)}
placeholder={insDs ? '+ insert signal' : 'pick ds first'} />
</div> </div>
<p class="hint">{hint}</p> <p class="hint">{hint}</p>
</div> </div>
@@ -934,13 +1098,12 @@ function parseDialogFields(n: LogicNode): DialogFieldSpec[] {
return []; return [];
} }
function DialogFieldsEditor({ allowInput, fields, onChange, dsOptions, signalOptions, loadSignals }: { function DialogFieldsEditor({ allowInput, fields, onChange, allSignals, onOpenSignals }: {
allowInput: boolean; allowInput: boolean;
fields: DialogFieldSpec[]; fields: DialogFieldSpec[];
onChange: (fields: DialogFieldSpec[]) => void; onChange: (fields: DialogFieldSpec[]) => void;
dsOptions: string[]; allSignals: SignalOption[];
signalOptions: (ds: string) => string[]; onOpenSignals: () => void;
loadSignals: (ds: string) => void;
}) { }) {
function patch(i: number, patch: Partial<DialogFieldSpec>) { function patch(i: number, patch: Partial<DialogFieldSpec>) {
onChange(fields.map((f, j) => (j === i ? { ...f, ...patch } : f))); onChange(fields.map((f, j) => (j === i ? { ...f, ...patch } : f)));
@@ -953,7 +1116,6 @@ function DialogFieldsEditor({ allowInput, fields, onChange, dsOptions, signalOpt
<label>{allowInput ? 'Fields' : 'Displayed values'}</label> <label>{allowInput ? 'Fields' : 'Displayed values'}</label>
{fields.length === 0 && <p class="hint">None yet.</p>} {fields.length === 0 && <p class="hint">None yet.</p>}
{fields.map((f, i) => { {fields.map((f, i) => {
const { ds, name } = splitRef(f.target ?? '');
return ( return (
<div key={i} class="flow-field-edit"> <div key={i} class="flow-field-edit">
<div class="flow-field-head"> <div class="flow-field-head">
@@ -972,11 +1134,9 @@ function DialogFieldsEditor({ allowInput, fields, onChange, dsOptions, signalOpt
{f.type === 'input' ? ( {f.type === 'input' ? (
<Fragment> <Fragment>
<div class="flow-row-edit"> <div class="flow-row-edit">
<SearchableSelect value={ds} options={dsOptions} <SignalPicker value={f.target ?? ''} options={allSignals}
onSelect={(newDs) => { loadSignals(newDs); patch(i, { target: `${newDs}:` }); }} placeholder="ds…" /> onOpen={onOpenSignals} allowFreeform placeholder="target signal…"
<SearchableSelect value={name} options={signalOptions(ds)} onChange={(ref) => patch(i, { target: ref })} />
onSelect={(sig) => patch(i, { target: `${ds}:${sig}` })}
placeholder={ds ? 'signal…' : 'pick ds'} />
</div> </div>
<input class={`prop-input${checkExpr(f.default ?? '') ? ' prop-input-error' : ''}`} <input class={`prop-input${checkExpr(f.default ?? '') ? ' prop-input-error' : ''}`}
value={f.default ?? ''} placeholder="default value (expression, optional)" value={f.default ?? ''} placeholder="default value (expression, optional)"
+45
View File
@@ -55,6 +55,17 @@ const MULTI_SIGNAL_WIDGETS = new Set(['plot', 'multiled']);
export default function PropertiesPane({ selected, multiCount, iface, onChange, onIfaceChange, width }: Props) { export default function PropertiesPane({ selected, multiCount, iface, onChange, onIfaceChange, width }: Props) {
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const [configSets, setConfigSets] = useState<{ id: string; name: string }[]>([]);
// Config sets are only needed by the Config Selector widget; fetch lazily the
// first time one is selected.
useEffect(() => {
if (selected?.type !== 'configselect' || configSets.length > 0) return;
fetch('/api/v1/config/sets')
.then(r => r.ok ? r.json() : [])
.then((list: { id: string; name: string }[]) => setConfigSets(list ?? []))
.catch(() => {});
}, [selected?.type]);
function setOpt(key: string, value: string) { function setOpt(key: string, value: string) {
if (!selected) return; if (!selected) return;
@@ -302,6 +313,7 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
<option value="fft">FFT</option> <option value="fft">FFT</option>
<option value="waterfall">Waterfall</option> <option value="waterfall">Waterfall</option>
<option value="logic">Logic analyser</option> <option value="logic">Logic analyser</option>
<option value="waveform">Waveform (array)</option>
</select> </select>
</Field> </Field>
{(w.options['plotType'] ?? 'timeseries') === 'timeseries' && ( {(w.options['plotType'] ?? 'timeseries') === 'timeseries' && (
@@ -359,6 +371,39 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
</Field> </Field>
</div> </div>
)} )}
{w.type === 'configselect' && (
<div>
<Field label="Label">
<TextInput value={w.options['label'] ?? 'Config'} onCommit={(v) => setOpt('label', v)} />
</Field>
<Field label="Config set">
<select class="prop-select" value={w.options['set'] ?? ''}
onChange={(e) => setOpt('set', (e.target as HTMLSelectElement).value)}>
<option value="">choose</option>
{configSets.map(cs => <option key={cs.id} value={cs.id}>{cs.name}</option>)}
</select>
</Field>
<Field label="Output variable">
<div class="prop-field-col">
<select class="prop-input" value={w.options['output'] ?? ''}
onChange={(e) => setOpt('output', (e.target as HTMLSelectElement).value)}>
<option value="">(none)</option>
{(iface.statevars ?? []).map(v => (
<option key={v.name} value={v.name}>{v.name}</option>
))}
</select>
<span class="prop-hint">A panel variable (define one of type "string" in the Variables tab); receives the selected instance id.</span>
</div>
</Field>
<Field label="Instance subset">
<div class="prop-field-col">
<TextInput value={w.options['instances'] ?? ''} onCommit={(v) => setOpt('instances', v)} />
<span class="prop-hint">Comma-separated instance ids to show. Empty = all instances of the set.</span>
</div>
</Field>
</div>
)}
</div> </div>
)} )}
+113
View File
@@ -0,0 +1,113 @@
import { h } from 'preact';
import { useState, useMemo } from 'preact/hooks';
// A single fuzzy-finder input for picking a signal as one "ds:name" reference,
// replacing the old stacked "Data source" + "Signal" select pair. The caller
// supplies a flattened option list (every ds:name it knows about) and, via
// onOpen, a hook to lazily load signals for all data sources when the dropdown
// is first opened. When allowFreeform is set, typing a value that matches no
// option (e.g. a new srv:/local: variable) can still be committed.
export interface SignalOption { ds: string; name: string; }
export default function SignalPicker({
value,
options,
onChange,
onOpen,
placeholder = 'Search signals…',
allowFreeform = false,
}: {
value: string; // current "ds:name" (or "")
options: SignalOption[];
onChange: (ref: string) => void;
onOpen?: () => void;
placeholder?: string;
allowFreeform?: boolean;
}) {
const [open, setOpen] = useState(false);
const [filter, setFilter] = useState('');
const filtered = useMemo(() => {
const tokens = filter.toLowerCase().split(/\s+/).filter(Boolean);
const scored = options
.map(o => ({ o, label: `${o.ds}:${o.name}` }))
.filter(({ label }) => {
const l = label.toLowerCase();
return tokens.every(t => l.includes(t));
});
// Prefer matches where the signal name (not the ds prefix) leads.
const f = filter.toLowerCase();
scored.sort((a, b) => {
const an = a.o.name.toLowerCase().startsWith(f) ? 0 : 1;
const bn = b.o.name.toLowerCase().startsWith(f) ? 0 : 1;
if (an !== bn) return an - bn;
return a.label.localeCompare(b.label);
});
return scored.slice(0, 200);
}, [options, filter]);
function openDropdown() {
setOpen(true);
setFilter('');
onOpen?.();
}
function commit(ref: string) {
onChange(ref);
setOpen(false);
setFilter('');
}
// A freeform entry is offered when the typed text looks like a usable
// reference and is not already an exact option.
const freeform = allowFreeform && filter.trim() !== ''
&& !filtered.some(({ label }) => label === filter.trim());
return (
<div class="search-select">
<div class="search-select-trigger" onClick={() => (open ? setOpen(false) : openDropdown())}>
{value || <span class="search-select-placeholder">{placeholder}</span>}
<span class="search-select-arrow">{open ? '▴' : '▾'}</span>
</div>
{open && (
<div class="search-select-dropdown">
<input
class="prop-input"
autoFocus
placeholder="Type to search… (ds:name)"
value={filter}
onInput={(e) => setFilter((e.target as HTMLInputElement).value)}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e: KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
if (filtered.length > 0) commit(`${filtered[0].o.ds}:${filtered[0].o.name}`);
else if (freeform) commit(filter.trim());
}
if (e.key === 'Escape') { e.preventDefault(); setOpen(false); }
}}
/>
<div class="search-select-list">
{freeform && (
<div class="search-select-item" onClick={() => commit(filter.trim())}>
Use {filter.trim()}
</div>
)}
{filtered.length === 0 && !freeform && <div class="search-select-item hint">No matches</div>}
{filtered.map(({ o, label }) => (
<div
key={label}
class={`search-select-item${label === value ? ' search-select-item-selected' : ''}`}
onClick={() => commit(`${o.ds}:${o.name}`)}
>
{label}
</div>
))}
</div>
</div>
)}
{open && <div class="search-select-backdrop" onClick={() => setOpen(false)} />}
</div>
);
}
+17 -22
View File
@@ -1,10 +1,10 @@
import { h, Fragment } from 'preact'; import { h, Fragment } from 'preact';
import { useState, useEffect, useRef, useMemo } from 'preact/hooks'; import { useState, useEffect, useRef, useMemo } from 'preact/hooks';
import type { SignalRef, StateVar } from './lib/types'; import type { SignalRef, StateVar } from './lib/types';
import SyntheticWizard from './SyntheticWizard';
import SyntheticGraphEditor from './SyntheticGraphEditor'; import SyntheticGraphEditor from './SyntheticGraphEditor';
import GroupsTree from './GroupsTree'; import GroupsTree from './GroupsTree';
import ChannelFinderModal from './ChannelFinderModal'; import ChannelFinderModal from './ChannelFinderModal';
import SignalPicker from './SignalPicker';
type TreeTab = 'sources' | 'groups'; type TreeTab = 'sources' | 'groups';
type GroupBy = 'datasource' | 'area' | 'system' | 'ioc'; type GroupBy = 'datasource' | 'area' | 'system' | 'ioc';
@@ -491,10 +491,11 @@ export default function SignalTree({ onDragStart, width, panelId, statevars, onS
)} )}
{showWizard && ( {showWizard && (
<SyntheticWizard <SyntheticGraphEditor
currentIfaceId={panelId} create
panelId={panelId}
onClose={() => setShowWizard(false)} onClose={() => setShowWizard(false)}
onCreated={loadAllSignals} onSaved={loadAllSignals}
/> />
)} )}
@@ -523,24 +524,18 @@ export default function SignalTree({ onDragStart, width, panelId, statevars, onS
</div> </div>
<div class="wizard-body"> <div class="wizard-body">
<div class="wizard-field"> <div class="wizard-field">
<label>Data Source</label> <label>Signal</label>
<select class="prop-select" value={manualDs} onChange={e => setManualDs((e.target as HTMLSelectElement).value)}> <SignalPicker
{Array.from(new Set(allSignals.map(s => s.ds))).map(ds => ( value={manualName ? `${manualDs}:${manualName}` : ''}
<option key={ds} value={ds}>{ds}</option> options={allSignals}
))} allowFreeform
{!allSignals.some(s => s.ds === 'epics') && <option value="epics">epics</option>} placeholder="Search or type ds:NAME…"
</select> onChange={(ref) => {
</div> const i = ref.indexOf(':');
<div class="wizard-field"> if (i < 0) { setManualDs(ref); setManualName(''); }
<label>Signal / PV Name</label> else { setManualDs(ref.slice(0, i)); setManualName(ref.slice(i + 1)); }
<input }} />
class="prop-input" <p class="hint">Pick an existing signal, or type <code>epics:MY:PV:NAME</code> to add a new one.</p>
autoFocus
placeholder="e.g. MY:PV:NAME"
value={manualName}
onInput={e => setManualName((e.target as HTMLInputElement).value)}
onKeyDown={e => e.key === 'Enter' && handleManualAdd()}
/>
</div> </div>
</div> </div>
<div class="wizard-footer"> <div class="wizard-footer">
File diff suppressed because it is too large Load Diff
-279
View File
@@ -1,279 +0,0 @@
import { h } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import LuaEditor from './LuaEditor';
import SearchableSelect from './SearchableSelect';
import type { InputRef, SignalDef } from './lib/types';
interface Props {
onClose: () => void;
onCreated: () => void;
// Interface id the wizard was opened from — used to bind panel-scoped signals.
currentIfaceId?: string;
}
interface NodeParam {
label: string;
key: string;
type: 'number' | 'text' | 'lua';
default: string;
}
const NODE_TYPES: Array<{ type: string; label: string; params: NodeParam[] }> = [
{ type: 'gain', label: 'Gain', params: [{ label: 'Factor', key: 'gain', type: 'number', default: '1' }] },
{ type: 'offset', label: 'Offset', params: [{ label: 'Offset', key: 'offset', type: 'number', default: '0' }] },
{ type: 'moving_average', label: 'Moving Average', params: [{ label: 'Window (n)', key: 'window', type: 'number', default: '10' }] },
{ type: 'rms', label: 'RMS', params: [{ label: 'Window (n)', key: 'window', type: 'number', default: '10' }] },
{ type: 'lowpass', label: 'Low-pass Filter', params: [
{ label: 'Cutoff freq (Hz)', key: 'freq', type: 'number', default: '1' },
{ label: 'Order (18)', key: 'order', type: 'number', default: '1' },
]},
{ type: 'derivative', label: 'Derivative', params: [] },
{ type: 'integrate', label: 'Integrate', params: [] },
{ type: 'clamp', label: 'Clamp', params: [{ label: 'Min', key: 'min', type: 'number', default: '0' }, { label: 'Max', key: 'max', type: 'number', default: '100' }] },
{ type: 'expr', label: 'Formula', params: [{ label: 'Expression (a,b,c,d = inputs)', key: 'expr', type: 'text', default: 'a * 1.0' }] },
{ type: 'lua', label: 'Lua Script', params: [{ label: 'Script (a,b,c,d = inputs)', key: 'script', type: 'lua', default: 'return a' }] },
];
interface DataSource {
name: string;
}
interface SignalInfo {
name: string;
description?: string;
}
// ── Main Wizard ──────────────────────────────────────────────────────────────
export default function SyntheticWizard({ onClose, onCreated, currentIfaceId }: Props) {
const [name, setName] = useState('');
const [inputs, setInputs] = useState<InputRef[]>([{ ds: 'stub', signal: 'sine_1hz' }]);
const [nodeType, setNodeType] = useState('gain');
const [params, setParams] = useState<Record<string, string>>({});
// Visibility scope. Panel scope requires an interface to bind to, so when the
// wizard is opened outside a saved panel it falls back to 'user'.
const [visibility, setVisibility] = useState<'panel' | 'user' | 'global'>(currentIfaceId ? 'panel' : 'user');
const [unit, setUnit] = useState('');
const [desc, setDesc] = useState('');
const [dispLow, setDispLow] = useState('0');
const [dispHigh, setDispHigh] = useState('100');
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
// Loaded data
const [dataSources, setDataSources] = useState<string[]>([]);
const [dsSignals, setDsSignals] = useState<Record<string, string[]>>({});
useEffect(() => {
fetch('/api/v1/datasources')
.then(r => r.ok ? r.json() : [])
.then((ds: DataSource[]) => setDataSources(ds.map(d => d.name)))
.catch(() => {});
}, []);
async function loadSignals(ds: string) {
if (dsSignals[ds]) return;
try {
const res = await fetch(`/api/v1/signals?ds=${encodeURIComponent(ds)}`);
if (!res.ok) return;
const sigs: SignalInfo[] = await res.json();
setDsSignals(prev => ({ ...prev, [ds]: sigs.map(s => s.name) }));
} catch {}
}
const nodeDef = NODE_TYPES.find(n => n.type === nodeType)!;
function getParam(key: string, def: string): string {
return params[key] ?? def;
}
function setParam(key: string, val: string) {
setParams(p => ({ ...p, [key]: val }));
}
function updateInput(idx: number, patch: Partial<InputRef>) {
setInputs(prev => prev.map((inp, i) => {
if (i !== idx) return inp;
const next = { ...inp, ...patch };
if (patch.ds) {
next.signal = '';
loadSignals(patch.ds);
}
return next;
}));
}
function addInput() {
setInputs(prev => [...prev, { ds: dataSources[0] || '', signal: '' }]);
}
function removeInput(idx: number) {
setInputs(prev => prev.filter((_, i) => i !== idx));
}
async function handleCreate() {
if (!name.trim()) { setError('Name is required'); return; }
if (inputs.some(i => !i.ds || !i.signal)) { setError('All inputs must have a data source and signal'); return; }
const nodeParams: Record<string, any> = {};
for (const p of nodeDef.params) {
const raw = getParam(p.key, p.default);
nodeParams[p.key] = p.type === 'number' ? parseFloat(raw) : raw;
}
const def: SignalDef = {
name: name.trim(),
inputs,
pipeline: [{ type: nodeType, params: nodeParams }],
meta: {
unit: unit || undefined,
description: desc || undefined,
displayLow: parseFloat(dispLow) || 0,
displayHigh: parseFloat(dispHigh) || 100,
},
visibility,
...(visibility === 'panel' && currentIfaceId ? { panel: currentIfaceId } : {}),
};
setSaving(true);
setError('');
try {
const res = await fetch('/api/v1/synthetic', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(def),
});
if (!res.ok) {
const j = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(j.error ?? res.statusText);
}
onCreated();
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setSaving(false);
}
}
return (
<div class="wizard-backdrop" onClick={onClose}>
<div class="wizard" onClick={(e) => e.stopPropagation()}>
<div class="wizard-header">
<span>New Synthetic Signal</span>
<button class="icon-btn" onClick={onClose}></button>
</div>
<div class="wizard-body">
{error && <p class="wizard-error">{error}</p>}
<div class="wizard-section-title">Signal identity</div>
<div class="wizard-field">
<label>Name</label>
<input class="prop-input" value={name}
placeholder="e.g. smoothed_pressure"
onInput={(e) => setName((e.target as HTMLInputElement).value)} />
</div>
<div class="wizard-field">
<label>Visibility</label>
<select class="prop-select" value={visibility}
onChange={(e) => setVisibility((e.target as HTMLSelectElement).value as any)}>
<option value="panel" disabled={!currentIfaceId}>
This panel only{currentIfaceId ? '' : ' (save panel first)'}
</option>
<option value="user">All my panels</option>
<option value="global">All panels (global)</option>
</select>
</div>
<div class="wizard-section-title">Input signals</div>
{inputs.map((inp, idx) => (
<div key={idx} class="wizard-field wizard-field-row" style="align-items: flex-end; gap: 0.5rem; margin-bottom: 0.5rem;">
<div class="wizard-field" style="flex:1;">
<label>Data source</label>
<SearchableSelect
value={inp.ds}
options={dataSources}
onSelect={(ds) => updateInput(idx, { ds })}
/>
</div>
<div class="wizard-field" style="flex:2;">
<label>Signal</label>
<SearchableSelect
value={inp.signal}
options={dsSignals[inp.ds] || []}
onSelect={(signal) => updateInput(idx, { signal })}
placeholder={inp.ds ? 'Search signals…' : 'Select data source first'}
/>
</div>
<button
class="icon-btn"
style="margin-bottom: 0.25rem;"
title="Remove input"
onClick={() => removeInput(idx)}
disabled={inputs.length === 1}
></button>
</div>
))}
<button class="panel-btn" style="margin-top: 0.5rem;" onClick={addInput}>+ Add input</button>
<div class="wizard-section-title" style="margin-top: 1.5rem;">Processing</div>
<div class="wizard-field">
<label>Node type</label>
<select class="prop-select" value={nodeType}
onChange={(e) => { setNodeType((e.target as HTMLSelectElement).value); setParams({}); }}>
{NODE_TYPES.map(n => <option key={n.type} value={n.type}>{n.label}</option>)}
</select>
</div>
{nodeDef.params.map(p => (
<div key={p.key} class="wizard-field">
<label>{p.label}</label>
{p.type === 'lua' ? (
<LuaEditor
value={getParam(p.key, p.default)}
onChange={(v) => setParam(p.key, v)}
/>
) : (
<input class="prop-input" type={p.type === 'number' ? 'number' : 'text'}
value={getParam(p.key, p.default)}
onInput={(e) => setParam(p.key, (e.target as HTMLInputElement).value)} />
)}
</div>
))}
<div class="wizard-section-title">Metadata (optional)</div>
<div class="wizard-field wizard-field-row">
<div class="wizard-field">
<label>Unit</label>
<input class="prop-input" value={unit} placeholder="m/s"
onInput={(e) => setUnit((e.target as HTMLInputElement).value)} />
</div>
<div class="wizard-field">
<label>Display low</label>
<input class="prop-input" type="number" value={dispLow}
onInput={(e) => setDispLow((e.target as HTMLInputElement).value)} />
</div>
<div class="wizard-field">
<label>Display high</label>
<input class="prop-input" type="number" value={dispHigh}
onInput={(e) => setDispHigh((e.target as HTMLInputElement).value)} />
</div>
</div>
<div class="wizard-field">
<label>Description</label>
<input class="prop-input" value={desc} placeholder="Optional description"
onInput={(e) => setDesc((e.target as HTMLInputElement).value)} />
</div>
</div>
<div class="wizard-footer">
<button class="toolbar-btn" onClick={onClose}>Cancel</button>
<button class="toolbar-btn toolbar-btn-primary" onClick={handleCreate} disabled={saving}>
{saving ? 'Creating…' : 'Create Signal'}
</button>
</div>
</div>
</div>
);
}
+227
View File
@@ -0,0 +1,227 @@
import { h } from 'preact';
import { useState, useEffect, useCallback } from 'preact/hooks';
import { diffLines, diffStats, sideBySide, DiffRow } from './lib/linediff';
// VersionMeta mirrors the Go VersionMeta returned by every versioned document
// type (interfaces, synthetic, controllogic, config sets/instances).
export interface VersionMeta {
version: number;
name: string;
tag?: string;
current: boolean;
savedAt: string;
}
function errMsg(e: unknown): string { return e instanceof Error ? e.message : String(e); }
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())}`;
}
async function fetchJSON<T = any>(url: string, opts?: RequestInit): Promise<T | null> {
const res = await fetch(url, opts);
if (!res.ok && res.status !== 204) {
let m = `HTTP ${res.status}`;
try { const e = await res.json(); if (e && e.error) m = e.error; } catch { /* ignore */ }
throw new Error(m);
}
if (res.status === 204) return null;
return res.json();
}
// fetchVersionText returns a revision's serialized content as text, pretty-
// printing JSON so structural changes produce clean, stable line diffs (XML
// panels are returned verbatim).
async function fetchVersionText(base: string, v: number): Promise<string> {
const res = await fetch(`${base}/versions/${v}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const raw = await res.text();
try { return JSON.stringify(JSON.parse(raw), null, 2); } catch { return raw; }
}
// ── Version tree ──────────────────────────────────────────────────────────────
export function VersionTree({ base, viewing, dirty, canWrite = true, onView, onForked, onPromoted, onCompare, onError, reloadKey }: {
base: string; // e.g. /api/v1/synthetic/<name>
viewing: number | null; // version currently open in the editor (active)
dirty?: boolean; // unsaved edits → dashed connector at the top
canWrite?: boolean; // gate promote/fork affordances
onView: (version: number) => void;
onForked: (newId: string) => void;
onPromoted: (version: number) => void;
onCompare: (av: number, bv: number) => void;
onError: (m: string) => void;
reloadKey?: number; // bump to force a versions reload
}) {
const [versions, setVersions] = useState<VersionMeta[]>([]);
const [a, setA] = useState<number | null>(null);
const [b, setB] = useState<number | null>(null);
const load = useCallback(async () => {
try {
const v = (await fetchJSON<VersionMeta[]>(`${base}/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 (e) { onError(`Versions failed: ${errMsg(e)}`); }
}, [base, onError]);
useEffect(() => { load(); }, [load, reloadKey]);
async function fork(version: number) {
try {
const obj = await fetchJSON<{ id: string }>(`${base}/versions/${version}/fork`, { method: 'POST' });
if (obj?.id) onForked(obj.id);
} catch (e) { onError(`Fork failed: ${errMsg(e)}`); }
}
async function promote(version: number) {
if (!confirm(`Promote v${version} to a new current version?`)) return;
try {
await fetchJSON(`${base}/versions/${version}/promote`, { method: 'POST' });
await load();
onPromoted(version);
} catch (e) { onError(`Promote failed: ${errMsg(e)}`); }
}
const activeVer = viewing ?? versions.find(v => v.current)?.version ?? null;
return (
<div class="ver-tree">
{dirty && (
<div class="ver-node ver-node-unsaved">
<div class="ver-rail">
<span class="ver-dot ver-dot-unsaved" />
<span class="ver-line ver-line-dashed" />
</div>
<div class="ver-body"><span class="ver-num">unsaved changes</span></div>
</div>
)}
{versions.length === 0 && <div class="hint ver-empty">No versions yet.</div>}
{versions.map((v, idx) => {
const active = v.version === activeVer;
const last = idx === versions.length - 1;
const cls = `ver-dot${v.current ? ' ver-dot-current' : ''}${active ? ' ver-dot-active' : ''}`;
return (
<div key={v.version} class={`ver-node${active ? ' ver-node-active' : ''}`}>
<div class="ver-rail">
<span class={cls} title={v.current ? 'Selected (executed) version' : ''} />
{!last && <span class="ver-line" />}
</div>
<div class="ver-body">
<button class="ver-num" title="View this version" onClick={() => onView(v.version)}>
v{v.version}
{v.current && <span class="ver-badge">current</span>}
{active && !v.current && <span class="ver-badge ver-badge-view">viewing</span>}
</button>
{v.tag && <span class="ver-tag hint" title={v.tag}>{v.tag}</span>}
<span class="ver-time hint">{fmtTime(v.savedAt)}</span>
<span class="ver-actions">
{activeVer !== null && v.version !== activeVer && (
<button class="cl-mini-btn" title={`Diff against the viewed version (v${activeVer})`}
onClick={() => onCompare(Math.min(activeVer, v.version), Math.max(activeVer, v.version))}>diff</button>
)}
{canWrite && <button class="cl-mini-btn" title="Fork into a new document" onClick={() => fork(v.version)}>fork</button>}
{canWrite && !v.current && <button class="cl-mini-btn" title="Promote to current" onClick={() => promote(v.version)}>promote</button>}
</span>
</div>
</div>
);
})}
{versions.length >= 2 && (
<div class="ver-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>
);
}
// ── Diff viewer ───────────────────────────────────────────────────────────────
export function DiffViewer({ base, av, bv, title, onClose }: {
base: string;
av: number;
bv: number;
title: string;
onClose: () => void;
}) {
const [rows, setRows] = useState<DiffRow[] | null>(null);
const [mode, setMode] = useState<'unified' | 'split'>('split');
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let alive = true;
(async () => {
try {
const [left, right] = await Promise.all([fetchVersionText(base, av), fetchVersionText(base, bv)]);
if (alive) setRows(diffLines(left, right));
} catch (e) { if (alive) setError(errMsg(e)); }
})();
return () => { alive = false; };
}, [base, av, bv]);
const stats = rows ? diffStats(rows) : { added: 0, removed: 0 };
return (
<div class="cl-confirm-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div class="cl-confirm ver-diff-modal">
<div class="ver-diff-head">
<span class="cl-confirm-title">Diff {title}</span>
<span class="ver-diff-stats">
<span class="ver-diff-add">+{stats.added}</span> <span class="ver-diff-del">{stats.removed}</span>
</span>
<span class="ver-diff-modes">
<button class={`panel-btn${mode === 'split' ? ' panel-btn-primary' : ''}`} onClick={() => setMode('split')}>Side by side</button>
<button class={`panel-btn${mode === 'unified' ? ' panel-btn-primary' : ''}`} onClick={() => setMode('unified')}>Unified</button>
</span>
<button class="panel-btn" onClick={onClose}>Close</button>
</div>
{error && <div class="cl-error">{error}</div>}
{!rows && !error && <div class="hint ver-diff-loading">Loading</div>}
{rows && (mode === 'unified' ? <UnifiedDiff rows={rows} /> : <SplitDiff rows={rows} />)}
</div>
</div>
);
}
function UnifiedDiff({ rows }: { rows: DiffRow[] }) {
return (
<div class="ver-diff ver-diff-unified">
{rows.map((r, i) => (
<div key={i} class={`ver-diff-row ver-diff-${r.op}`}>
<span class="ver-diff-ln">{r.leftNo ?? ''}</span>
<span class="ver-diff-ln">{r.rightNo ?? ''}</span>
<span class="ver-diff-sign">{r.op === 'add' ? '+' : r.op === 'del' ? '' : ' '}</span>
<span class="ver-diff-text">{r.text || '\u00a0'}</span>
</div>
))}
</div>
);
}
function SplitDiff({ rows }: { rows: DiffRow[] }) {
const pairs = sideBySide(rows);
return (
<div class="ver-diff ver-diff-split">
{pairs.map((p, i) => (
<div key={i} class="ver-diff-splitrow">
<span class="ver-diff-ln">{p.left?.leftNo ?? ''}</span>
<span class={`ver-diff-cell ver-diff-${p.left ? p.left.op : 'empty'}`}>{p.left ? (p.left.text || '\u00a0') : ''}</span>
<span class="ver-diff-ln">{p.right?.rightNo ?? ''}</span>
<span class={`ver-diff-cell ver-diff-${p.right ? p.right.op : 'empty'}`}>{p.right ? (p.right.text || '\u00a0') : ''}</span>
</div>
))}
</div>
);
}
+34 -8
View File
@@ -7,10 +7,11 @@ import { parseInterface } from './lib/xml';
import { newPlotPanel } from './lib/templates'; import { newPlotPanel } from './lib/templates';
import { useAuth, canWrite } from './lib/auth'; import { useAuth, canWrite } from './lib/auth';
import type { Interface } from './lib/types'; import type { Interface } from './lib/types';
import ContextualHelp from './ContextualHelp';
import HelpModal from './HelpModal'; import HelpModal from './HelpModal';
import ZoomControl from './ZoomControl'; import ZoomControl from './ZoomControl';
import ControlLogicEditor from './ControlLogicEditor'; import ControlLogicEditor from './ControlLogicEditor';
import AuditViewer from './AuditViewer';
import ConfigManager from './ConfigManager';
interface Props { interface Props {
onEdit?: (iface?: Interface) => void; onEdit?: (iface?: Interface) => void;
@@ -33,6 +34,8 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) {
const [helpSection, setHelpSection] = useState('start'); const [helpSection, setHelpSection] = useState('start');
const [showTimeNav, setShowTimeNav] = useState(false); const [showTimeNav, setShowTimeNav] = useState(false);
const [showControlLogic, setShowControlLogic] = useState(false); const [showControlLogic, setShowControlLogic] = useState(false);
const [showAudit, setShowAudit] = useState(false);
const [showConfig, setShowConfig] = useState(false);
const [leftW, setLeftW] = useState(220); const [leftW, setLeftW] = useState(220);
const [listCollapsed, setListCollapsed] = useState(false); const [listCollapsed, setListCollapsed] = useState(false);
const me = useAuth(); const me = useAuth();
@@ -137,9 +140,13 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) {
)} )}
</div> </div>
<div class="toolbar-right"> <div class="toolbar-right">
<div class={`status-chip ${wsStatus}`}> <div
class={`status-chip ${wsStatus}`}
title={me.user ? `Signed in as ${me.user}${writable ? '' : ' (read-only)'}` : undefined}
>
<span class="status-dot"></span> <span class="status-dot"></span>
{wsStatus === 'connected' && me.user ? `connected as ${me.user}` : wsStatus} {wsStatus === 'connected' && me.user ? `connected as ${me.user}` : wsStatus}
{me.user && !writable && ' (read-only)'}
</div> </div>
<button <button
class={`toolbar-btn${showTimeNav ? ' toolbar-btn-active' : ''}`} class={`toolbar-btn${showTimeNav ? ' toolbar-btn-active' : ''}`}
@@ -148,7 +155,6 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) {
> >
History History
</button> </button>
<ContextualHelp mode="view" onOpenManual={openHelp} />
<button <button
class="icon-btn help-manual-btn" class="icon-btn help-manual-btn"
onClick={() => openHelp('start')} onClick={() => openHelp('start')}
@@ -166,6 +172,24 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) {
Control logic Control logic
</button> </button>
)} )}
{me.canViewAudit && (
<button
class="toolbar-btn"
onClick={() => setShowAudit(true)}
title="View the audit log"
>
🛡 Audit
</button>
)}
{writable && (
<button
class="toolbar-btn"
onClick={() => setShowConfig(true)}
title="Open configuration manager"
>
🗂 Config
</button>
)}
{writable && ( {writable && (
<button <button
class="btn-edit" class="btn-edit"
@@ -175,11 +199,6 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) {
Edit Edit
</button> </button>
)} )}
{me.user && (
<span class="user-chip" title={`Signed in as ${me.user}${writable ? '' : ' (read-only)'}`}>
{me.user}{!writable && ' (read-only)'}
</span>
)}
</div> </div>
</header> </header>
@@ -257,6 +276,13 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) {
{showControlLogic && ( {showControlLogic && (
<ControlLogicEditor onClose={() => setShowControlLogic(false)} /> <ControlLogicEditor onClose={() => setShowControlLogic(false)} />
)} )}
{showAudit && (
<AuditViewer onClose={() => setShowAudit(false)} />
)}
{showConfig && (
<ConfigManager onClose={() => setShowConfig(false)} />
)}
</div> </div>
); );
} }
+2 -1
View File
@@ -4,7 +4,7 @@ import type { Me, AccessLevel } from './types';
// Default identity used before /api/v1/me resolves (or if it fails): assume a // Default identity used before /api/v1/me resolves (or if it fails): assume a
// trusted LAN user with full access, matching the backend default. // trusted LAN user with full access, matching the backend default.
export const DEFAULT_ME: Me = { user: '', level: 'write', groups: [], canEditLogic: true }; export const DEFAULT_ME: Me = { user: '', level: 'write', groups: [], canEditLogic: true, canViewAudit: true };
// AuthContext carries the resolved identity + global access level for the // AuthContext carries the resolved identity + global access level for the
// current user throughout the app. // current user throughout the app.
@@ -37,6 +37,7 @@ export async function fetchMe(): Promise<Me> {
level: (data.level as AccessLevel) ?? 'write', level: (data.level as AccessLevel) ?? 'write',
groups: Array.isArray(data.groups) ? data.groups : [], groups: Array.isArray(data.groups) ? data.groups : [],
canEditLogic: data.canEditLogic !== false, canEditLogic: data.canEditLogic !== false,
canViewAudit: data.canViewAudit !== false,
}; };
} catch { } catch {
return DEFAULT_ME; return DEFAULT_ME;
+24
View File
@@ -0,0 +1,24 @@
import { writable } from './store';
// A dialog request pushed by a server-side control-logic action.dialog node and
// delivered over the WebSocket. Unlike panel logic dialogs (lib/logic.ts) these
// originate on the server and are addressed to the connected user by identity.
export interface ControlDialog {
id: string;
kind: 'info' | 'error' | 'input';
title?: string;
message?: string;
}
// Active control-logic dialogs awaiting the user's acknowledgement / input.
export const controlDialogs = writable<ControlDialog[]>([]);
// pushControlDialog is invoked by the WS client on a "dialog" message.
export function pushControlDialog(d: ControlDialog): void {
controlDialogs.update(list => (list.some(x => x.id === d.id) ? list : [...list, d]));
}
// dismissControlDialog removes a dialog once answered or cancelled.
export function dismissControlDialog(id: string): void {
controlDialogs.update(list => list.filter(d => d.id !== id));
}
+99
View File
@@ -0,0 +1,99 @@
// Line-based diff (git style) used by the version history diff viewer. A simple
// Longest-Common-Subsequence over lines yields a sequence of equal/added/removed
// rows that can be rendered as a unified diff or split side-by-side.
export type DiffOp = 'equal' | 'add' | 'del';
export interface DiffRow {
op: DiffOp;
// Line content. For 'equal' both sides are identical (text).
text: string;
// 1-based line numbers in the left/right document; null when absent on a side.
leftNo: number | null;
rightNo: number | null;
}
function splitLines(s: string): string[] {
// Drop a single trailing newline so a file ending in "\n" doesn't yield a
// spurious empty final row.
const t = s.endsWith('\n') ? s.slice(0, -1) : s;
return t.length === 0 ? [] : t.split('\n');
}
// diffLines computes the LCS table and walks it back into ordered rows.
export function diffLines(leftText: string, rightText: string): DiffRow[] {
const a = splitLines(leftText);
const b = splitLines(rightText);
const n = a.length;
const m = b.length;
// lcs[i][j] = length of LCS of a[i:] and b[j:].
const lcs: number[][] = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
for (let i = n - 1; i >= 0; i--) {
for (let j = m - 1; j >= 0; j--) {
lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
}
}
const rows: DiffRow[] = [];
let i = 0;
let j = 0;
let la = 1;
let lb = 1;
while (i < n && j < m) {
if (a[i] === b[j]) {
rows.push({ op: 'equal', text: a[i], leftNo: la++, rightNo: lb++ });
i++; j++;
} else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
rows.push({ op: 'del', text: a[i], leftNo: la++, rightNo: null });
i++;
} else {
rows.push({ op: 'add', text: b[j], leftNo: null, rightNo: lb++ });
j++;
}
}
while (i < n) rows.push({ op: 'del', text: a[i++], leftNo: la++, rightNo: null });
while (j < m) rows.push({ op: 'add', text: b[j++], leftNo: null, rightNo: lb++ });
return rows;
}
export interface DiffStats { added: number; removed: number; }
export function diffStats(rows: DiffRow[]): DiffStats {
let added = 0;
let removed = 0;
for (const r of rows) {
if (r.op === 'add') added++;
else if (r.op === 'del') removed++;
}
return { added, removed };
}
// sideBySide pairs rows into aligned [left, right] columns: a del with the next
// add becomes one changed row; otherwise each row occupies one side.
export interface SideRow {
left: DiffRow | null;
right: DiffRow | null;
}
export function sideBySide(rows: DiffRow[]): SideRow[] {
const out: SideRow[] = [];
for (let k = 0; k < rows.length; k++) {
const r = rows[k];
if (r.op === 'equal') {
out.push({ left: r, right: r });
} else if (r.op === 'del') {
// Pair consecutive del/add as a single changed line when possible.
const next = rows[k + 1];
if (next && next.op === 'add') {
out.push({ left: r, right: next });
k++;
} else {
out.push({ left: r, right: null });
}
} else {
out.push({ left: null, right: r });
}
}
return out;
}
+167
View File
@@ -75,6 +75,88 @@ function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms));
} }
// Reads a single parameter's value from a config instance, coercing it to a
// number. Returns null if the instance/set/param is missing or non-numeric.
async function readConfigParam(instanceId: string, key: string): Promise<number | null> {
try {
const ir = await fetch(`/api/v1/config/instances/${encodeURIComponent(instanceId)}`);
if (!ir.ok) return null;
const inst: { setId: string; setVersion?: number; values?: Record<string, any> } = await ir.json();
const sv = inst.setVersion && inst.setVersion > 0
? `/api/v1/config/sets/${encodeURIComponent(inst.setId)}/versions/${inst.setVersion}`
: `/api/v1/config/sets/${encodeURIComponent(inst.setId)}`;
const sr = await fetch(sv);
if (!sr.ok) return null;
const set: { parameters?: Array<{ key: string; default?: any }> } = await sr.json();
const param = (set.parameters ?? []).find(p => p.key === key);
if (!param) return null;
const raw = inst.values?.[key] ?? param.default;
const n = toNum(raw);
return isNaN(n) ? null : n;
} catch {
return null;
}
}
// Write a single numeric value into a config instance's parameter, creating a
// new revision server-side. Reads the current instance, overwrites values[key],
// and PUTs it back. Silently no-ops on any transport/parse error.
async function writeConfigParam(instanceId: string, key: string, val: number): Promise<void> {
try {
const ir = await fetch(`/api/v1/config/instances/${encodeURIComponent(instanceId)}`);
if (!ir.ok) return;
const inst: any = await ir.json();
inst.values = { ...(inst.values ?? {}), [key]: val };
await fetch(`/api/v1/config/instances/${encodeURIComponent(instanceId)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(inst),
});
} catch {}
}
// Create a new config instance for `setId`, optionally seeding its values from
// an existing instance `fromId`. Returns the new instance id, or '' on failure.
async function createConfigInstance(setId: string, name: string, fromId: string): Promise<string> {
try {
let values: Record<string, any> = {};
if (fromId) {
const fr = await fetch(`/api/v1/config/instances/${encodeURIComponent(fromId)}`);
if (fr.ok) {
const src: any = await fr.json();
values = { ...(src.values ?? {}) };
}
}
const r = await fetch('/api/v1/config/instances', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, setId, values }),
});
if (!r.ok) return '';
const out: any = await r.json();
return String(out?.id ?? '');
} catch {
return '';
}
}
// Snapshot the current live values of every target signal of `setId` into a new
// config instance (optional label). Returns the new instance id, or '' on failure.
async function snapshotConfigSet(setId: string, name: string): Promise<string> {
try {
const r = await fetch(`/api/v1/config/sets/${encodeURIComponent(setId)}/snapshot`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
if (!r.ok) return '';
const out: any = await r.json();
return String(out?.instance?.id ?? '');
} catch {
return '';
}
}
function toNum(v: any): number { function toNum(v: any): number {
if (typeof v === 'number') return v; if (typeof v === 'number') return v;
if (typeof v === 'boolean') return v ? 1 : 0; if (typeof v === 'boolean') return v ? 1 : 0;
@@ -158,6 +240,20 @@ class LogicEngine {
return toNum(this.live.get(refKey(ds, name))); return toNum(this.live.get(refKey(ds, name)));
} }
// Resolve the config-instance id a node operates on. With instanceSource
// 'var' the id is read (as a raw string) from a panel-local variable / signal
// — this is how a config-selector widget feeds the apply/read/write nodes.
// Otherwise the fixed `instance` param (an id chosen at design time) is used.
private resolveInstanceId(node: LogicNode): string {
if ((node.params.instanceSource ?? 'fixed') === 'var') {
const r = parseRef(node.params.instanceVar ?? '');
if (!r) return '';
const v = this.live.get(refKey(r.ds, r.name));
return v == null ? '' : String(v).trim();
}
return (node.params.instance ?? '').trim();
}
/** Activate a panel's logic graph. Replaces any previously loaded graph. */ /** Activate a panel's logic graph. Replaces any previously loaded graph. */
load(graph: LogicGraph | undefined): void { load(graph: LogicGraph | undefined): void {
this.clear(); this.clear();
@@ -266,6 +362,18 @@ class LogicEngine {
case 'action.write': case 'action.write':
case 'action.accumulate': case 'action.accumulate':
case 'action.log': collectRefs(node.params.expr ?? '').forEach(want); break; case 'action.log': collectRefs(node.params.expr ?? '').forEach(want); break;
case 'action.config.apply':
case 'action.config.read':
case 'action.config.write': {
// Dynamic-instance nodes read their target instance id from a panel
// var; subscribe it so the live cache holds the selected id.
if ((node.params.instanceSource ?? 'fixed') === 'var') {
const r = parseRef(node.params.instanceVar ?? '');
if (r) want(r);
}
if (node.kind === 'action.config.write') collectRefs(node.params.expr ?? '').forEach(want);
break;
}
case 'action.dialog.info': case 'action.dialog.info':
case 'action.dialog.error': case 'action.dialog.error':
case 'action.dialog.setpoint': { case 'action.dialog.setpoint': {
@@ -390,6 +498,65 @@ class LogicEngine {
return; return;
} }
case 'action.config.apply': {
const id = this.resolveInstanceId(node);
if (id) {
try {
await fetch(`/api/v1/config/instances/${encodeURIComponent(id)}/apply`, { method: 'POST' });
} catch {}
}
await this.follow(node.id, 'out', ctx);
return;
}
case 'action.config.read': {
const id = this.resolveInstanceId(node);
const key = (node.params.key ?? '').trim();
const ref = parseRef(node.params.target ?? '');
if (id && key && ref) {
const val = await readConfigParam(id, key);
if (val !== null && !isNaN(val)) wsClient.write(ref, val);
}
await this.follow(node.id, 'out', ctx);
return;
}
case 'action.config.write': {
const id = this.resolveInstanceId(node);
const key = (node.params.key ?? '').trim();
const val = evalExpr(node.params.expr ?? '', ctx.resolve);
if (id && key && !isNaN(val)) {
await writeConfigParam(id, key, val);
}
await this.follow(node.id, 'out', ctx);
return;
}
case 'action.config.create': {
const setId = (node.params.set ?? '').trim();
const name = (node.params.name ?? '').trim() || 'auto';
const fromId = (node.params.from ?? '').trim();
const target = parseRef(node.params.target ?? '');
if (setId) {
const newId = await createConfigInstance(setId, name, fromId);
if (newId && target) wsClient.write(target, newId);
}
await this.follow(node.id, 'out', ctx);
return;
}
case 'action.config.snapshot': {
const setId = (node.params.set ?? '').trim();
const name = (node.params.name ?? '').trim();
const target = parseRef(node.params.target ?? '');
if (setId) {
const newId = await snapshotConfigSet(setId, name);
if (newId && target) wsClient.write(target, newId);
}
await this.follow(node.id, 'out', ctx);
return;
}
case 'action.delay': case 'action.delay':
await sleep(Math.max(0, parseInt(node.params.ms ?? '0', 10) || 0)); await sleep(Math.max(0, parseInt(node.params.ms ?? '0', 10) || 0));
await this.follow(node.id, 'out', ctx); await this.follow(node.id, 'out', ctx);
+86
View File
@@ -0,0 +1,86 @@
// Static type propagation for the synthetic node-graph editor. This mirrors the
// Go runtime/compile rules in internal/dsp/types.go (OpOutputType) so the editor
// can colour wires by data type and flag type-incompatible wirings before save.
// Keep the two in sync — a parity test guards the pair.
import type { SynthGraph, SynthGraphNode } from './types';
export type SynthType = 'scalar' | 'array' | 'unknown';
// reductionOps collapse an array (or scalar) to a single scalar.
const REDUCTION_OPS = new Set(['index', 'length', 'sum', 'mean', 'min', 'max']);
// arrayProducerOps require an array input and yield an array.
const ARRAY_PRODUCER_OPS = new Set(['fft', 'slice']);
// scalarOnlyOps reject array inputs and yield a scalar (stateful filters + lua).
const SCALAR_ONLY_OPS = new Set(['moving_average', 'rms', 'lowpass', 'derivative', 'integrate', 'lua']);
// opOutputType reports an op's output type given its input types, plus an error
// message when the inputs are definitely incompatible with the op. 'unknown'
// inputs (a source whose real type isn't known yet) never trigger an error —
// runtime typing is authoritative.
export function opOutputType(op: string, inputs: SynthType[]): { type: SynthType; error?: string } {
if (REDUCTION_OPS.has(op)) return { type: 'scalar' };
if (ARRAY_PRODUCER_OPS.has(op)) {
if (inputs.some(t => t === 'scalar')) return { type: 'unknown', error: `${op} requires an array input` };
return { type: 'array' };
}
if (SCALAR_ONLY_OPS.has(op)) {
if (inputs.some(t => t === 'array')) return { type: 'unknown', error: `${op} does not accept an array input` };
return { type: 'scalar' };
}
// Elementwise stateless ops (gain, offset, add, subtract, multiply, divide,
// clamp, threshold, expr): array if any input is an array, scalar if all
// inputs are definitely scalar, otherwise unknown.
if (inputs.some(t => t === 'array')) return { type: 'array' };
if (inputs.some(t => t === 'unknown')) return { type: 'unknown' };
return { type: 'scalar' };
}
// inferNodeTypes walks the DAG in dependency order and assigns each node an
// output type. Source node types come from `sourceType` (resolved from upstream
// signal metadata); unresolved sources default to 'unknown'. `errors` collects
// per-node type-conflict messages for the editor's validation.
export function inferNodeTypes(
g: SynthGraph,
sourceType: (n: SynthGraphNode) => SynthType,
): { types: Map<string, SynthType>; errors: Map<string, string> } {
const types = new Map<string, SynthType>();
const errors = new Map<string, string>();
const byId = new Map(g.nodes.map(n => [n.id, n]));
// Kahn topological order over node inputs.
const indeg = new Map<string, number>();
const succ = new Map<string, string[]>();
for (const n of g.nodes) indeg.set(n.id, 0);
for (const n of g.nodes) {
for (const from of n.inputs ?? []) {
if (!byId.has(from)) continue;
indeg.set(n.id, (indeg.get(n.id) ?? 0) + 1);
succ.set(from, [...(succ.get(from) ?? []), n.id]);
}
}
const queue = g.nodes.filter(n => (indeg.get(n.id) ?? 0) === 0).map(n => n.id);
while (queue.length) {
const id = queue.shift()!;
const n = byId.get(id)!;
if (n.kind === 'source') {
types.set(id, sourceType(n));
} else if (n.kind === 'output') {
const from = (n.inputs ?? [])[0];
types.set(id, (from && types.get(from)) || 'unknown');
} else {
const inTypes = (n.inputs ?? []).map(f => types.get(f) ?? 'unknown');
const { type, error } = opOutputType(n.op ?? '', inTypes);
types.set(id, type);
if (error) errors.set(id, error);
}
for (const s of succ.get(id) ?? []) {
indeg.set(s, (indeg.get(s) ?? 0) - 1);
if ((indeg.get(s) ?? 0) === 0) queue.push(s);
}
}
return { types, errors };
}
+34 -2
View File
@@ -9,6 +9,9 @@ export interface SignalValue {
value: any; value: any;
quality: 'good' | 'uncertain' | 'bad' | 'unknown'; quality: 'good' | 'uncertain' | 'bad' | 'unknown';
ts: string | null; ts: string | null;
// Raw EPICS alarm fields (absent/0 = NO_ALARM). severity: 1=MINOR,2=MAJOR,3=INVALID.
severity?: number;
status?: number;
} }
// Metadata for a signal (received once on subscribe) // Metadata for a signal (received once on subscribe)
@@ -149,7 +152,12 @@ export type LogicNodeKind =
| 'action.widget' | 'action.widget'
| 'action.dialog.info' | 'action.dialog.info'
| 'action.dialog.error' | 'action.dialog.error'
| 'action.dialog.setpoint'; | 'action.dialog.setpoint'
| 'action.config.apply'
| 'action.config.read'
| 'action.config.write'
| 'action.config.create'
| 'action.config.snapshot';
// A block on the flow canvas. `params` holds kind-specific fields as strings. // A block on the flow canvas. `params` holds kind-specific fields as strings.
export interface LogicNode { export interface LogicNode {
@@ -209,6 +217,9 @@ export interface Me {
// Whether the user may add/edit panel logic and server-side control logic. // Whether the user may add/edit panel logic and server-side control logic.
// False only when a logic-editors allowlist is configured and excludes them. // False only when a logic-editors allowlist is configured and excludes them.
canEditLogic: boolean; canEditLogic: boolean;
// Whether the user may view the audit log. False only when an audit-readers
// allowlist is configured and excludes them, or auditing is disabled.
canViewAudit: boolean;
} }
// Effective permission a user holds on a single panel or folder. // Effective permission a user holds on a single panel or folder.
@@ -269,12 +280,33 @@ export interface PipelineNode {
params: Record<string, any>; params: Record<string, any>;
} }
// DAG form of a synthetic signal (mirrors backend synthetic.Graph). A node is a
// source (ds:signal root), an op (DSP node with ordered `inputs`), or the single
// output. When present it supersedes the legacy inputs+pipeline linear form.
export type SynthNodeKind = 'source' | 'op' | 'output';
export interface SynthGraphNode {
id: string;
kind: SynthNodeKind;
op?: string;
params?: Record<string, any>;
ds?: string;
signal?: string;
inputs?: string[]; // upstream node ids, in input order
x?: number;
y?: number;
}
export interface SynthGraph {
nodes: SynthGraphNode[];
output: string;
}
export interface SignalDef { export interface SignalDef {
name: string; name: string;
ds?: string; // legacy single input ds?: string; // legacy single input
signal?: string; // legacy single input signal?: string; // legacy single input
inputs?: InputRef[]; inputs?: InputRef[];
pipeline: PipelineNode[]; pipeline?: PipelineNode[];
graph?: SynthGraph; // DAG form (preferred when present)
meta: { meta: {
unit?: string; unit?: string;
description?: string; description?: string;
+21
View File
@@ -1,5 +1,6 @@
import { writable, type Readable } from './store'; import { writable, type Readable } from './store';
import { writeLocalState } from './localstate'; import { writeLocalState } from './localstate';
import { pushControlDialog } from './controldialogs';
import type { SignalRef, SignalValue, SignalMeta, HistoryPoint } from './types'; import type { SignalRef, SignalValue, SignalMeta, HistoryPoint } from './types';
// ── Types ──────────────────────────────────────────────────────────────────── // ── Types ────────────────────────────────────────────────────────────────────
@@ -130,6 +131,8 @@ class WsClient {
value: msg.value, value: msg.value,
quality: msg.quality ?? 'unknown', quality: msg.quality ?? 'unknown',
ts: msg.ts ?? null, ts: msg.ts ?? null,
severity: msg.severity ?? 0,
status: msg.status ?? 0,
}; };
for (const s of subs) s.onUpdate(val); for (const s of subs) s.onUpdate(val);
break; break;
@@ -161,6 +164,16 @@ class WsClient {
} }
break; break;
} }
case 'dialog': {
// Server-side control logic requesting a user notification / input.
pushControlDialog({
id: String(msg.id),
kind: msg.kind === 'error' || msg.kind === 'input' ? msg.kind : 'info',
title: msg.title,
message: msg.message,
});
break;
}
case 'error': case 'error':
// Resolve any pending history callbacks with empty data on error // Resolve any pending history callbacks with empty data on error
if (key && this.histCallbacks.has(key)) { if (key && this.histCallbacks.has(key)) {
@@ -268,6 +281,14 @@ class WsClient {
console.log('[ws] write', ref.ds, ref.name, value, 'ws state:', this.ws?.readyState); console.log('[ws] write', ref.ds, ref.name, value, 'ws state:', this.ws?.readyState);
this._send({ type: 'write', ds: ref.ds, name: ref.name, value }); this._send({ type: 'write', ds: ref.ds, name: ref.name, value });
} }
/**
* Answer a control-logic input dialog. `value === null` cancels (dismisses)
* the dialog without writing its target server variable.
*/
sendDialogResponse(id: string, value: number | null): void {
this._send({ type: 'dialogResponse', id, value });
}
} }
// Singleton instance // Singleton instance
+1165 -81
View File
File diff suppressed because it is too large Load Diff
+80
View File
@@ -0,0 +1,80 @@
import { h } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import { wsClient } from '../lib/ws';
import { getSignalStore } from '../lib/stores';
import type { Widget, SignalValue } from '../lib/types';
interface InstanceMeta { id: string; name: string; setId?: string; }
interface Props { widget: Widget; onContextMenu?: (e: MouseEvent) => void; }
// A combo that lists the config instances of a chosen set and writes the
// selected instance id (a string) to a panel-local variable. The output var can
// then drive the apply/read/write config logic nodes (set to "From variable"),
// letting an operator pick which configuration the flow operates on at run time.
export default function ConfigSelect({ widget, onContextMenu }: Props) {
const setId = widget.options['set'] || '';
const output = widget.options['output'] || '';
const label = widget.options['label'] || 'Config';
// Optional comma-separated allow-list of instance ids; empty shows them all.
const subset = (widget.options['instances'] || '')
.split(',').map(s => s.trim()).filter(Boolean);
const [instances, setInstances] = useState<InstanceMeta[]>([]);
const [selected, setSelected] = useState('');
// Mirror the output var so external writers (e.g. a create-config node) keep
// the combo in sync.
useEffect(() => {
if (!output) return;
const unsub = getSignalStore({ ds: 'local', name: output }).subscribe((v: SignalValue) => {
const id = v.value == null ? '' : String(v.value);
setSelected(prev => (id && id !== prev ? id : prev));
});
return unsub;
}, [output]);
useEffect(() => {
let cancelled = false;
fetch('/api/v1/config/instances')
.then(r => r.ok ? r.json() : [])
.then((list: InstanceMeta[]) => {
if (cancelled) return;
let filtered = (list ?? []).filter(ci => !setId || ci.setId === setId);
if (subset.length > 0) filtered = filtered.filter(ci => subset.includes(ci.id));
setInstances(filtered);
// Default the output to the first instance when nothing is chosen yet.
setSelected(prev => {
if (prev && filtered.some(ci => ci.id === prev)) return prev;
const first = filtered[0]?.id ?? '';
if (first && output) wsClient.write({ ds: 'local', name: output }, first);
return first;
});
})
.catch(() => {});
return () => { cancelled = true; };
}, [setId, widget.options['instances']]);
function onChange(id: string) {
setSelected(id);
if (output) wsClient.write({ ds: 'local', name: output }, id);
}
return (
<div
class="configselect"
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
onContextMenu={onContextMenu}
>
<span class="cs-label">{label}:</span>
<select
class="cs-select"
value={selected}
onChange={(e: Event) => onChange((e.target as HTMLSelectElement).value)}
>
{instances.length === 0 && <option value="">no instances</option>}
{instances.map(ci => <option key={ci.id} value={ci.id}>{ci.name}</option>)}
</select>
</div>
);
}
+57 -2
View File
@@ -84,12 +84,25 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
const showLegend = legendPos !== 'none'; const showLegend = legendPos !== 'none';
const buffers: SeriesBuffer[] = signals.map(() => ({ timestamps: [], values: [] })); const buffers: SeriesBuffer[] = signals.map(() => ({ timestamps: [], values: [] }));
// Latest waveform (array) value per signal — used only by plotType 'waveform'.
const waveforms: number[][] = signals.map(() => []);
const unsubs: (() => void)[] = []; const unsubs: (() => void)[] = [];
const fmt = widget.options['format'] ?? ''; const fmt = widget.options['format'] ?? '';
// ── uPlot (timeseries) ────────────────────────────────────────────────── // ── uPlot (timeseries) ──────────────────────────────────────────────────
let uplot: uPlot | null = null; let uplot: uPlot | null = null;
// Newest sample timestamp across all series (seconds). Anchoring the live
// x-axis to this — rather than wall-clock Date.now() — avoids client/server
// clock skew clipping the latest point, and keeps the right edge on real data.
function latestSampleTs(): number {
let m = -Infinity;
for (const b of buffers) {
if (b.timestamps.length) m = Math.max(m, b.timestamps[b.timestamps.length - 1]);
}
return isFinite(m) ? m : Date.now() / 1000;
}
// windowSec: apply rolling window for live mode; 0 = use full buffer (historical) // windowSec: apply rolling window for live mode; 0 = use full buffer (historical)
function uplotData(windowSec = 0): uPlot.AlignedData { function uplotData(windowSec = 0): uPlot.AlignedData {
if (signals.length === 0) return [[]]; if (signals.length === 0) return [[]];
@@ -165,7 +178,22 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
{ stroke: '#94a3b8', grid: { stroke: '#2d3748' }, ticks: { stroke: '#475569' } }, { stroke: '#94a3b8', grid: { stroke: '#2d3748' }, ticks: { stroke: '#475569' } },
yAxis, yAxis,
], ],
scales: { x: { time: true }, y: { ...scaleY, range: scaleYRange } }, scales: {
x: {
time: true,
// Live mode with a finite window: pin the x-axis to a rolling
// [newest window, newest] span so a single old/outlier sample
// can't stretch the axis and compress real points to the right.
// Historical mode (timeRange) keeps uPlot's data-driven auto-range.
...(!timeRange && timeWindow > 0
? { range: (): [number, number] => {
const r = latestSampleTs();
return [r - timeWindow, r];
} }
: {}),
},
y: { ...scaleY, range: scaleYRange },
},
}; };
} }
@@ -243,6 +271,24 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
}), }),
}; };
} }
case 'waveform': {
// Latest waveform (array) sample per signal, drawn as an x-vs-index
// trace. Each update replaces the trace rather than appending.
return {
backgroundColor: ECHARTS_DARK.backgroundColor,
legend: legendOpt,
grid: { left: 60, right: 16, top: showLegend ? 28 : 12, bottom: 40 },
xAxis: { type: 'value', name: 'Index', nameLocation: 'middle', nameGap: 24, min: 0, axisLine, axisLabel, splitLine },
yAxis: { type: 'value', name: 'Value', axisLine, axisLabel, splitLine },
series: signals.map((s, i) => ({
name: s.name,
type: 'line',
data: (waveforms[i] ?? []).map((v, k) => [k, v]),
lineStyle: { color: s.color ?? COLORS[i % COLORS.length] },
showSymbol: false,
})),
};
}
case 'logic': { case 'logic': {
// Logic-analyser style: each signal is a 0/1 step trace stacked on its // 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). // own lane, with a relative-time x-axis (seconds before now).
@@ -397,7 +443,16 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
if (sv.value === null || sv.value === undefined || sv.ts === null) return; if (sv.value === null || sv.value === undefined || sv.ts === null) return;
const ts = new Date(sv.ts).getTime() / 1000; 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)); const v = typeof sv.value === 'number' ? sv.value : parseFloat(String(sv.value));
if (isNaN(v)) return; if (isNaN(v)) return;
pushSample(buffers[i], ts, v); pushSample(buffers[i], ts, v);
+22 -1
View File
@@ -1,10 +1,31 @@
{ {
"panels": {}, "panels": {
"epics_test": {
"owner": ""
},
"new-plot-panel": {
"owner": "",
"folder": "fld-c5396468072d4f69"
},
"test": {
"owner": "martino",
"order": 1
},
"test-1781729251317": {
"owner": "",
"order": 3
}
},
"folders": { "folders": {
"fld-68b0bb9c23c9fd3b": { "fld-68b0bb9c23c9fd3b": {
"id": "fld-68b0bb9c23c9fd3b", "id": "fld-68b0bb9c23c9fd3b",
"name": "Test", "name": "Test",
"owner": "" "owner": ""
},
"fld-c5396468072d4f69": {
"id": "fld-c5396468072d4f69",
"name": "Plots",
"owner": "martino"
} }
} }
} }
Binary file not shown.
@@ -0,0 +1,13 @@
{
"id": "new-instance",
"name": "New instance",
"owner": "martino",
"setId": "new-config-set",
"values": {
"param1": 1,
"param2": 1,
"param3": "Test",
"param4": "Idle"
},
"version": 3
}
@@ -0,0 +1,8 @@
{
"id": "new-instance",
"name": "New instance",
"owner": "martino",
"setId": "new-config-set",
"values": {},
"version": 1
}
@@ -0,0 +1,12 @@
{
"id": "new-instance",
"name": "New instance",
"owner": "martino",
"setId": "new-config-set",
"values": {
"param1": 1,
"param2": 1,
"param4": "Idle"
},
"version": 2
}
@@ -0,0 +1,46 @@
{
"id": "new-config-set",
"name": "New config set",
"owner": "martino",
"parameters": [
{
"ds": "epics",
"key": "param1",
"max": 1,
"min": 0,
"signal": "UOPI:INTERLOCK",
"type": "float64"
},
{
"ds": "epics",
"key": "param2",
"max": 100,
"min": 0,
"signal": "UOPI:FLOW",
"type": "float64",
"unit": "L/min"
},
{
"default": "CIAO",
"ds": "epics",
"key": "param3",
"signal": "UOPI:MESSAGE",
"subgroup": "Casa",
"type": "string"
},
{
"ds": "epics",
"enumValues": [
"Idle",
"Running",
"Fault",
"Maintenance"
],
"key": "param4",
"signal": "UOPI:MODE",
"subgroup": "Casa",
"type": "enum"
}
],
"version": 3
}
@@ -0,0 +1,7 @@
{
"id": "new-config-set",
"name": "New config set",
"owner": "martino",
"parameters": [],
"version": 1
}
@@ -0,0 +1,25 @@
{
"id": "new-config-set",
"name": "New config set",
"owner": "martino",
"parameters": [
{
"ds": "epics",
"key": "param1",
"max": 1,
"min": 0,
"signal": "UOPI:INTERLOCK",
"type": "float64"
},
{
"ds": "epics",
"key": "param2",
"max": 100,
"min": 0,
"signal": "UOPI:FLOW",
"type": "float64",
"unit": "L/min"
}
],
"version": 2
}

Some files were not shown because too many files have changed in this diff Show More