Compare commits

..

11 Commits

Author SHA1 Message Date
Martino Ferrari ac24011487 Implemented admin pane + user permission 2026-06-22 17:49:14 +02:00
Martino Ferrari 73fcbe7b28 Improved plot view 2026-06-22 00:30:15 +02:00
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
135 changed files with 20157 additions and 1422 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 |
| **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 |
| **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 |
| **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 |
@@ -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/reorder` | Reorder panels / move them between folders |
| `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 |
| `PUT/DELETE` | `/api/v1/folders/{id}` | Rename/reparent or delete a folder |
| `GET/POST/DELETE` | `/api/v1/synthetic` | Manage synthetic signal definitions |
| `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/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/usergroups` | List configured users and groups (for sharing) |
| `GET` | `/metrics` | Prometheus-format server metrics |
+138
View File
@@ -0,0 +1,138 @@
# 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:
- Config editor:
- [x] Instance should be filtered by config set (combo box) (`ConfigManager.tsx` InstancesManager: `setFilter` combo in the instance list head, shown when >1 set; empty-set hint)
- [x] Validation rules should also be filtered by config set (combo box) (`ConfigManager.tsx` RulesManager: `setFilter` combo mirroring instances, shown when >1 set; empty-set hint; `Meta.setId` surfaced via `List`)
- [x] Validation rules can be enabled / disabled — disabled rules are skipped when an instance is created/updated/applied (`Enabled *bool`, nil=enabled for legacy; `IsEnabled()`; `rulesForSet` skips disabled; UI checkbox + list `off` badge)
- [x] Rule editor preview button — runs the working (unsaved) source against a live signal snapshot of the set without persisting it, showing the input snapshot JSON and processed output JSON (`POST /config/rules/preview``previewConfigRule`; `RulePreviewView`)
- [ ] Synthetic editor:
- [x] color code the node link by type
- [x] edges color depends on type (e.g. float) including arrays (scalar = slate, array = purple)
- [x] input / outputs are color coded (out port = node's type; in port = accepted type, gray when it accepts either)
- [x] can not connect input / output that are not compatible (definite scalar↔array mismatch blocked; unknown/any always allowed)
- [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
- [x] toggle switch (`web/src/widgets/Toggle.tsx`; read+write bool control, configurable on/off value+label, confirm dialog)
- [x] table widget (`web/src/widgets/TableWidget.tsx`; multi-signal value table, one row per bound signal, configurable columns name/value/unit/status/time, optional header + title, per-signal value format + row-label overrides; blends in view mode)
- [ ] other industrial hmi widgets
- [x] widget panel exposes an editable Widget ID field (default = generated uuid) for easier logic interaction (`PropertiesPane.tsx` `IdInput`; `EditMode.renameWidgetId` propagates the rename to `action.widget` logic refs + plot-layout leaves; rejects empty/duplicate ids)
- [x] add container widgets: labelled/title pane, tab panes, collapsable panes (`web/src/widgets/Container.tsx`; decorative grouping frame rendered behind widgets, accent/bg options; `pane` variant = title + view-mode collapse; `tabs` variant = tab bar where each contained widget is assigned a tab via its "Tab" field and only the active tab shows; geometric membership via `web/src/lib/containers.ts`)
- [x] moving container widget should move the widgets on it — starting a move (drag or arrow nudge) on a container snapshots the widgets geometrically inside it (centre-inside, transitive through nested containers) and moves them by the same delta; membership is captured at move start so widgets never re-parent mid-move (`withContainedWidgets` in `web/src/lib/containers.ts`, used by `EditCanvas` drag-start + `EditMode` arrow nudge)
- [x] plot pane:
- [x] add toolbar (hover toolbar in `PlotWidget`, `.plot-toolbar`)
- [x] add time window selector to toolbar (Auto/15s/30s/1m/5m/15m/1h rolling-window override, live windowed plot types)
- [x] plot x-axis synchronised (shared uPlot cursor crosshair across all linked timeseries plots + zoom/pan range sync in historical mode; per-plot 🔗 link/unlink toolbar toggle; `web/src/lib/plotSync.ts`)
- [x] cursors and measuraments (📏 measure mode: click cursor A then B → on-canvas A/B lines + readout panel with Δt/rate and per-signal value@A→value@B and Δ)
- [x] pause/resume (local toolbar pause, OR'd with panel-logic pause; buffers persist across plot-type/window changes)
- [x] save screenshot (PNG export — uPlot canvas / ECharts getDataURL)
- [x] clean ui:
- [x] create small statusbar where connection widget and other status related info will be placed (bottom `.statusbar`: connection chip + current panel name + history indicator)
- [x] group advanced items (audit/control loops/config manager etc) in a tool menu or something similar to not fill the toolbar (⋯ Tools ▾ dropdown)
- [x] make the toolbar as clean as possible (toolbar-right now: History · 📖 · zoom · Tools · Edit)
- [x] panel-selection list: the whole row is clickable to open a panel (not just the name text); `onClick` moved to the `<li>`, action buttons `stopPropagation` (`InterfaceList.tsx`)
- [x] panel-selection list: plot vs panel entries shown with distinguishing monochrome inline-SVG icons (line-chart = plot / control-panel = HMI; `KindIcon`, currentColor); backend `InterfaceMeta.Kind` surfaced from the XML `kind` attr, `InterfaceListItem.kind` in the frontend type (preserved through the list normalizer)
- [ ] implement proper grouping strategy, in all selector tree the options should be filtered by user (private config), group (combo to select group if user in multiple groups), global (public config)
- [ ] panel tree by user / group / global
- [ ] signal tree by user / group / global
- [ ] config tree by user / group / global
- [ ] control sequence by user / group / global
- [ ] Node editors:
- [x] In all editors, implement node grouping and collapsing feature (with optional group label) (Shift+click multi-select → G/⊞ to group; editable label; ▾/▸ collapse to a compact box that reroutes crossing wires; Delete ungroups; shared `web/src/lib/nodeGroups.ts`; persisted in panel-logic XML + control-logic/synthetic JSON via Go `NodeGroup` structs — cosmetic editor metadata, ignored at eval)
- [x] opened group should be on top of other nodes (group frame lifted to z-index 1 above loose nodes; member nodes get `.flow-node-grouped` at z-index 2 so they stay above their own frame and clickable; applied in all three editors)
- [x] node counter should be better padded (`.flow-group-count` gained vertical padding so the "N nodes" line isn't cramped against the collapsed-box header)
- [x] In all editors: undo / redo + copy / paste with shortcuts (Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y undo-redo; Ctrl+C / Ctrl+V copy-paste of the selection, multi-select aware, internal wires preserved, ids remapped, pasted +offset; ref-based 50-deep history + clipboard scoped per editor; toolbar ↩/↪ buttons). Panel `LogicEditor` already had this; added to `ControlLogicEditor` (undo/redo + copy/paste) and `SyntheticGraphEditor` (copy/paste — it already had undo/redo). Synthetic paste never copies the permanent output node
- [x] In all editors: implement zoom in / out, home zoom and fit zoom to help user navigate complex graph (shared `web/src/lib/flowZoom.ts` `useFlowZoom` hook; CSS `transform: scale()` on a `.flow-canvas-zoom` layer inside the scaled `.flow-canvas-inner`; toolbar row /%//⤢ in all three editors; `toCanvas` divides pointer offsets by zoom)
- [x] fix: the fit-zoom (⤢) button was clipped outside the fixed-width palette — toolbar buttons now shrink to fit (`min-width:0`, zero side padding in `.flow-palette-toolbar .toolbar-btn`)
- [x] fix: zoom value (e.g. 100%) is overflowing, reduce padding and increase the zoom value widget width (`flow-zoom-pct` class on the zoom-value button → `flex:1.9` so it gets ~2× the width of the single-glyph icon buttons; toolbar font dropped to 0.75rem, gap to 0.25rem, `tabular-nums` to stop width jitter; all three editors)
- [x] Live / debug mode in all three node editors — evaluate the graph online and badge each node's value at human speed (~0.5 s). Shared frontend `web/src/lib/flowDebug.ts` (badge/active classes, `useFlowDebug` poller) + CSS `.flow-node-active`/`.flow-node-badge`/`.flow-wire-active`; a green ◉ toggle in each `.flow-palette-toolbar`. Per-editor backends:
- [ ] Syntetic signal editor:
- [x] Implement live / debug mode where value are evaluated online and visualized at human speed (e.g. 0.5s) — server-side stateless single-shot trace: `POST /api/v1/synthetic/trace` snapshots live source signals (broker `ReadNow`) and runs `evalSampleTrace` with fresh state, returning every node's value; stateful ops (moving_average/rms/lowpass/derivative/integrate/lua) badged `~approx`
- [ ] Logic editor:
- [x] Implement live / debug mode where value are evaluated online and visualized at human speed (e.g. 0.5s) — editor spins up a second client-side `LogicEngine` in new `dryRun` mode (real writes/config/dialogs suppressed) loaded with the edited graph; per-node values polled into the shared badges; the view-mode singleton is untouched
- add full suppor to local array values: dynamic, dynamic but capped max, fixed size etc:
- array functions should work with new local array
- [ ] Control loop:
- [x] Implement live / debug mode where value are evaluated online and visualized at human speed (e.g. 0.5s) — server pushes per-node events over the WS (`debugSubscribe`/`debugNode`) via a new `DebugObserver` on the engine (lock-free `atomic.Value`, gated by a per-graph watch set) + `internal/server/debughub.go` (drop-on-full fan-out). Two modes share one message shape: **Live** observes the running enabled graph; **Simulate** dry-runs the unsaved edits in a throwaway sandbox (`StartSimulate`, side effects suppressed). Live/Sim sub-toggle in the editor
- 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
- [x] Implement admin pane: create / manage groups, set users permits, manage auditors etc
- [x] user manager
- [x] group manager
- [x] server statistics: load, conenctions, observed signals, average latency, other statistics
- [ ] Implement new datasources:
- [ ] Finalize alarm service
- [ ] modbus tcp
- [ ] scpi tcp / VXI-11 protocol
- [ ] 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)
- [ ] clients should be distributed to balance load\
- same user should be (if possible) connect to only one service to simplify synch issue
- [ ] control sequences should be executed in only in one server instance but with backups ones to take over if the active service stop or die
- [ ] sync config between service
- [ ] manage conflict
- [ ] avoid incorrect
- [ ] ensure that the system can survive with up to only one instance alive
- [ ] advance admin panel:
- [ ] should have info about service topology and statistics of each server
- [ ] admin should deploy new instances via ssh to target machines directly from the admin panel
- [ ] for phisical connect datasource (e.g. modbus) pin the service to a specific machine or deploy specific datasource only service to a machine: user can setup backup instances (e.g. machine in the same sub-network that can be switched on in case primary fail)
- [ ] QA and CI
- [ ] coverage of service 90+%
- [ ] coverage of client 80+%
- [ ] add integrated tests with interface simulations
- [ ] update doc and keep user manual up to date
- [ ] add code example for lua scripts
- [ ] add tutorial
+85 -11
View File
@@ -9,14 +9,19 @@ import (
"log/slog"
"os"
"os/signal"
"os/user"
"path/filepath"
"syscall"
"github.com/uopi/uopi/internal/access"
"github.com/uopi/uopi/internal/audit"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/config"
"github.com/uopi/uopi/internal/confmgr"
"github.com/uopi/uopi/internal/controllogic"
"github.com/uopi/uopi/internal/datasource/epics"
"github.com/uopi/uopi/internal/datasource/pva"
"github.com/uopi/uopi/internal/datasource/servervar"
"github.com/uopi/uopi/internal/datasource/stub"
"github.com/uopi/uopi/internal/datasource/synthetic"
"github.com/uopi/uopi/internal/panelacl"
@@ -41,6 +46,16 @@ func main() {
os.Exit(1)
}
// In an unproxied/local deployment (no trusted_user_header) without an explicit
// default_user, attribute actions to the OS user running the process so the UI
// and audit log show a real identity instead of anonymous.
if cfg.Server.TrustedUserHeader == "" && cfg.Server.DefaultUser == "" {
if u, err := user.Current(); err == nil && u.Username != "" {
cfg.Server.DefaultUser = u.Username
log.Info("no default_user configured; defaulting to OS user", "user", u.Username)
}
}
if err := os.MkdirAll(cfg.Server.StorageDir, 0o755); err != nil {
log.Error("failed to create storage dir", "dir", cfg.Server.StorageDir, "err", err)
os.Exit(1)
@@ -58,6 +73,12 @@ func main() {
os.Exit(1)
}
cfgStore, err := confmgr.New(cfg.Server.StorageDir)
if err != nil {
log.Error("failed to open configuration store", "err", err)
os.Exit(1)
}
webFS, err := fs.Sub(web.FS, "dist")
if err != nil {
log.Error("failed to sub web dist", "err", err)
@@ -122,18 +143,60 @@ func main() {
}
}
// Build the global access policy from config: every user is trusted with
// full write access unless downgraded by the blacklist; groups are named
// sets of users referenced by per-panel sharing.
blacklist := make(map[string]string, len(cfg.Server.Blacklist))
for _, e := range cfg.Server.Blacklist {
blacklist[e.User] = e.Level
// 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)
}
groups := make(map[string][]string, len(cfg.Groups))
brk.Register(srvVars)
// Build the role-based access policy from config: each [[groups]] entry lists
// members by role (viewer/operator/logiceditor/auditor/admin) and an optional
// parent for nesting. Every user is an implicit viewer of the built-in public
// group; a config with no roles at all is fully open (everyone admin).
specs := make([]access.GroupSpec, 0, len(cfg.Groups))
for _, g := range cfg.Groups {
groups[g.Name] = g.Members
members := make(map[string]access.Role)
assign := func(users []string, role access.Role) {
for _, u := range users {
members[u] = role
}
}
assign(g.Viewers, access.RoleViewer)
assign(g.Operators, access.RoleOperator)
assign(g.LogicEditors, access.RoleLogic)
assign(g.Auditors, access.RoleAuditor)
assign(g.Admins, access.RoleAdmin)
specs = append(specs, access.GroupSpec{Name: g.Name, Parent: g.Parent, Members: members})
}
policy := access.New(cfg.Server.DefaultUser, specs)
// Once enabled, runtime admin-pane changes persist to {storage_dir}/access.json,
// which (when present on a later startup) supersedes the TOML access config.
if err := policy.EnablePersistence(cfg.Server.StorageDir); err != nil {
log.Error("failed to load access store", "err", err)
os.Exit(1)
}
// 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)
}
policy := access.New(cfg.Server.DefaultUser, blacklist, groups, cfg.Server.LogicEditors)
// Server-side control logic: flow graphs that run continuously under the root
// context, independent of any panel. The store is loaded from disk and the
@@ -143,10 +206,21 @@ func main() {
log.Error("failed to open control logic store", "err", err)
os.Exit(1)
}
ctrlEngine := controllogic.NewEngine(ctx, brk, ctrlStore, log)
ctrlEngine := controllogic.NewEngine(ctx, brk, ctrlStore, 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)
// Debug hub: control-logic editors observe live graphs or dry-run unsaved
// edits; the engine pushes per-node execution events through it.
debugHub := server.NewDebugHub(ctrlEngine, log)
ctrlEngine.SetDebugObserver(debugHub)
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, debugHub, recorder, cfg.Datasource.EPICS.ChannelFinderURL, cfg.Datasource.EPICS.ArchiveURL, cfg.Server.TrustedUserHeader, log)
if err := srv.Start(ctx); err != nil {
fmt.Fprintf(os.Stderr, "server error: %v\n", err)
+102 -3
View File
@@ -192,6 +192,7 @@ When multiple widgets are selected:
| Histogram | numeric scalar(s) |
| Bar chart | numeric scalar(s) |
| Logic analyser | boolean / integer (bitset) |
| Waveform | 1-D numeric array (latest sample, x-vs-index) |
### 4.5 Widget Properties (Properties Pane)
@@ -235,6 +236,9 @@ A signal defined by composing one or more input signals through a chain of proce
processing node, set parameters, Create.
- **Node-graph editor** — a visual editor that wires one or more inputs through a chain of
DSP blocks, for multi-input pipelines. It compiles to the same inputs + pipeline model.
It supports undo/redo (Ctrl+Z / Ctrl+Shift+Z) and copy/paste (Ctrl+C / Ctrl+V) of the
selection; paste remaps ids and preserves internal wires (the permanent output node is
never duplicated).
**Visibility scope:** each synthetic signal is scoped as *panel* (visible only to the panel
that created it), *user*, or *global* (shared with everyone).
@@ -278,7 +282,7 @@ copy/paste.
|----------|-------|
| 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) |
| 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) |
**System helpers in expressions:** `{sys:time}` (epoch seconds) and `{sys:dt}` (seconds
@@ -295,7 +299,16 @@ managed through the REST API.
- Triggers include *cron* schedules and signal *alarm*/threshold conditions.
- 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.
- The editor has its own undo/redo and copy/paste (Ctrl+Z / Ctrl+Shift+Z, Ctrl+C / Ctrl+V;
also ↩/↪ toolbar buttons). Copy/paste is multi-select aware and preserves wires internal
to the selection.
- Editing is gated by the logic-edit restriction (§2).
---
@@ -304,7 +317,7 @@ managed through the REST API.
- 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).
- 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.
- 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.
@@ -344,7 +357,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 |
|-------------|--------|
+201 -39
View File
@@ -41,7 +41,7 @@
| ---------------- | --------------------------------------------------------------- |
| `preact` 10 | Virtual DOM UI framework |
| `uPlot` | Extremely fast time-series/line plot (canvas-based, < 40 kB) |
| `Apache ECharts` | FFT, waterfall, histogram, bar, logic analyser plots |
| `Apache ECharts` | FFT, waterfall, histogram, bar, logic analyser, waveform plots |
| `uplot.css` | uPlot default stylesheet |
**Intentionally excluded:** React, Vue, Svelte, Konva, WebGPU, jQuery, npm at runtime.
@@ -164,6 +164,22 @@ Framing: JSON messages over a single persistent WebSocket connection per client.
// Request historical data
{ "type": "history", "signal": "EPICS:PV1", "start": "2026-01-01T00:00:00Z", "end": "2026-01-02T00:00:00Z", "maxPoints": 5000 }
// Start a control-logic live-debug session (one per client; replaces any prior).
// mode "live" — observe the running, enabled graph identified by graphId.
// mode "simulate" — dry-run the unsaved `graph` in a server sandbox (no real
// writes/config/dialogs); re-send on each edit to refresh it.
{ "type": "debugSubscribe", "mode": "live", "graphId": "g1" }
{ "type": "debugSubscribe", "mode": "simulate", "graph": { /* unsaved control-logic graph */ } }
// Stop the current debug session (tears down any simulate sandbox).
{ "type": "debugUnsubscribe" }
// Force a trigger node of the current debug session (live or simulate) to run
// now, as if it had fired. nodeId must be a trigger node of the watched graph;
// best-effort (dropped if the session is gone). Drives the editor's
// double-click-to-fire gesture on trigger nodes in debug mode.
{ "type": "fireTrigger", "nodeId": "t" }
```
**Server → Client messages:**
@@ -178,6 +194,11 @@ Framing: JSON messages over a single persistent WebSocket connection per client.
// Historical data response
{ "type": "history", "signal": "EPICS:PV1", "points": [ { "ts": "...", "value": 1.2 }, ... ] }
// Control-logic node execution during a live-debug session. Emitted ~per node
// run for the watched graph (live) or sandbox (simulate); value is meaningful
// only when hasValue is true (e.g. an action.write's value, a flow.if branch 0/1).
{ "type": "debugNode", "graphId": "g1", "nodeId": "w", "value": 42, "hasValue": true, "ts": 1750000000000 }
// Error
{ "type": "error", "code": "NOT_FOUND", "message": "Signal not found" }
```
@@ -198,10 +219,6 @@ Base path: `/api/v1`
| POST | `/interfaces/reorder` | Reorder panels / move between folders |
| GET, PUT, DELETE| `/interfaces/{id}` | Download, update, or delete 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, POST | `/folders` | List or create panel folders |
| PUT, DELETE | `/folders/{id}` | Rename/reparent or delete a folder |
@@ -209,12 +226,36 @@ Base path: `/api/v1`
| GET, PUT | `/groups` | Read or set group definitions |
| GET, POST | `/synthetic` | List or create synthetic signal definitions |
| GET, PUT, DELETE| `/synthetic/{name}` | Read, update, or delete a synthetic definition |
| POST | `/synthetic/trace` | Stateless single-shot trace of an unsaved graph for the live-debug view — every node's value; stateful ops flagged `approx` |
| 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, 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
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
@@ -234,15 +275,30 @@ endpoints and for any change to a panel's `<logic>` block.
**Built-in node types:**
| Node type | Parameters | Description |
| ------------ | ----------------------------------- | ------------------------------------------------ |
| `source` | `ds`, `name` | Reads a signal from any data source |
| `gain` | `factor` | Multiplies by a constant |
| `offset` | `value` | Adds a constant |
| `moving_avg` | `window` (samples) | Rolling mean |
| `lowpass` | `freq` (Hz), `order` (18) | Cascaded IIR Butterworth-style low-pass filter |
| `formula` | `expr` | Inline math expression (variables: `a`, `b`, …) |
| `lua` | `script` | Arbitrary Lua 5.1 code with persistent state |
| Node type | Parameters | In→Out | Description |
| ---------------- | --------------------------- | -------------- | ----------------------------------------------- |
| `source` | `ds`, `name` | — | Reads a signal from any data source |
| `gain` | `gain` | elementwise | Multiplies by a constant |
| `offset` | `offset` | elementwise | Adds a constant |
| `add`/`subtract` | — | elementwise | Sum of inputs / `a b` |
| `multiply`/`divide` | — | elementwise | Product of inputs / `a ÷ b` |
| `clamp` | `min`, `max` | elementwise | Constrains to a range |
| `threshold` | `threshold`, `high`, `low` | elementwise | Comparator output |
| `moving_average` | `window` (samples) | scalar-only | Rolling mean |
| `rms` | `window` (samples) | scalar-only | Rolling RMS |
| `derivative` | — | scalar-only | Time derivative (per-sample `dt`) |
| `integrate` | — | scalar-only | Trapezoidal integral |
| `lowpass` | `freq` (Hz), `order` (18) | scalar-only | Cascaded IIR Butterworth-style low-pass filter |
| `expr` | `expr`, `vars` | elementwise | Inline math expression (named inputs) |
| `lua` | `script`, `vars` | scalar-only | Arbitrary Lua 5.1 code with persistent state |
| `index` | `i` | array→scalar | Element `i` of a waveform (bounds-checked) |
| `slice` | `start`, `end` | array→array | Sub-range of a waveform (clamped) |
| `sum`/`mean` | — | array→scalar | Σ / average of a waveform |
| `min`/`max` | — | array→scalar | Reduction of a waveform |
| `length` | — | array→scalar | Element count of a waveform |
| `fft` | — | array→array | Magnitude spectrum (zero-padded to next pow-2) |
**Scalar vs waveform values:** A value flowing through the graph is a `dsp.Sample` — either a scalar `float64` or a `[]float64` waveform (the array-aware counterpart of EPICS `TypeFloat64Array`). *Elementwise* ops broadcast over arrays (scalar inputs act as constants; array inputs must share a length). *Reduction*/*producer* ops (`index`/`slice`/`sum`/…/`fft`) operate natively on waveforms. *Scalar-only* ops (stateful filters + `lua`) reject array inputs, since their per-evaluation state cannot be split across array lanes. `OpOutputType` (`internal/dsp/types.go`) propagates types statically at compile time to reject invalid wirings and to report the synthetic's metadata type; the editor mirrors these rules in `web/src/lib/synthTypes.ts` to colour wires by data type (scalar vs array) and flag type-incompatible links. Runtime `Sample` typing is authoritative.
**Low-pass filter implementation:** Cascaded first-order IIR sections. Each stage computes `y = y_prev + α·(x y_prev)` where `α = dt / (RC + dt)` and `RC = 1/(2π·fc)`. `dt` is computed per sample from source timestamps so the filter is correct for event-driven (non-uniform) data.
@@ -303,16 +359,111 @@ 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
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
Identity and global policy live in `internal/access` (`Policy`, `Level`). The user identity
is read per request from `server.trusted_user_header` (with a `default_user` fallback) and
stored on the request context. `accessMiddleware` gates mutating HTTP methods by the user's
global level. Per-panel ownership and ACL evaluation (with folder inheritance) live in
`internal/panelacl`, backed by the `acl.json` sidecar. `Policy.CanEditLogic(user)` enforces
the optional `server.logic_editors` allowlist over control-logic endpoints and over any
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.
Identity and global policy live in `internal/access` (`Policy`, `Role`, `Level`). The user
identity is read per request from `server.trusted_user_header` (with a `default_user`
fallback) and stored on the request context. Access is **role-based** through group
memberships: each `[[groups]]` block lists members by role along the cumulative ladder
`viewer < operator < logiceditor < auditor < admin`, and a user's **effective** global
capability is the highest role across all their memberships. Roles map to capabilities as:
operator+ → write (`Level` is derived, `LevelWrite` for operator+, else `LevelRead`);
logiceditor+ → `CanEditLogic`; auditor+ → `CanViewAudit`; admin → `CanAdmin`.
`accessMiddleware` gates mutating HTTP methods by the derived global level. Per-panel
ownership and ACL evaluation (with folder inheritance) live in `internal/panelacl`, backed
by the `acl.json` sidecar.
A built-in **public** group is always present: every user (and anonymous) is an implicit
viewer member, so an identified caller is at least read-only. Groups may **nest** via a
`parent` pointer (a forest rooted at top-level groups); a member of a parent group inherits
its role on every descendant group too, unless overridden lower. Cycles are rejected
(offending parent dropped to root), and the public group is protected from rename, reparent,
and delete. As a bootstrap convenience, a `Policy` with **no roles assigned anywhere** is
treated as unconfigured → fully open (everyone is admin), matching trusted-LAN/dev use;
assigning any role switches to strict mode where unlisted users are read-only viewers.
The capability checks (`CanEditLogic`, `CanViewAudit`, `CanAdmin`) are surfaced to the
frontend through `/api/v1/me` (`canEditLogic`, `canViewAudit`, `canAdmin`) so the UI can
hide affordances. The `Policy` is seeded from the TOML config at startup but is
**runtime-mutable** through the admin pane. `Policy.EnablePersistence(storageDir)` points
it at an `access.json` sidecar; once any admin mutation is made, that file is written (tmp +
atomic rename) and, on a later startup, supersedes the TOML access config (which then only
bootstraps an empty install). All policy state is guarded by an `RWMutex` (reads share,
mutations are exclusive and persist), keeping the shared `*Policy` pointer wiring intact.
The admin REST routes live under `/api/v1/admin/*` (all `requireAdmin`-gated):
`GET /admin/access` returns an `AccessSnapshot` (users with effective role + per-group
roles, groups with members and parents, the role ladder, and the configured flag);
`PUT /admin/users/{user}` replaces a user's full set of per-group roles (body
`{roles: {group: role}}`, creating missing groups); `POST|PUT|DELETE /admin/groups[/{name}]`
create (name + optional parent), rename + set parent and member roles, and delete groups;
and `GET /admin/stats` reports live server statistics (the `internal/metrics` counters via
`metrics.Snapshot`, the broker's observed-signal count and data-source list, Go runtime
stats, and the Linux `/proc/loadavg` load average). The frontend `AdminPane.tsx` (a modal
opened from the view-mode Tools dropdown when `canAdmin`) presents Users (per-group role
assignment with an effective-role badge), Groups (nesting + per-member roles), and
Server-stats tabs.
### 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).
---
@@ -353,7 +504,7 @@ The edit canvas is a free-form HTML div with absolutely positioned widget compon
View mode renders widgets as absolutely positioned Preact components on a scrollable canvas div:
- Each widget subscribes to its signal store(s) in a `useEffect` and re-renders only when values change.
- uPlot (time series) and ECharts (histogram, bar, FFT, waterfall, logic analyser) manage their own canvas elements inside their widget component.
- uPlot (time series) and ECharts (histogram, bar, FFT, waterfall, logic analyser, waveform) manage their own canvas elements inside their widget component. The `waveform` plot renders a waveform (array) signal's latest `[]float64` as an x-vs-index trace, replacing the trace on each update.
- Plot widgets maintain a rolling ring buffer of 200,000 samples per signal for smooth long-window display.
- Step-hold interpolation: when multiple signals at different update rates share a plot, the most recent value is carried forward to fill the shared time axis correctly.
@@ -470,15 +621,25 @@ storage_dir = "./interfaces"
# Access control (all optional)
trusted_user_header = "" # header carrying the proxy-authenticated user
default_user = "" # identity when the header is absent (LAN/dev)
logic_editors = [] # users/groups allowed to edit panel & control logic
# [[server.blacklist]] # downgrade users: level = "readonly" | "noaccess"
# user = "guest"
# level = "readonly"
# [[groups]] # named user sets for per-panel sharing
# name = "operators"
# members = ["alice", "bob"]
# Role-based access through group memberships. Roles (low→high):
# viewer < operator < logiceditor < auditor < admin
# Effective capability = highest role across all memberships. The built-in
# "public" group makes every user an implicit viewer. Groups may nest via
# "parent" (members inherit their role on descendants). No roles anywhere = open
# (everyone admin); once set, unlisted users are read-only viewers.
# [[groups]]
# name = "public"
# admins = ["alice"] # alice is a global admin
# [[groups]]
# name = "operations"
# operators = ["bob"]
# auditors = ["carol"]
# [[groups]]
# name = "engineers"
# parent = "operations" # bob inherits operator here
# logiceditors = ["dave"]
# viewers = ["erin"]
[datasource.epics]
enabled = true
@@ -491,7 +652,7 @@ enabled = true
```
All settings can also be overridden with `UOPI_*` environment variables (e.g.
`UOPI_SERVER_LISTEN`, `UOPI_SERVER_LOGIC_EDITORS`, `UOPI_EPICS_CA_ADDR_LIST`).
`UOPI_SERVER_LISTEN`, `UOPI_EPICS_CA_ADDR_LIST`).
---
@@ -516,11 +677,12 @@ All settings can also be overridden with `UOPI_*` environment variables (e.g.
- Interface XML parsing: use strict schema validation to prevent XXE.
- **Identity & access control:** the end-user identity is taken from a header set by a
trusted authenticating reverse proxy (`trusted_user_header`), never from client-supplied
values — the proxy MUST strip any inbound copy of that header or it can be spoofed. A
global blacklist downgrades users (read-only/no-access); per-panel ACLs and the optional
`logic_editors` allowlist provide finer control. An unidentified caller (no header, no
`default_user`) is treated as a trusted-LAN user with full write access, preserving the
unproxied/SSH-tunnel deployment model.
values — the proxy MUST strip any inbound copy of that header or it can be spoofed.
Authorisation is role-based through group memberships (viewer/operator/logiceditor/
auditor/admin), with the highest role across memberships deciding global capability;
per-panel ACLs provide finer per-panel control. When **no** roles are assigned anywhere
the deployment is fully open (everyone admin), preserving the unproxied/SSH-tunnel/dev
model; once any role is set, unlisted and anonymous callers are read-only viewers.
---
+14 -1
View File
@@ -3,10 +3,23 @@ module github.com/uopi/uopi
go 1.26.2
require (
cuelang.org/go v0.16.1
github.com/BurntSushi/toml v1.6.0
github.com/coder/websocket v1.8.14
github.com/evanw/esbuild v0.28.0
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/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/evanw/esbuild v0.28.0 h1:V96ghtc5p5JnNUQIUsc5H3kr+AcFcMqOJll2ZmJW6Lo=
github.com/evanw/esbuild v0.28.0/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA=
github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
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=
+605 -108
View File
@@ -1,32 +1,114 @@
// Package access implements uopi's global user-access policy: every user is
// trusted (full write) by default, while a configured blacklist can downgrade
// specific users to read-only or no access. It also resolves the per-request
// user identity and the user→group memberships defined in config.
// Package access implements uopi's role-based access policy. Access is granted
// through group memberships: every user belongs implicitly to the built-in
// "public" group (granting the baseline viewer role), and may additionally be
// assigned a higher role in any number of named groups. Roles form a cumulative
// ladder — viewer < operator < logic-editor < auditor < admin — where each level
// includes all powers below it. A user's effective capability is the highest
// role they hold across all their groups.
//
// This is the foundation layer (Phase 1). Per-panel ownership/ACL evaluation is
// layered on top of this in a later phase.
// Groups can be nested: a member of a parent group holds that role on every
// descendant group too (unless the descendant assigns them a different role),
// so an admin of an organisation group administers its sub-teams.
//
// As a bootstrap convenience, a policy with no role assignments at all is treated
// as fully open (everyone is admin), matching an unconfigured/dev deployment.
// Assigning any role switches to strict mode where unlisted users are viewers.
//
// The policy is seeded from the TOML config at startup but is runtime-mutable
// through the admin pane: once EnablePersistence is called, every mutation is
// written to a JSON sidecar ({storageDir}/access.json) which, when present on a
// later startup, becomes the source of truth (the TOML config is then only a
// bootstrap seed). All access is guarded by an RWMutex and safe for concurrent
// use; the *Policy pointer is stable so existing shared-pointer wiring is
// preserved.
package access
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"sort"
"strings"
"sync"
)
// Level is a global access level. Higher levels include lower ones.
// PublicGroup is the built-in group every user implicitly belongs to. It cannot
// be renamed or deleted and is always a top-level (parentless) group.
const PublicGroup = "public"
// Role is a cumulative access level granted by a group membership. Higher roles
// include every power of the lower ones.
type Role int
const (
// RoleViewer permits reads only (no signal writes). It is the baseline every
// user receives via the public group.
RoleViewer Role = iota
// RoleOperator adds signal writes (full HMI interaction).
RoleOperator
// RoleLogic adds editing panel and server-side control logic.
RoleLogic
// RoleAuditor adds viewing the audit log.
RoleAuditor
// RoleAdmin adds managing users, groups and access, and viewing server stats.
RoleAdmin
)
// RoleNames lists the role tokens from lowest to highest, for the admin UI.
var RoleNames = []string{"viewer", "operator", "logiceditor", "auditor", "admin"}
// String renders the role using the tokens accepted by ParseRole.
func (r Role) String() string {
switch r {
case RoleOperator:
return "operator"
case RoleLogic:
return "logiceditor"
case RoleAuditor:
return "auditor"
case RoleAdmin:
return "admin"
default:
return "viewer"
}
}
// ParseRole maps a config/JSON token to a Role. Unknown values fall back to the
// least-privileged viewer.
func ParseRole(s string) Role {
switch strings.ToLower(strings.TrimSpace(s)) {
case "operator", "write", "operate", "rw":
return RoleOperator
case "logiceditor", "logic", "logic_editor", "editor":
return RoleLogic
case "auditor", "audit":
return RoleAuditor
case "admin", "administrator":
return RoleAdmin
default:
return RoleViewer
}
}
// Level is a coarse global access level retained for the rest of the codebase
// (WebSocket auth, middleware, per-panel ACL capping). It is derived from a
// user's effective role: viewer → read-only, operator and above → write.
type Level int
const (
// LevelNone denies all access.
// LevelNone denies all access. Never produced by the role model, but kept so
// existing switch statements remain exhaustive.
LevelNone Level = iota
// LevelRead permits reads only (no create/update/delete/share, no signal writes).
// LevelRead permits reads only.
LevelRead
// LevelWrite permits full access. This is the default for any user not blacklisted.
// LevelWrite permits full write access.
LevelWrite
)
// String renders the level using the same tokens accepted by ParseLevel and
// surfaced to the frontend via /api/v1/me.
// String renders the level using the tokens surfaced to the frontend via
// /api/v1/me.
func (l Level) String() string {
switch l {
case LevelNone:
@@ -38,80 +120,218 @@ func (l Level) String() string {
}
}
// ParseLevel maps a config string to a Level. Unknown values restrict to
// read-only, since the only reason to list a user is to limit them.
func ParseLevel(s string) Level {
switch strings.ToLower(strings.TrimSpace(s)) {
case "noaccess", "none", "no":
return LevelNone
case "readonly", "read", "ro":
return LevelRead
case "write", "readwrite", "rw", "full":
func roleToLevel(r Role) Level {
if r >= RoleOperator {
return LevelWrite
default:
return LevelRead
}
return LevelRead
}
// Policy holds the resolved global access configuration. It is immutable after
// construction and safe for concurrent use.
// group holds a group's parent (for nesting) and explicit per-user role
// assignments.
type group struct {
parent string
members map[string]Role
}
// Policy holds the resolved role-based access configuration. It is safe for
// concurrent use; reads take a shared lock and admin mutations take an exclusive
// lock and persist to disk.
type Policy struct {
defaultUser string
blacklist map[string]Level // user → downgraded level
userGroups map[string][]string // user → groups they belong to
groupNames []string // all configured group names (sorted)
logicEditors map[string]bool // users + group names allowed to edit logic
mu sync.RWMutex
path string // access.json path; "" disables persistence
defaultUser string // immutable after construction/load
groups map[string]*group
}
// New builds a Policy. blacklist maps a username to a config level string;
// groups maps a group name to its member usernames. logicEditors optionally
// restricts who may edit panel/control logic (usernames or group names); empty
// means no restriction.
func New(defaultUser string, blacklist map[string]string, groups map[string][]string, logicEditors []string) *Policy {
// GroupSpec seeds one group at construction time: its name, optional parent, and
// explicit user→role assignments.
type GroupSpec struct {
Name string
Parent string
Members map[string]Role
}
// New builds a Policy from group specs. The built-in public group is always
// created. A spec listing the same user in multiple roles keeps the last one;
// callers should pass the highest intended role.
func New(defaultUser string, specs []GroupSpec) *Policy {
p := &Policy{
defaultUser: strings.TrimSpace(defaultUser),
blacklist: make(map[string]Level),
userGroups: make(map[string][]string),
logicEditors: make(map[string]bool),
defaultUser: strings.TrimSpace(defaultUser),
groups: make(map[string]*group),
}
for _, e := range logicEditors {
e = strings.TrimSpace(e)
if e != "" {
p.logicEditors[e] = true
}
}
for user, lvl := range blacklist {
u := strings.TrimSpace(user)
if u == "" {
for _, s := range specs {
name := strings.TrimSpace(s.Name)
if name == "" {
continue
}
p.blacklist[u] = ParseLevel(lvl)
}
for g, members := range groups {
g = strings.TrimSpace(g)
if g == "" {
continue
}
p.groupNames = append(p.groupNames, g)
for _, m := range members {
m = strings.TrimSpace(m)
if m == "" {
continue
g := p.ensureGroupLocked(name)
g.parent = strings.TrimSpace(s.Parent)
for u, r := range s.Members {
if u = strings.TrimSpace(u); u != "" {
g.members[u] = r
}
p.userGroups[m] = append(p.userGroups[m], g)
}
}
sort.Strings(p.groupNames)
p.ensureGroupLocked(PublicGroup).parent = ""
p.normalizeParentsLocked()
return p
}
// GroupNames returns a copy of every configured user-group name, sorted.
func (p *Policy) GroupNames() []string {
out := make([]string, len(p.groupNames))
copy(out, p.groupNames)
return out
// ensureGroupLocked returns the named group, creating an empty one if needed.
// The caller must hold p.mu for writing (or be in construction).
func (p *Policy) ensureGroupLocked(name string) *group {
g, ok := p.groups[name]
if !ok {
g = &group{members: make(map[string]Role)}
p.groups[name] = g
}
return g
}
// normalizeParentsLocked drops parent references to missing groups or that would
// form a cycle, and forces the public group to be a root.
func (p *Policy) normalizeParentsLocked() {
for name, g := range p.groups {
if name == PublicGroup {
g.parent = ""
continue
}
if g.parent == "" {
continue
}
if _, ok := p.groups[g.parent]; !ok || p.hasCycleLocked(name) {
g.parent = ""
}
}
}
// hasCycleLocked reports whether following parent links from start loops back.
func (p *Policy) hasCycleLocked(start string) bool {
seen := make(map[string]bool)
for cur := start; cur != ""; {
if seen[cur] {
return true
}
seen[cur] = true
g, ok := p.groups[cur]
if !ok {
return false
}
cur = g.parent
}
return false
}
// ── effective role ─────────────────────────────────────────────────────────
// configuredLocked reports whether any explicit role is assigned anywhere. An
// unconfigured policy is treated as fully open (bootstrap-safe).
func (p *Policy) configuredLocked() bool {
for _, g := range p.groups {
if len(g.members) > 0 {
return true
}
}
return false
}
// effectiveRoleLocked returns a user's highest role across all groups. Unlisted
// users (and anonymous callers) get the viewer baseline once the policy is
// configured; an unconfigured policy grants everyone admin.
func (p *Policy) effectiveRoleLocked(user string) Role {
if !p.configuredLocked() {
return RoleAdmin
}
best := RoleViewer
if user = strings.TrimSpace(user); user == "" {
return best
}
for _, g := range p.groups {
if r, ok := g.members[user]; ok && r > best {
best = r
}
}
return best
}
// ── persistence ────────────────────────────────────────────────────────────
// persisted is the on-disk schema for access.json.
type persisted struct {
DefaultUser string `json:"defaultUser"`
Groups map[string]persistedGroup `json:"groups"`
}
type persistedGroup struct {
Parent string `json:"parent"`
Members map[string]string `json:"members"` // user → role token
}
// EnablePersistence points the policy at {storageDir}/access.json. If the file
// exists its contents replace the TOML-seeded state (the file is the source of
// truth once written); otherwise the current state is kept and the file is only
// created on the first mutation.
func (p *Policy) EnablePersistence(storageDir string) error {
p.mu.Lock()
defer p.mu.Unlock()
p.path = filepath.Join(storageDir, "access.json")
data, err := os.ReadFile(p.path)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return err
}
var ps persisted
if err := json.Unmarshal(data, &ps); err != nil {
return err
}
p.defaultUser = strings.TrimSpace(ps.DefaultUser)
p.groups = make(map[string]*group)
for name, pg := range ps.Groups {
if name = strings.TrimSpace(name); name == "" {
continue
}
g := p.ensureGroupLocked(name)
g.parent = strings.TrimSpace(pg.Parent)
for u, token := range pg.Members {
if u = strings.TrimSpace(u); u != "" {
g.members[u] = ParseRole(token)
}
}
}
p.ensureGroupLocked(PublicGroup).parent = ""
p.normalizeParentsLocked()
return nil
}
// saveLocked atomically persists the current state when persistence is enabled.
func (p *Policy) saveLocked() error {
if p.path == "" {
return nil
}
ps := persisted{DefaultUser: p.defaultUser, Groups: make(map[string]persistedGroup, len(p.groups))}
for name, g := range p.groups {
pg := persistedGroup{Parent: g.parent, Members: make(map[string]string, len(g.members))}
for u, r := range g.members {
pg.Members[u] = r.String()
}
ps.Groups[name] = pg
}
data, err := json.MarshalIndent(ps, "", " ")
if err != nil {
return err
}
tmp := p.path + ".tmp"
if err := os.WriteFile(tmp, data, 0o644); err != nil {
return err
}
return os.Rename(tmp, p.path)
}
// ── reads ──────────────────────────────────────────────────────────────────
// ResolveUser trims the proxy-provided header value and falls back to the
// configured default_user when it is empty (e.g. unproxied/dev deployments).
func (p *Policy) ResolveUser(headerValue string) string {
@@ -122,58 +342,335 @@ func (p *Policy) ResolveUser(headerValue string) string {
return u
}
// Level returns the global access level for a user. Users that are neither
// blacklisted nor anonymous get full write access.
// Level returns the coarse global access level for a user.
func (p *Policy) Level(user string) Level {
user = strings.TrimSpace(user)
if user == "" {
// No identity at all (no proxy header, no default_user): trusted LAN.
return LevelWrite
}
if lvl, ok := p.blacklist[user]; ok {
return lvl
}
return LevelWrite
p.mu.RLock()
defer p.mu.RUnlock()
return roleToLevel(p.effectiveRoleLocked(user))
}
// LogicRestricted reports whether a logic-editor allowlist is configured. When
// false, any write-capable user may edit panel/control logic.
func (p *Policy) LogicRestricted() bool {
return len(p.logicEditors) > 0
// EffectiveRole returns the highest role a user holds across all groups.
func (p *Policy) EffectiveRole(user string) Role {
p.mu.RLock()
defer p.mu.RUnlock()
return p.effectiveRoleLocked(user)
}
// CanEditLogic reports whether a user may add or edit panel logic and
// server-side control logic. When no allowlist is configured everyone with
// write access qualifies; otherwise the user (or one of their groups) must be
// listed. Anonymous/trusted-LAN callers (user=="") are always permitted.
// CanEditLogic reports whether a user may add or edit panel and control logic
// (logic-editor role or higher).
func (p *Policy) CanEditLogic(user string) bool {
user = strings.TrimSpace(user)
if user == "" {
return true
}
if !p.LogicRestricted() {
return true
}
if p.logicEditors[user] {
return true
}
for _, g := range p.userGroups[user] {
if p.logicEditors[g] {
return true
}
}
return false
p.mu.RLock()
defer p.mu.RUnlock()
return p.effectiveRoleLocked(user) >= RoleLogic
}
// GroupsOf returns a copy of the groups a user belongs to.
func (p *Policy) GroupsOf(user string) []string {
src := p.userGroups[strings.TrimSpace(user)]
out := make([]string, len(src))
copy(out, src)
// CanViewAudit reports whether a user may view the audit log (auditor or admin).
func (p *Policy) CanViewAudit(user string) bool {
p.mu.RLock()
defer p.mu.RUnlock()
return p.effectiveRoleLocked(user) >= RoleAuditor
}
// CanAdmin reports whether a user may use the admin pane (admin role).
func (p *Policy) CanAdmin(user string) bool {
p.mu.RLock()
defer p.mu.RUnlock()
return p.effectiveRoleLocked(user) >= RoleAdmin
}
// GroupNames returns a copy of every group name, sorted.
func (p *Policy) GroupNames() []string {
p.mu.RLock()
defer p.mu.RUnlock()
return p.groupNamesLocked()
}
func (p *Policy) groupNamesLocked() []string {
out := make([]string, 0, len(p.groups))
for n := range p.groups {
out = append(out, n)
}
sort.Strings(out)
return out
}
// ── request-scoped user identity ────────────────────────────────────────────
// GroupsOf returns the groups a user effectively belongs to: every group they
// are an explicit member of, plus that group's descendants (membership flows
// parent→child). The implicit public-group viewer baseline is not included.
// Used by per-panel/folder sharing rules.
func (p *Policy) GroupsOf(user string) []string {
p.mu.RLock()
defer p.mu.RUnlock()
if user = strings.TrimSpace(user); user == "" {
return nil
}
set := make(map[string]bool)
for name, g := range p.groups {
if _, ok := g.members[user]; ok {
set[name] = true
p.addDescendantsLocked(name, set)
}
}
out := make([]string, 0, len(set))
for n := range set {
out = append(out, n)
}
sort.Strings(out)
return out
}
// addDescendantsLocked adds every transitive child of parent into set.
func (p *Policy) addDescendantsLocked(parent string, set map[string]bool) {
for name, g := range p.groups {
if g.parent == parent && !set[name] {
set[name] = true
p.addDescendantsLocked(name, set)
}
}
}
// ── admin snapshot ─────────────────────────────────────────────────────────
// MemberInfo is one user's role within a group.
type MemberInfo struct {
User string `json:"user"`
Role string `json:"role"`
}
// GroupInfo describes one group for the admin pane.
type GroupInfo struct {
Name string `json:"name"`
Parent string `json:"parent"`
Members []MemberInfo `json:"members"`
}
// UserInfo describes one user's memberships and resulting effective role.
type UserInfo struct {
Name string `json:"name"`
EffectiveRole string `json:"effectiveRole"`
Roles map[string]string `json:"roles"` // group → role token
}
// AccessSnapshot is the full mutable access state, rendered for the admin pane.
type AccessSnapshot struct {
DefaultUser string `json:"defaultUser"`
PublicGroup string `json:"publicGroup"`
Roles []string `json:"roles"` // role ladder, low→high
Configured bool `json:"configured"`
Users []UserInfo `json:"users"`
Groups []GroupInfo `json:"groups"`
}
// Snapshot returns a copy of the full access configuration for the admin pane.
func (p *Policy) Snapshot() AccessSnapshot {
p.mu.RLock()
defer p.mu.RUnlock()
snap := AccessSnapshot{
DefaultUser: p.defaultUser,
PublicGroup: PublicGroup,
Roles: append([]string(nil), RoleNames...),
Configured: p.configuredLocked(),
}
userSet := make(map[string]bool)
for _, name := range p.groupNamesLocked() {
g := p.groups[name]
gi := GroupInfo{Name: name, Parent: g.parent}
users := make([]string, 0, len(g.members))
for u := range g.members {
users = append(users, u)
userSet[u] = true
}
sort.Strings(users)
for _, u := range users {
gi.Members = append(gi.Members, MemberInfo{User: u, Role: g.members[u].String()})
}
snap.Groups = append(snap.Groups, gi)
}
users := make([]string, 0, len(userSet))
for u := range userSet {
users = append(users, u)
}
sort.Strings(users)
for _, u := range users {
ui := UserInfo{Name: u, EffectiveRole: p.effectiveRoleLocked(u).String(), Roles: make(map[string]string)}
for name, g := range p.groups {
if r, ok := g.members[u]; ok {
ui.Roles[name] = r.String()
}
}
snap.Users = append(snap.Users, ui)
}
return snap
}
// ── mutations (admin pane) ─────────────────────────────────────────────────
// ErrNotFound is returned when a named group does not exist.
var ErrNotFound = errors.New("group not found")
// SetMemberRole assigns a user a role within an existing group.
func (p *Policy) SetMemberRole(groupName, user string, role Role) error {
groupName, user = strings.TrimSpace(groupName), strings.TrimSpace(user)
if groupName == "" {
return errors.New("empty group name")
}
if user == "" {
return errors.New("empty user")
}
p.mu.Lock()
defer p.mu.Unlock()
g, ok := p.groups[groupName]
if !ok {
return ErrNotFound
}
g.members[user] = role
return p.saveLocked()
}
// RemoveMember drops a user's explicit role in a group.
func (p *Policy) RemoveMember(groupName, user string) error {
groupName, user = strings.TrimSpace(groupName), strings.TrimSpace(user)
p.mu.Lock()
defer p.mu.Unlock()
g, ok := p.groups[groupName]
if !ok {
return ErrNotFound
}
delete(g.members, user)
return p.saveLocked()
}
// SetUserRoles replaces a user's full set of memberships: the user is placed in
// exactly the listed groups with the given roles and removed from all others.
// Listed groups that do not exist are created.
func (p *Policy) SetUserRoles(user string, roles map[string]Role) error {
user = strings.TrimSpace(user)
if user == "" {
return errors.New("empty user")
}
p.mu.Lock()
defer p.mu.Unlock()
want := make(map[string]Role, len(roles))
for g, r := range roles {
if g = strings.TrimSpace(g); g != "" {
want[g] = r
p.ensureGroupLocked(g)
}
}
for name, g := range p.groups {
if r, ok := want[name]; ok {
g.members[user] = r
} else {
delete(g.members, user)
}
}
p.normalizeParentsLocked()
return p.saveLocked()
}
// CreateGroup adds an empty group with an optional parent if it does not exist.
func (p *Policy) CreateGroup(name, parent string) error {
name, parent = strings.TrimSpace(name), strings.TrimSpace(parent)
if name == "" {
return errors.New("empty group name")
}
p.mu.Lock()
defer p.mu.Unlock()
if _, ok := p.groups[name]; ok {
return nil
}
g := p.ensureGroupLocked(name)
g.parent = parent
p.normalizeParentsLocked()
return p.saveLocked()
}
// SetGroup replaces an existing group's parent and members. The public group's
// parent is always forced to root.
func (p *Policy) SetGroup(name, parent string, members map[string]Role) error {
name, parent = strings.TrimSpace(name), strings.TrimSpace(parent)
if name == "" {
return errors.New("empty group name")
}
p.mu.Lock()
defer p.mu.Unlock()
g, ok := p.groups[name]
if !ok {
return ErrNotFound
}
if name != PublicGroup {
g.parent = parent
}
g.members = make(map[string]Role, len(members))
for u, r := range members {
if u = strings.TrimSpace(u); u != "" {
g.members[u] = r
}
}
p.normalizeParentsLocked()
return p.saveLocked()
}
// RenameGroup renames a group, re-pointing any children at the new name. The
// public group cannot be renamed.
func (p *Policy) RenameGroup(oldName, newName string) error {
oldName, newName = strings.TrimSpace(oldName), strings.TrimSpace(newName)
if oldName == "" || newName == "" {
return errors.New("empty group name")
}
if oldName == PublicGroup {
return errors.New("cannot rename the public group")
}
p.mu.Lock()
defer p.mu.Unlock()
g, ok := p.groups[oldName]
if !ok {
return ErrNotFound
}
if oldName == newName {
return nil
}
if _, exists := p.groups[newName]; exists {
return errors.New("group already exists: " + newName)
}
delete(p.groups, oldName)
p.groups[newName] = g
for _, other := range p.groups {
if other.parent == oldName {
other.parent = newName
}
}
return p.saveLocked()
}
// DeleteGroup removes a group, reparenting its children to the deleted group's
// parent. The public group cannot be deleted; member users keep other groups.
func (p *Policy) DeleteGroup(name string) error {
name = strings.TrimSpace(name)
if name == PublicGroup {
return errors.New("cannot delete the public group")
}
p.mu.Lock()
defer p.mu.Unlock()
g, ok := p.groups[name]
if !ok {
return ErrNotFound
}
parent := g.parent
delete(p.groups, name)
for _, other := range p.groups {
if other.parent == name {
other.parent = parent
}
}
p.normalizeParentsLocked()
return p.saveLocked()
}
// ── request-scoped user identity ───────────────────────────────────────────
type ctxKey struct{}
+257 -26
View File
@@ -1,35 +1,266 @@
package access
import "testing"
import (
"os"
"path/filepath"
"sync"
"testing"
)
func TestCanEditLogic(t *testing.T) {
groups := map[string][]string{"ops": {"carol"}}
// spec is a small helper to build a GroupSpec.
func spec(name, parent string, members map[string]Role) GroupSpec {
return GroupSpec{Name: name, Parent: parent, Members: members}
}
// No allowlist configured: everyone with write access may edit logic.
open := New("", nil, groups, nil)
func TestUnconfiguredIsOpen(t *testing.T) {
// No roles assigned anywhere → fully open (everyone is admin), matching a
// fresh/dev deployment.
p := New("", nil)
for _, u := range []string{"", "alice", "carol"} {
if !open.CanEditLogic(u) {
t.Errorf("unrestricted: CanEditLogic(%q) = false, want true", u)
if !p.CanAdmin(u) {
t.Errorf("unconfigured: CanAdmin(%q) = false, want true", u)
}
}
if open.LogicRestricted() {
t.Error("LogicRestricted() = true with no allowlist")
}
// Allowlist by username and by group name.
p := New("", nil, groups, []string{"alice", "ops"})
if !p.LogicRestricted() {
t.Error("LogicRestricted() = false with allowlist set")
}
cases := map[string]bool{
"": true, // anonymous / trusted LAN
"alice": true, // listed user
"carol": true, // member of listed group "ops"
"bob": false, // not listed
}
for u, want := range cases {
if got := p.CanEditLogic(u); got != want {
t.Errorf("CanEditLogic(%q) = %v, want %v", u, got, want)
if p.Level(u) != LevelWrite {
t.Errorf("unconfigured: Level(%q) = %v, want write", u, p.Level(u))
}
}
}
func TestRoleLadder(t *testing.T) {
p := New("", []GroupSpec{
spec("public", "", map[string]Role{"adm": RoleAdmin}),
spec("ops", "", map[string]Role{
"viewer": RoleViewer,
"op": RoleOperator,
"logic": RoleLogic,
"aud": RoleAuditor,
}),
})
// Configured now → unlisted users (and anonymous) are viewers (read-only).
if p.Level("") != LevelRead {
t.Errorf("anonymous Level = %v, want read", p.Level(""))
}
if p.Level("nobody") != LevelRead {
t.Errorf("unlisted Level = %v, want read", p.Level("nobody"))
}
cases := []struct {
user string
level Level
editLogic bool
audit bool
admin bool
}{
{"viewer", LevelRead, false, false, false},
{"op", LevelWrite, false, false, false},
{"logic", LevelWrite, true, false, false},
{"aud", LevelWrite, true, true, false},
{"adm", LevelWrite, true, true, true},
}
for _, c := range cases {
if p.Level(c.user) != c.level {
t.Errorf("%s: Level = %v, want %v", c.user, p.Level(c.user), c.level)
}
if p.CanEditLogic(c.user) != c.editLogic {
t.Errorf("%s: CanEditLogic = %v, want %v", c.user, p.CanEditLogic(c.user), c.editLogic)
}
if p.CanViewAudit(c.user) != c.audit {
t.Errorf("%s: CanViewAudit = %v, want %v", c.user, p.CanViewAudit(c.user), c.audit)
}
if p.CanAdmin(c.user) != c.admin {
t.Errorf("%s: CanAdmin = %v, want %v", c.user, p.CanAdmin(c.user), c.admin)
}
}
}
func TestEffectiveRoleIsMaxAcrossGroups(t *testing.T) {
p := New("", []GroupSpec{
spec("public", "", map[string]Role{"x": RoleViewer}),
spec("a", "", map[string]Role{"x": RoleOperator}),
spec("b", "", map[string]Role{"x": RoleAuditor}),
})
if got := p.EffectiveRole("x"); got != RoleAuditor {
t.Errorf("EffectiveRole(x) = %v, want auditor (max across groups)", got)
}
}
func TestNestingInheritsMembership(t *testing.T) {
// org → team-a → squad-1. A member of org is effectively in the descendants
// too (membership flows parent→child), surfaced via GroupsOf for panel ACLs.
p := New("", []GroupSpec{
spec("org", "", map[string]Role{"boss": RoleAdmin}),
spec("team-a", "org", nil),
spec("squad-1", "team-a", nil),
})
groups := p.GroupsOf("boss")
for _, want := range []string{"org", "team-a", "squad-1"} {
if !containsStr(groups, want) {
t.Errorf("GroupsOf(boss) = %v, missing %q", groups, want)
}
}
}
func TestMutationsRoundTrip(t *testing.T) {
p := New("", []GroupSpec{
spec("public", "", map[string]Role{"root": RoleAdmin}),
spec("ops", "", map[string]Role{"carol": RoleOperator}),
})
// Set a user's full membership set: dave operator in ops, admin in eng (new).
if err := p.SetUserRoles("dave", map[string]Role{"ops": RoleOperator, "eng": RoleAdmin}); err != nil {
t.Fatal(err)
}
if g := p.GroupsOf("dave"); !containsStr(g, "ops") || !containsStr(g, "eng") {
t.Errorf("GroupsOf(dave) = %v, want ops+eng", g)
}
if !p.CanAdmin("dave") { // admin in eng
t.Error("CanAdmin(dave) = false after admin role in eng")
}
// Replacing membership removes dave from ops.
if err := p.SetUserRoles("dave", map[string]Role{"eng": RoleAdmin}); err != nil {
t.Fatal(err)
}
if containsStr(p.GroupsOf("dave"), "ops") {
t.Error("dave still in ops after membership replaced")
}
// Rename carries dave's admin grant from eng → engineering.
if err := p.RenameGroup("eng", "engineering"); err != nil {
t.Fatal(err)
}
if !p.CanAdmin("dave") {
t.Error("CanAdmin(dave) = false after eng renamed to engineering")
}
if !containsStr(p.GroupNames(), "engineering") || containsStr(p.GroupNames(), "eng") {
t.Errorf("GroupNames after rename = %v", p.GroupNames())
}
// Delete drops the group; dave loses admin but root (in public) keeps it.
if err := p.DeleteGroup("engineering"); err != nil {
t.Fatal(err)
}
if p.CanAdmin("dave") {
t.Error("CanAdmin(dave) = true after engineering deleted")
}
if !p.CanAdmin("root") {
t.Error("CanAdmin(root) = false; root grant in public should survive")
}
if err := p.DeleteGroup("nope"); err != ErrNotFound {
t.Errorf("DeleteGroup(nope) = %v, want ErrNotFound", err)
}
}
func TestPublicGroupProtected(t *testing.T) {
p := New("", []GroupSpec{spec("public", "", map[string]Role{"a": RoleAdmin})})
if err := p.DeleteGroup(PublicGroup); err == nil {
t.Error("DeleteGroup(public) = nil, want error")
}
if err := p.RenameGroup(PublicGroup, "other"); err == nil {
t.Error("RenameGroup(public) = nil, want error")
}
if !containsStr(p.GroupNames(), PublicGroup) {
t.Error("public group missing after protected mutations")
}
}
func TestParentCycleRejected(t *testing.T) {
p := New("", []GroupSpec{
spec("a", "", map[string]Role{"x": RoleViewer}),
spec("b", "a", nil),
})
// Making a's parent b would create a cycle a→b→a; it must be dropped to root.
if err := p.SetGroup("a", "b", map[string]Role{"x": RoleViewer}); err != nil {
t.Fatal(err)
}
snap := p.Snapshot()
for _, g := range snap.Groups {
if g.Name == "a" && g.Parent != "" {
t.Errorf("group a parent = %q, want root (cycle should be dropped)", g.Parent)
}
}
}
func TestPersistenceRoundTrip(t *testing.T) {
dir := t.TempDir()
p := New("admin", []GroupSpec{
spec("public", "", map[string]Role{"alice": RoleLogic}),
spec("ops", "", map[string]Role{"carol": RoleAdmin}),
})
if err := p.EnablePersistence(dir); err != nil {
t.Fatal(err)
}
// A mutation triggers the first write of access.json.
if err := p.SetUserRoles("dave", map[string]Role{"ops": RoleOperator}); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(dir, "access.json")); err != nil {
t.Fatalf("access.json not written: %v", err)
}
// A fresh policy loading the same dir should see the persisted state, not the
// (different) seed it was constructed with.
p2 := New("seed", []GroupSpec{spec("public", "", map[string]Role{"seeduser": RoleViewer})})
if err := p2.EnablePersistence(dir); err != nil {
t.Fatal(err)
}
if p2.ResolveUser("") != "admin" {
t.Errorf("default user = %q, want admin", p2.ResolveUser(""))
}
if !p2.CanEditLogic("alice") || p2.CanEditLogic("zed") {
t.Error("logic-editor role not persisted")
}
if !p2.CanAdmin("carol") {
t.Error("admin role not persisted")
}
if !containsStr(p2.GroupsOf("dave"), "ops") {
t.Error("dave membership not persisted")
}
if p2.EffectiveRole("seeduser") != RoleViewer || p2.Level("seeduser") != LevelRead {
// seeduser came from the discarded seed; should be an unlisted viewer now.
t.Error("persisted state did not supersede the seed")
}
}
func TestConcurrentAccess(t *testing.T) {
p := New("", []GroupSpec{
spec("public", "", map[string]Role{"root": RoleAdmin}),
spec("ops", "", map[string]Role{"carol": RoleOperator}),
})
if err := p.EnablePersistence(t.TempDir()); err != nil {
t.Fatal(err)
}
var wg sync.WaitGroup
for i := 0; i < 8; i++ {
wg.Add(2)
go func() {
defer wg.Done()
for j := 0; j < 100; j++ {
p.CanAdmin("carol")
p.Level("carol")
p.GroupsOf("carol")
p.Snapshot()
}
}()
go func() {
defer wg.Done()
for j := 0; j < 100; j++ {
p.SetMemberRole("ops", "carol", RoleAuditor)
p.SetUserRoles("carol", map[string]Role{"ops": RoleOperator, "eng": RoleAdmin})
p.RemoveMember("ops", "carol")
}
}()
}
wg.Wait()
}
func containsStr(xs []string, v string) bool {
for _, x := range xs {
if x == v {
return true
}
}
return false
}
+567 -11
View File
@@ -2,22 +2,31 @@
package api
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"net/url"
"os"
"runtime"
"strconv"
"strings"
"time"
"github.com/uopi/uopi/internal/access"
"github.com/uopi/uopi/internal/audit"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/confmgr"
"github.com/uopi/uopi/internal/controllogic"
"github.com/uopi/uopi/internal/datasource"
"github.com/uopi/uopi/internal/datasource/synthetic"
"github.com/uopi/uopi/internal/metrics"
"github.com/uopi/uopi/internal/panelacl"
"github.com/uopi/uopi/internal/storage"
)
@@ -27,25 +36,33 @@ type Handler struct {
broker *broker.Broker
synthetic *synthetic.Synthetic // nil if not enabled
store *storage.Store
cfg *confmgr.Store
policy *access.Policy
acl *panelacl.Store
ctrlLogic *controllogic.Store
ctrlEngine *controllogic.Engine
channelFinderURL string // empty if not configured
archiverURL string // empty if not configured
audit audit.Recorder // never nil; audit.Nop when disabled
channelFinderURL string // empty if not configured
archiverURL string // empty if not configured
log *slog.Logger
}
// New creates an API Handler. synth may be nil if the synthetic DS is disabled.
func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, policy *access.Policy, acl *panelacl.Store, ctrlLogic *controllogic.Store, ctrlEngine *controllogic.Engine, channelFinderURL, archiverURL string, log *slog.Logger) *Handler {
// rec records system-affecting mutations; pass audit.Nop() to disable auditing.
func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, cfg *confmgr.Store, policy *access.Policy, acl *panelacl.Store, ctrlLogic *controllogic.Store, ctrlEngine *controllogic.Engine, rec audit.Recorder, channelFinderURL, archiverURL string, log *slog.Logger) *Handler {
if rec == nil {
rec = audit.Nop()
}
return &Handler{
broker: b,
synthetic: synth,
store: store,
cfg: cfg,
policy: policy,
acl: acl,
ctrlLogic: ctrlLogic,
ctrlEngine: ctrlEngine,
audit: rec,
channelFinderURL: channelFinderURL,
archiverURL: archiverURL,
log: log,
@@ -56,6 +73,7 @@ func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, pol
// Requires Go 1.22+ for method+path routing.
func (h *Handler) Register(mux *http.ServeMux, prefix string) {
mux.HandleFunc("GET "+prefix+"/me", h.getMe)
mux.HandleFunc("GET "+prefix+"/audit", h.getAudit)
mux.HandleFunc("GET "+prefix+"/datasources", h.listDataSources)
mux.HandleFunc("GET "+prefix+"/signals", h.listSignals)
mux.HandleFunc("GET "+prefix+"/signals/search", h.searchSignals)
@@ -83,6 +101,14 @@ func (h *Handler) Register(mux *http.ServeMux, prefix string) {
mux.HandleFunc("DELETE "+prefix+"/folders/{id}", h.deleteFolder)
// User groups defined in config (read-only; for the sharing UI)
mux.HandleFunc("GET "+prefix+"/usergroups", h.listUserGroups)
// Admin pane: runtime access management + server statistics. Every route is
// gated by CanAdmin (the admins allowlist).
mux.HandleFunc("GET "+prefix+"/admin/access", h.getAdminAccess)
mux.HandleFunc("PUT "+prefix+"/admin/users/{user}", h.putAdminUser)
mux.HandleFunc("POST "+prefix+"/admin/groups", h.createAdminGroup)
mux.HandleFunc("PUT "+prefix+"/admin/groups/{name}", h.updateAdminGroup)
mux.HandleFunc("DELETE "+prefix+"/admin/groups/{name}", h.deleteAdminGroup)
mux.HandleFunc("GET "+prefix+"/admin/stats", h.getAdminStats)
// Signal group tree
mux.HandleFunc("GET "+prefix+"/groups", h.getGroups)
mux.HandleFunc("PUT "+prefix+"/groups", h.putGroups)
@@ -92,6 +118,13 @@ func (h *Handler) Register(mux *http.ServeMux, prefix string) {
mux.HandleFunc("GET "+prefix+"/synthetic/{name}", h.getSynthetic)
mux.HandleFunc("PUT "+prefix+"/synthetic/{name}", h.updateSynthetic)
mux.HandleFunc("DELETE "+prefix+"/synthetic/{name}", h.deleteSynthetic)
// Single-shot debug trace of an unsaved synthetic graph (live editor overlay).
mux.HandleFunc("POST "+prefix+"/synthetic/trace", h.traceSynthetic)
// 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
// middleware; each mutation reloads the running engine).
mux.HandleFunc("GET "+prefix+"/controllogic", h.listControlLogic)
@@ -99,6 +132,52 @@ func (h *Handler) Register(mux *http.ServeMux, prefix string) {
mux.HandleFunc("GET "+prefix+"/controllogic/{id}", h.getControlLogic)
mux.HandleFunc("PUT "+prefix+"/controllogic/{id}", h.updateControlLogic)
mux.HandleFunc("DELETE "+prefix+"/controllogic/{id}", h.deleteControlLogic)
// 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("POST "+prefix+"/config/rules/preview", h.previewConfigRule)
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 ─────────────────────────────────────────────────────────────────────
@@ -118,15 +197,94 @@ func (h *Handler) getMe(w http.ResponseWriter, r *http.Request) {
"level": h.policy.Level(user).String(),
"groups": groups,
"canEditLogic": h.policy.CanEditLogic(user),
"canViewAudit": h.policy.CanViewAudit(user),
"canAdmin": h.policy.CanAdmin(user),
})
}
// ── /audit ────────────────────────────────────────────────────────────────────
// getAudit returns audit-log entries matching the query filters. Access is
// restricted to users permitted by CanViewAudit (the audit-readers allowlist).
// Filters: start, end (RFC3339), user, action, ds, signal, limit.
func (h *Handler) getAudit(w http.ResponseWriter, r *http.Request) {
if !h.policy.CanViewAudit(caller(r)) {
jsonError(w, http.StatusForbidden, "you are not permitted to view the audit log")
return
}
q := r.URL.Query()
f := audit.Filter{
Actor: q.Get("user"),
Action: q.Get("action"),
DS: q.Get("ds"),
Signal: q.Get("signal"),
}
if v := q.Get("start"); v != "" {
t, err := time.Parse(time.RFC3339, v)
if err != nil {
jsonError(w, http.StatusBadRequest, "invalid start time (want RFC3339): "+v)
return
}
f.Start = t
}
if v := q.Get("end"); v != "" {
t, err := time.Parse(time.RFC3339, v)
if err != nil {
jsonError(w, http.StatusBadRequest, "invalid end time (want RFC3339): "+v)
return
}
f.End = t
}
if v := q.Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
f.Limit = n
}
}
events, err := h.audit.Query(f)
if err != nil {
h.log.Error("audit query", "err", err)
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
jsonOK(w, events)
}
// ── access helpers ────────────────────────────────────────────────────────────
// caller returns the resolved end-user identity for the request (set by the
// access middleware).
func caller(r *http.Request) string { return access.UserFrom(r.Context()) }
// clientIP returns the best-effort client address for audit attribution,
// preferring the proxy-set X-Forwarded-For / X-Real-IP headers.
func clientIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if i := strings.IndexByte(xff, ','); i >= 0 {
return strings.TrimSpace(xff[:i])
}
return strings.TrimSpace(xff)
}
if xr := r.Header.Get("X-Real-IP"); xr != "" {
return strings.TrimSpace(xr)
}
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
return host
}
return r.RemoteAddr
}
// recordMutation appends a successful configuration change to the audit log.
func (h *Handler) recordMutation(r *http.Request, action, detail string) {
h.audit.Record(audit.Event{
Actor: caller(r),
ActorType: audit.ActorUser,
Action: action,
Detail: detail,
IP: clientIP(r),
Outcome: audit.OutcomeOK,
})
}
// capByGlobal lowers a per-panel/folder permission to what the user's global
// access level allows: read-only users can never exceed read, no-access users
// get nothing.
@@ -142,23 +300,25 @@ func (h *Handler) capByGlobal(user string, p panelacl.Perm) panelacl.Perm {
return p
}
// panelPerm returns the caller's effective permission on a panel. A request
// with no resolved identity (no proxy header and no default_user) is a trusted
// LAN deployment with no per-user enforcement, so it gets full write.
// panelPerm returns the caller's effective permission on a panel. A request with
// no resolved identity (no proxy header and no default_user) has no per-user ACL,
// so it is governed purely by the global level: full write on an unconfigured
// (open) policy, read-only once roles are configured.
func (h *Handler) panelPerm(r *http.Request, id string) panelacl.Perm {
user := caller(r)
if user == "" {
return panelacl.PermWrite
return h.capByGlobal("", panelacl.PermWrite)
}
return h.capByGlobal(user, h.acl.PanelPerm(id, user, h.policy.GroupsOf(user)))
}
// folderPerm returns the caller's effective permission on a folder. As with
// panelPerm, an unidentified caller is trusted with full write.
// panelPerm, an unidentified caller is governed by the global level (full write
// on an unconfigured/open policy, read-only once roles are configured).
func (h *Handler) folderPerm(r *http.Request, id string) panelacl.Perm {
user := caller(r)
if user == "" {
return panelacl.PermWrite
return h.capByGlobal("", panelacl.PermWrite)
}
return h.capByGlobal(user, h.acl.FolderPerm(id, user, h.policy.GroupsOf(user)))
}
@@ -490,6 +650,7 @@ func (h *Handler) createInterface(w http.ResponseWriter, r *http.Request) {
h.log.Error("record panel ownership", "id", id, "err", err)
}
}
h.recordMutation(r, "interface.create", id)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(map[string]string{"id": id})
@@ -580,6 +741,7 @@ func (h *Handler) updateInterface(w http.ResponseWriter, r *http.Request) {
}
return
}
h.recordMutation(r, "interface.update", id)
w.WriteHeader(http.StatusNoContent)
}
@@ -721,6 +883,7 @@ func (h *Handler) deleteInterface(w http.ResponseWriter, r *http.Request) {
if err := h.acl.DeletePanel(id); err != nil {
h.log.Error("delete panel ACL", "id", id, "err", err)
}
h.recordMutation(r, "interface.delete", id)
w.WriteHeader(http.StatusNoContent)
}
@@ -969,6 +1132,209 @@ func (h *Handler) listUserGroups(w http.ResponseWriter, _ *http.Request) {
jsonOK(w, names)
}
// ── /admin ──────────────────────────────────────────────────────────────────
// requireAdmin writes a 403 and returns false unless the caller may use the
// admin pane (CanAdmin). Anonymous/trusted-LAN callers and, when no admins
// allowlist is configured, everyone, are permitted.
func (h *Handler) requireAdmin(w http.ResponseWriter, r *http.Request) bool {
if h.policy.CanAdmin(caller(r)) {
return true
}
jsonError(w, http.StatusForbidden, "you are not permitted to administer this server")
return false
}
// getAdminAccess returns the full mutable access configuration (users, groups,
// allowlists) for the admin pane.
func (h *Handler) getAdminAccess(w http.ResponseWriter, r *http.Request) {
if !h.requireAdmin(w, r) {
return
}
jsonOK(w, h.policy.Snapshot())
}
type adminUserReq struct {
// Roles maps each group the user should belong to → their role token. The
// user is removed from any group not listed. Unknown groups are created.
Roles map[string]string `json:"roles"`
}
// putAdminUser replaces a user's full set of (group → role) memberships.
func (h *Handler) putAdminUser(w http.ResponseWriter, r *http.Request) {
if !h.requireAdmin(w, r) {
return
}
user := strings.TrimSpace(r.PathValue("user"))
if user == "" {
jsonError(w, http.StatusBadRequest, "empty user")
return
}
var req adminUserReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
roles := make(map[string]access.Role, len(req.Roles))
for g, token := range req.Roles {
if g = strings.TrimSpace(g); g != "" {
roles[g] = access.ParseRole(token)
}
}
if err := h.policy.SetUserRoles(user, roles); err != nil {
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
h.recordMutation(r, "admin.user", user)
jsonOK(w, h.policy.Snapshot())
}
type adminGroupReq struct {
Name string `json:"name"` // for update: the (possibly new) group name
Parent string `json:"parent"` // parent group for nesting ("" = root)
Members map[string]string `json:"members"`
}
// createAdminGroup creates an empty group with an optional parent.
func (h *Handler) createAdminGroup(w http.ResponseWriter, r *http.Request) {
if !h.requireAdmin(w, r) {
return
}
var req adminGroupReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
if strings.TrimSpace(req.Name) == "" {
jsonError(w, http.StatusBadRequest, "empty group name")
return
}
if err := h.policy.CreateGroup(req.Name, req.Parent); err != nil {
jsonError(w, http.StatusBadRequest, err.Error())
return
}
h.recordMutation(r, "admin.group.create", req.Name)
w.WriteHeader(http.StatusCreated)
jsonOK(w, h.policy.Snapshot())
}
// updateAdminGroup renames a group (when the body name differs from the path)
// and replaces its parent and member roles.
func (h *Handler) updateAdminGroup(w http.ResponseWriter, r *http.Request) {
if !h.requireAdmin(w, r) {
return
}
old := strings.TrimSpace(r.PathValue("name"))
var req adminGroupReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
name := strings.TrimSpace(req.Name)
if name == "" {
name = old
}
if name != old {
if err := h.policy.RenameGroup(old, name); err != nil {
if errors.Is(err, access.ErrNotFound) {
jsonError(w, http.StatusNotFound, "group not found: "+old)
return
}
jsonError(w, http.StatusBadRequest, err.Error())
return
}
}
members := make(map[string]access.Role, len(req.Members))
for u, token := range req.Members {
if u = strings.TrimSpace(u); u != "" {
members[u] = access.ParseRole(token)
}
}
if err := h.policy.SetGroup(name, req.Parent, members); err != nil {
if errors.Is(err, access.ErrNotFound) {
jsonError(w, http.StatusNotFound, "group not found: "+name)
return
}
jsonError(w, http.StatusBadRequest, err.Error())
return
}
h.recordMutation(r, "admin.group.update", name)
jsonOK(w, h.policy.Snapshot())
}
// deleteAdminGroup removes a group (members keep their other groups; children
// are reparented). The built-in public group cannot be deleted.
func (h *Handler) deleteAdminGroup(w http.ResponseWriter, r *http.Request) {
if !h.requireAdmin(w, r) {
return
}
name := strings.TrimSpace(r.PathValue("name"))
if err := h.policy.DeleteGroup(name); err != nil {
if errors.Is(err, access.ErrNotFound) {
jsonError(w, http.StatusNotFound, "group not found: "+name)
return
}
jsonError(w, http.StatusBadRequest, err.Error())
return
}
h.recordMutation(r, "admin.group.delete", name)
jsonOK(w, h.policy.Snapshot())
}
// getAdminStats reports live server statistics: the in-process metrics counters,
// observed signals and data sources from the broker, Go runtime stats, and the
// system load average on Linux.
func (h *Handler) getAdminStats(w http.ResponseWriter, r *http.Request) {
if !h.requireAdmin(w, r) {
return
}
m := metrics.Snapshot()
var ms runtime.MemStats
runtime.ReadMemStats(&ms)
sources := h.broker.DataSources()
dsNames := make([]string, len(sources))
for i, ds := range sources {
dsNames[i] = ds.Name()
}
jsonOK(w, map[string]any{
"uptimeSeconds": m.UptimeSeconds,
"wsConnections": m.WsConnections,
"observedSignals": h.broker.ActiveSubscriptions(),
"msgIn": m.MsgIn,
"msgOut": m.MsgOut,
"writeOps": m.WriteOps,
"historyReqs": m.HistoryReqs,
"goroutines": runtime.NumGoroutine(),
"memAllocBytes": ms.Alloc,
"memSysBytes": ms.Sys,
"heapInuseBytes": ms.HeapInuse,
"loadAvg": readLoadAvg(),
"dataSources": dsNames,
})
}
// readLoadAvg returns the 1/5/15-minute system load averages from
// /proc/loadavg, or nil on non-Linux systems or any read/parse error.
func readLoadAvg() []float64 {
data, err := os.ReadFile("/proc/loadavg")
if err != nil {
return nil
}
fields := strings.Fields(string(data))
if len(fields) < 3 {
return nil
}
out := make([]float64, 3)
for i := 0; i < 3; i++ {
v, err := strconv.ParseFloat(fields[i], 64)
if err != nil {
return nil
}
out[i] = v
}
return out
}
// genID returns a short unique id with the given prefix (e.g. "fld-1a2b3c4d").
func genID(prefix string) string {
var b [8]byte
@@ -1076,6 +1442,110 @@ func (h *Handler) deleteSynthetic(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// traceSynthetic evaluates an unsaved synthetic graph once against the current
// live value of each source signal and returns every node's computed value. It
// persists nothing; it powers the editor's live/debug overlay.
func (h *Handler) traceSynthetic(w http.ResponseWriter, r *http.Request) {
if h.synthetic == nil {
jsonError(w, http.StatusServiceUnavailable, "synthetic data source not enabled")
return
}
var def synthetic.SignalDef
if err := json.NewDecoder(r.Body).Decode(&def); err != nil {
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
read := func(ds, name string) (any, error) {
v, err := h.broker.ReadNow(ctx, broker.SignalRef{DS: ds, Name: name})
if err != nil {
return nil, err
}
return v.Data, nil
}
res, err := h.synthetic.Trace(def, read)
if err != nil {
jsonError(w, http.StatusBadRequest, err.Error())
return
}
jsonOK(w, res)
}
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 ──────────────────────────────────────────────────────────────
func (h *Handler) listControlLogic(w http.ResponseWriter, _ *http.Request) {
@@ -1123,6 +1593,7 @@ func (h *Handler) createControlLogic(w http.ResponseWriter, r *http.Request) {
return
}
h.ctrlEngine.Reload()
h.recordMutation(r, "controllogic.create", g.ID+" "+g.Name)
w.WriteHeader(http.StatusCreated)
jsonOK(w, g)
}
@@ -1152,6 +1623,7 @@ func (h *Handler) updateControlLogic(w http.ResponseWriter, r *http.Request) {
return
}
h.ctrlEngine.Reload()
h.recordMutation(r, "controllogic.update", g.ID+" "+g.Name)
jsonOK(w, g)
}
@@ -1164,14 +1636,98 @@ func (h *Handler) deleteControlLogic(w http.ResponseWriter, r *http.Request) {
jsonError(w, http.StatusForbidden, "you are not permitted to edit logic")
return
}
if err := h.ctrlLogic.Delete(r.PathValue("id")); err != nil {
jsonError(w, http.StatusNotFound, "control logic graph not found: "+r.PathValue("id"))
id := r.PathValue("id")
if err := h.ctrlLogic.Delete(id); err != nil {
jsonError(w, http.StatusNotFound, "control logic graph not found: "+id)
return
}
h.ctrlEngine.Reload()
h.recordMutation(r, "controllogic.delete", id)
w.WriteHeader(http.StatusNoContent)
}
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 ───────────────────────────────────────────────────────────────────
// extractLogicBlock returns the verbatim <logic>…</logic> section of an
+165 -5
View File
@@ -14,7 +14,9 @@ import (
"github.com/uopi/uopi/internal/access"
"github.com/uopi/uopi/internal/api"
"github.com/uopi/uopi/internal/audit"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/confmgr"
"github.com/uopi/uopi/internal/controllogic"
"github.com/uopi/uopi/internal/datasource/stub"
"github.com/uopi/uopi/internal/panelacl"
@@ -50,10 +52,16 @@ func setup(t *testing.T) (*httptest.Server, func()) {
if err != nil {
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()
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), acl, clStore, clEngine, audit.Nop(), "", "", log).Register(mux, "/api/v1")
srv := httptest.NewServer(mux)
return srv, func() {
@@ -214,7 +222,9 @@ func TestSearchSignals(t *testing.T) {
resp := get(t, srv, "/api/v1/signals/search?q=sine")
assertStatus(t, resp, http.StatusOK)
var signals []struct{ Name string `json:"name"` }
var signals []struct {
Name string `json:"name"`
}
readJSON(t, resp, &signals)
for _, s := range signals {
@@ -259,7 +269,9 @@ func TestInterfaceCRUD(t *testing.T) {
// Create
resp = postRaw(t, srv, "/api/v1/interfaces", "application/xml", []byte(sampleXML))
assertStatus(t, resp, http.StatusCreated)
var created struct{ ID string `json:"id"` }
var created struct {
ID string `json:"id"`
}
readJSON(t, resp, &created)
if created.ID == "" {
t.Fatal("expected non-empty ID from create")
@@ -303,7 +315,9 @@ func TestInterfaceCRUD(t *testing.T) {
t.Fatal("clone:", err)
}
assertStatus(t, resp, http.StatusCreated)
var cloned struct{ ID string `json:"id"` }
var cloned struct {
ID string `json:"id"`
}
readJSON(t, resp, &cloned)
if cloned.ID == created.ID {
t.Error("clone produced same ID as original")
@@ -438,6 +452,152 @@ func TestStorageValidateID(t *testing.T) {
}
}
// ── /api/v1/admin ─────────────────────────────────────────────────────────────
// adminSetup builds a server whose mux injects the user named by the
// "X-Test-User" header into the request context (mirroring the real access
// middleware), so admin-gating can be exercised. The policy restricts admin to
// the "ops" group, of which "alice" is a member.
func adminSetup(t *testing.T) (*httptest.Server, func()) {
t.Helper()
ctx, cancel := context.WithCancel(context.Background())
log := slog.New(slog.NewTextHandler(io.Discard, nil))
brk := broker.New(ctx, log)
ds := stub.New()
if err := ds.Connect(ctx); err != nil {
t.Fatal("stub connect:", err)
}
brk.Register(ds)
dir := t.TempDir()
store, _ := storage.New(dir)
acl, _ := panelacl.New(dir)
clStore, _ := controllogic.NewStore(dir)
cfgStore, _ := confmgr.New(dir)
clEngine := controllogic.NewEngine(ctx, brk, clStore, cfgStore, audit.Nop(), log)
// "alice" is an admin (via the ops group); everyone else is a viewer.
policy := access.New("", []access.GroupSpec{
{Name: "ops", Members: map[string]access.Role{"alice": access.RoleAdmin}},
})
if err := policy.EnablePersistence(dir); err != nil {
t.Fatal("EnablePersistence:", err)
}
inner := http.NewServeMux()
api.New(brk, nil, store, cfgStore, policy, acl, clStore, clEngine, audit.Nop(), "", "", log).Register(inner, "/api/v1")
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if u := r.Header.Get("X-Test-User"); u != "" {
r = r.WithContext(access.WithUser(r.Context(), u))
}
inner.ServeHTTP(w, r)
})
srv := httptest.NewServer(mux)
return srv, func() { srv.Close(); cancel() }
}
func reqAs(t *testing.T, srv *httptest.Server, method, path, user string, body []byte) *http.Response {
t.Helper()
var rdr io.Reader
if body != nil {
rdr = bytes.NewReader(body)
}
req, err := http.NewRequest(method, srv.URL+path, rdr)
if err != nil {
t.Fatal("NewRequest:", err)
}
if user != "" {
req.Header.Set("X-Test-User", user)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(method, path, err)
}
return resp
}
func TestAdminForbiddenForNonAdmin(t *testing.T) {
srv, teardown := adminSetup(t)
defer teardown()
// "bob" is only a viewer → 403 on every admin route.
assertStatus(t, reqAs(t, srv, http.MethodGet, "/api/v1/admin/access", "bob", nil), http.StatusForbidden)
assertStatus(t, reqAs(t, srv, http.MethodGet, "/api/v1/admin/stats", "bob", nil), http.StatusForbidden)
assertStatus(t, reqAs(t, srv, http.MethodPut, "/api/v1/admin/users/carol", "bob",
[]byte(`{"roles":{"public":"operator"}}`)), http.StatusForbidden)
}
func TestAdminUserAndGroupMutations(t *testing.T) {
srv, teardown := adminSetup(t)
defer teardown()
// alice (admin) assigns carol an auditor role in a new "team" group.
resp := reqAs(t, srv, http.MethodPut, "/api/v1/admin/users/carol", "alice",
[]byte(`{"roles":{"team":"auditor"}}`))
assertStatus(t, resp, http.StatusOK)
var snap access.AccessSnapshot
readJSON(t, resp, &snap)
var carol *access.UserInfo
for i := range snap.Users {
if snap.Users[i].Name == "carol" {
carol = &snap.Users[i]
}
}
if carol == nil {
t.Fatal("carol missing from snapshot")
}
if carol.EffectiveRole != "auditor" || carol.Roles["team"] != "auditor" {
t.Errorf("carol = %+v, want auditor in team", carol)
}
// Create (with parent), rename, and delete a group.
assertStatus(t, reqAs(t, srv, http.MethodPost, "/api/v1/admin/groups", "alice",
[]byte(`{"name":"eng","parent":"public"}`)), http.StatusCreated)
resp = reqAs(t, srv, http.MethodPut, "/api/v1/admin/groups/eng", "alice",
[]byte(`{"name":"engineering","members":{"carol":"admin"}}`))
assertStatus(t, resp, http.StatusOK)
readJSON(t, resp, &snap)
if !hasGroup(snap, "engineering") || hasGroup(snap, "eng") {
t.Errorf("groups after rename = %+v", snap.Groups)
}
assertStatus(t, reqAs(t, srv, http.MethodDelete, "/api/v1/admin/groups/engineering", "alice", nil), http.StatusOK)
assertStatus(t, reqAs(t, srv, http.MethodDelete, "/api/v1/admin/groups/nope", "alice", nil), http.StatusNotFound)
// The built-in public group cannot be deleted.
assertStatus(t, reqAs(t, srv, http.MethodDelete, "/api/v1/admin/groups/public", "alice", nil), http.StatusBadRequest)
}
func hasGroup(s access.AccessSnapshot, name string) bool {
for _, g := range s.Groups {
if g.Name == name {
return true
}
}
return false
}
func TestAdminStats(t *testing.T) {
srv, teardown := adminSetup(t)
defer teardown()
resp := reqAs(t, srv, http.MethodGet, "/api/v1/admin/stats", "alice", nil)
assertStatus(t, resp, http.StatusOK)
var stats map[string]any
readJSON(t, resp, &stats)
for _, k := range []string{"uptimeSeconds", "wsConnections", "observedSignals", "goroutines", "dataSources"} {
if _, ok := stats[k]; !ok {
t.Errorf("stats missing key %q", k)
}
}
}
// ── Ensure os is used (blank import guard) ─────────────────────────────────────
var _ = os.DevNull
+801
View File
@@ -0,0 +1,801 @@
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))
}
// previewConfigRule evaluates a (possibly unsaved) CUE source against a live
// snapshot of the bound set's target signals, without storing anything. Unlike
// check (which uses sample/default values), preview reads the current hardware
// values so the operator sees exactly what the rule would derive from the real
// configuration. Body: {setId, source}. Returns the captured snapshot plus the
// rule result (violations + transformed values).
func (h *Handler) previewConfigRule(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
var body struct {
SetID string `json:"setId"`
Source string `json:"source"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
set, err := h.cfg.GetSet(body.SetID)
if err != nil {
jsonError(w, configStatus(err), 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))
res := confmgr.EvaluateRule(body.Source, snap.Values)
jsonOK(w, struct {
Snapshot confmgr.SnapshotResult `json:"snapshot"`
Result confmgr.RuleResult `json:"result"`
}{snap, res})
}
+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
// subscriptions. Useful for diagnostics and tests.
func (b *Broker) ActiveSubscriptions() int {
+38 -27
View File
@@ -12,22 +12,34 @@ import (
type Config struct {
Server ServerConfig `toml:"server"`
Datasource DatasourceConfig `toml:"datasource"`
Audit AuditConfig `toml:"audit"`
// Groups are named sets of users, referenced by panel sharing rules.
Groups []GroupDef `toml:"groups"`
}
// GroupDef is a named set of users defined as [[groups]] in the config file.
type GroupDef struct {
Name string `toml:"name"`
Members []string `toml:"members"`
// 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. Who
// may *view* the log is governed by the auditor role (see GroupDef).
type AuditConfig struct {
Enabled bool `toml:"enabled"`
DBPath string `toml:"db_path"` // SQLite file; default {storage_dir}/audit.db
}
// BlacklistEntry downgrades a specific user's global access level. Levels:
// "readonly" (no writes) or "noaccess" (denied). Users not listed are trusted
// with full access.
type BlacklistEntry struct {
User string `toml:"user"`
Level string `toml:"level"`
// GroupDef defines one access group as [[groups]] in the config file. Each group
// has an optional parent (for nesting) and lists its members by role. Roles form
// a cumulative ladder: viewer < operator < logiceditor < auditor < admin. A user
// listed in several role buckets keeps the highest. The built-in "public" group
// (every user is an implicit viewer member) may be configured by name to raise
// specific users globally.
type GroupDef struct {
Name string `toml:"name"`
Parent string `toml:"parent"`
Viewers []string `toml:"viewers"`
Operators []string `toml:"operators"`
LogicEditors []string `toml:"logiceditors"`
Auditors []string `toml:"auditors"`
Admins []string `toml:"admins"`
}
type ServerConfig struct {
@@ -45,18 +57,14 @@ type ServerConfig struct {
// DefaultUser is the identity used when the trusted user header is absent or
// empty (e.g. unproxied/dev/LAN deployments). Empty leaves the user anonymous.
//
// Access levels (write/readonly), logic-edit, audit-view and admin rights are
// all granted via group roles (see GroupDef / [[groups]]). A config with no
// group roles at all is treated as fully open (everyone is admin), so an
// unconfigured deployment behaves like trusted LAN. Once the admin pane writes
// {storage_dir}/access.json, that file — not this config — is the source of
// truth for access.
DefaultUser string `toml:"default_user"`
// Blacklist downgrades specific users' global access level. Everyone not
// listed is trusted with full write access.
Blacklist []BlacklistEntry `toml:"blacklist"`
// LogicEditors optionally restricts who may add or edit panel logic (the
// <logic> block of interfaces) and server-side control logic. Entries are
// usernames or group names. When empty, no restriction applies (any user
// with write access may edit logic). Anonymous/trusted-LAN callers are
// always permitted.
LogicEditors []string `toml:"logic_editors"`
}
type DatasourceConfig struct {
@@ -71,10 +79,10 @@ type StubConfig struct {
}
type EPICSConfig struct {
Enabled bool `toml:"enabled"`
CAAddrList string `toml:"ca_addr_list"`
ArchiveURL string `toml:"archive_url"`
ChannelFinderURL string `toml:"channel_finder_url"`
Enabled bool `toml:"enabled"`
CAAddrList string `toml:"ca_addr_list"`
ArchiveURL string `toml:"archive_url"`
ChannelFinderURL string `toml:"channel_finder_url"`
AutoSyncFilter string `toml:"auto_sync_filter"`
AutoSyncFromArchiver bool `toml:"auto_sync_from_archiver"`
PVNames []string `toml:"pv_names"`
@@ -138,8 +146,11 @@ func applyEnv(cfg *Config) {
if v := env("UOPI_SERVER_DEFAULT_USER"); v != "" {
cfg.Server.DefaultUser = v
}
if v := env("UOPI_SERVER_LOGIC_EDITORS"); v != "" {
cfg.Server.LogicEditors = strings.Fields(v)
if v := env("UOPI_AUDIT_ENABLED"); v != "" {
cfg.Audit.Enabled = (v == "true" || v == "YES")
}
if v := env("UOPI_AUDIT_DB_PATH"); v != "" {
cfg.Audit.DBPath = v
}
if v := env("UOPI_EPICS_CA_ADDR_LIST"); 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)
}
}
}
+207
View File
@@ -0,0 +1,207 @@
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"`
// Enabled gates whether the rule runs when instances of the bound set are
// saved/applied. A nil pointer (legacy rules created before the flag) is
// treated as enabled, so existing rules keep their behaviour.
Enabled *bool `json:"enabled,omitempty"`
Version int `json:"version"`
Tag string `json:"tag,omitempty"`
Owner string `json:"owner,omitempty"`
Source string `json:"source"`
}
// IsEnabled reports whether the rule participates in instance evaluation. A nil
// Enabled flag (legacy rules) is treated as enabled.
func (r ConfigRule) IsEnabled() bool { return r.Enabled == nil || *r.Enabled }
// 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")
}
}
+692
View File
@@ -0,0 +1,692 @@
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"`
// Enabled is only populated for rules: nil for sets/instances and for legacy
// rules without the flag. Lets the rule list show enabled/disabled status
// without a GET per rule.
Enabled *bool `json:"enabled,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"`
Enabled *bool `json:"enabled"`
}
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, Enabled: h.Enabled})
}
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
}
if !rule.IsEnabled() {
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
}
+290
View File
@@ -0,0 +1,290 @@
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 TestDisabledRuleSkipped(t *testing.T) {
s, _ := New(t.TempDir())
set, _ := s.CreateSet(sampleSet(), "")
disabled := false
if _, err := s.CreateRule(ConfigRule{
Name: "voltage cap",
SetID: set.ID,
Enabled: &disabled,
Source: "voltage: <=24",
}, ""); err != nil {
t.Fatal(err)
}
// A value that would violate the rule still saves because the rule is off.
if _, err := s.CreateInstance(ConfigInstance{
Name: "bad", SetID: set.ID, Values: map[string]any{"voltage": 40.0},
}, ""); err != nil {
t.Fatalf("expected disabled rule to be skipped, got %v", err)
}
}
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)
}
}
+174
View File
@@ -0,0 +1,174 @@
package controllogic
import (
"context"
"sync"
"time"
"github.com/uopi/uopi/internal/broker"
)
// DebugEvent reports a single node execution for the live debug view. Value is
// only meaningful when HasValue is true (e.g. an action.write's written value or
// a flow.if branch as 0/1); otherwise the event just marks the node as active.
type DebugEvent struct {
GraphID string `json:"graphId"`
NodeID string `json:"nodeId"`
Value float64 `json:"value"`
HasValue bool `json:"hasValue"`
TS int64 `json:"ts"` // unix millis
}
// DebugObserver receives node-execution events from running (and simulated)
// graphs. The server implements it; Observe must not block (the hub fans out
// drop-on-full so a slow editor never stalls the engine).
type DebugObserver interface {
Observe(DebugEvent)
}
// debugObsBox wraps a DebugObserver so atomic.Value always sees one type.
type debugObsBox struct{ o DebugObserver }
// SetDebugObserver installs the sink for node-execution events. Safe to call
// once at startup; read lock-free by running flows.
func (e *Engine) SetDebugObserver(o DebugObserver) {
e.debugObs.Store(debugObsBox{o: o})
}
// SetDebugWatch replaces the set of graph ids with at least one live debug
// subscriber. emitDebug short-circuits for graphs absent from this set, so the
// common (nobody watching) case costs a single atomic load. The hub owns the
// map and must not mutate it after publishing (it is read without a lock).
func (e *Engine) SetDebugWatch(ids map[string]bool) {
e.debugWatch.Store(ids)
}
// registerFire records a compiled graph's manual-fire channel under its route id
// (live graph id or simulate sandbox id).
func (e *Engine) registerFire(id string, ch chan string) {
e.fireMu.Lock()
e.fireChs[id] = ch
e.fireMu.Unlock()
}
// unregisterFire removes id's fire channel, but only if it still points at ch —
// so a newer generation that reused the same id (live reload) is not clobbered
// by the old generation's teardown.
func (e *Engine) unregisterFire(id string, ch chan string) {
e.fireMu.Lock()
if e.fireChs[id] == ch {
delete(e.fireChs, id)
}
e.fireMu.Unlock()
}
// FireTrigger asks the graph behind a debug route (graphID) to run triggerID's
// flow now, as if the trigger had fired. Non-blocking and best-effort: returns
// false if the route is gone or its fire buffer is full. The receiving graph
// validates that triggerID is actually one of its trigger nodes.
func (e *Engine) FireTrigger(graphID, triggerID string) bool {
e.fireMu.Lock()
ch := e.fireChs[graphID]
e.fireMu.Unlock()
if ch == nil || triggerID == "" {
return false
}
select {
case ch <- triggerID:
return true
default:
return false
}
}
// emitDebug reports a node execution to the observer when the graph is watched
// (or the graph is a simulate sandbox, which is always its own subscriber).
func (cg *compiledGraph) emitDebug(nodeID string, value float64, hasValue bool) {
if !cg.alwaysDebug {
w, _ := cg.engine.debugWatch.Load().(map[string]bool)
if !w[cg.id] {
return
}
}
box, _ := cg.engine.debugObs.Load().(debugObsBox)
if box.o == nil {
return
}
box.o.Observe(DebugEvent{
GraphID: cg.id,
NodeID: nodeID,
Value: value,
HasValue: hasValue,
TS: time.Now().UnixMilli(),
})
}
// StartSimulate runs g in a throwaway sandbox generation: real side effects are
// suppressed (data-source writes, config mutations, dialogs) but local vars,
// triggers, timers and the flow itself execute normally, emitting debug events
// the editor can visualise. It is independent of Reload's live generation. The
// returned stop func cancels the sandbox and waits for its goroutines to drain;
// it is safe to call more than once.
func (e *Engine) StartSimulate(g Graph) func() {
cg := compile(g)
cg.engine = e
cg.dryRun = true
cg.alwaysDebug = true
ctx, cancel := context.WithCancel(e.root)
wg := &sync.WaitGroup{}
cg.genCtx = ctx
cg.wg = wg
// Sandbox-local live cache so simulate reads don't disturb the live engine.
updates := make(chan broker.Update, 128)
var unsubs []func()
for _, r := range cg.refs {
unsub, err := e.broker.Subscribe(broker.SignalRef{DS: r.DS, Name: r.Name}, updates)
if err != nil {
e.log.Warn("control logic simulate: subscribe failed", "ds", r.DS, "signal", r.Name, "err", err)
continue
}
unsubs = append(unsubs, unsub)
}
e.registerFire(cg.id, cg.fireCh)
wg.Add(1)
go func() {
defer wg.Done()
<-ctx.Done()
for _, u := range unsubs {
u()
}
e.unregisterFire(cg.id, cg.fireCh)
}()
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case u := <-updates:
val := toNum(u.Value.Data)
key := refKey(u.Ref.DS, u.Ref.Name)
e.liveMu.Lock()
e.live[key] = val
e.liveMu.Unlock()
cg.onSignal(key, val)
case <-ctx.Done():
return
}
}
}()
cg.startTriggers()
var once sync.Once
return func() {
once.Do(func() {
cancel()
wg.Wait()
})
}
}
+196
View File
@@ -0,0 +1,196 @@
package controllogic
import (
"context"
"sync"
"testing"
"time"
)
// fakeObserver records every DebugEvent it receives, in order.
type fakeObserver struct {
mu sync.Mutex
events []DebugEvent
}
func (f *fakeObserver) Observe(ev DebugEvent) {
f.mu.Lock()
f.events = append(f.events, ev)
f.mu.Unlock()
}
func (f *fakeObserver) snapshot() []DebugEvent {
f.mu.Lock()
defer f.mu.Unlock()
return append([]DebugEvent(nil), f.events...)
}
func (f *fakeObserver) last(nodeID string) (DebugEvent, bool) {
f.mu.Lock()
defer f.mu.Unlock()
for i := len(f.events) - 1; i >= 0; i-- {
if f.events[i].NodeID == nodeID {
return f.events[i], true
}
}
return DebugEvent{}, false
}
// thresholdWriteGraph is a 2-node flow: a timer trigger → an action.write of 42
// to "tgt:OUT". Used by both the observe and simulate tests.
func thresholdWriteGraph(id string) Graph {
return Graph{
ID: id,
Name: "flow",
Nodes: []Node{
{ID: "t", Kind: "trigger.timer", Params: map[string]string{"interval": "100"}},
{ID: "w", Kind: "action.write", Params: map[string]string{"target": "tgt:OUT", "expr": "42"}},
},
Wires: []Wire{{From: "t", To: "w"}},
}
}
// TestDebugObserverCapturesNodeValues drives a flow directly and asserts the
// observer saw the trigger fire and the write node's value — and that the real
// data-source write still happened (live mode, not dry-run).
func TestDebugObserverCapturesNodeValues(t *testing.T) {
e, src, _ := setupConfigEngine(t)
obs := &fakeObserver{}
e.SetDebugObserver(obs)
e.SetDebugWatch(map[string]bool{"g1": true})
cg := compile(thresholdWriteGraph("g1"))
cg.engine = e
cg.genCtx = context.Background()
cg.wg = &sync.WaitGroup{}
cg.activate("t")
cg.wg.Wait()
events := obs.snapshot()
if len(events) == 0 {
t.Fatal("observer captured no events")
}
// Trigger then write must both be reported.
if _, ok := obs.last("t"); !ok {
t.Error("no event for trigger node 't'")
}
wEv, ok := obs.last("w")
if !ok {
t.Fatal("no event for write node 'w'")
}
if !wEv.HasValue || wEv.Value != 42 {
t.Errorf("write node event = %+v, want value 42 hasValue true", wEv)
}
if wEv.GraphID != "g1" {
t.Errorf("event GraphID = %q, want g1", wEv.GraphID)
}
// Live mode: the real write went through.
if got, ok := src.get("OUT"); !ok || toNum(got) != 42 {
t.Errorf("OUT = %v (ok=%v), want 42 written", got, ok)
}
}
// manualWriteGraph is a flow whose trigger never fires on its own (a threshold
// with no signal wired) → an action.write of 42 to "tgt:OUT". Used to prove
// FireTrigger forces a run that would otherwise never happen.
func manualWriteGraph(id string) Graph {
return Graph{
ID: id,
Name: "flow",
Nodes: []Node{
{ID: "t", Kind: "trigger.threshold", Params: map[string]string{"op": ">", "value": "0"}},
{ID: "w", Kind: "action.write", Params: map[string]string{"target": "tgt:OUT", "expr": "42"}},
},
Wires: []Wire{{From: "t", To: "w"}},
}
}
// TestFireTriggerForcesRun verifies FireTrigger drives a flow whose trigger
// never fires on its own, routed through the simulate sandbox.
func TestFireTriggerForcesRun(t *testing.T) {
e, _, _ := setupConfigEngine(t)
obs := &fakeObserver{}
e.SetDebugObserver(obs)
e.SetDebugWatch(map[string]bool{})
stop := e.StartSimulate(manualWriteGraph("sim"))
defer stop()
// Nothing should have run yet — the threshold trigger has no signal.
time.Sleep(50 * time.Millisecond)
if _, ok := obs.last("w"); ok {
t.Fatal("write node ran before any manual fire")
}
if !e.FireTrigger("sim", "t") {
t.Fatal("FireTrigger returned false for an active simulate route")
}
// Poll for the write node event (the flow runs on its own goroutine).
var wEv DebugEvent
for i := 0; i < 100; i++ {
if ev, ok := obs.last("w"); ok {
wEv = ev
break
}
time.Sleep(10 * time.Millisecond)
}
if !wEv.HasValue || wEv.Value != 42 {
t.Errorf("write node event = %+v, want value 42 hasValue true", wEv)
}
// An unknown route id must be a no-op (best-effort, returns false).
if e.FireTrigger("does-not-exist", "t") {
t.Error("FireTrigger returned true for an unknown route")
}
}
// TestDebugWatchGatesEmission verifies emitDebug is silent for graphs absent
// from the watch set, so unwatched live graphs cost nothing.
func TestDebugWatchGatesEmission(t *testing.T) {
e, _, _ := setupConfigEngine(t)
obs := &fakeObserver{}
e.SetDebugObserver(obs)
e.SetDebugWatch(map[string]bool{}) // nobody watching
cg := compile(thresholdWriteGraph("g1"))
cg.engine = e
cg.genCtx = context.Background()
cg.wg = &sync.WaitGroup{}
cg.activate("t")
cg.wg.Wait()
if got := obs.snapshot(); len(got) != 0 {
t.Errorf("observer received %d events for an unwatched graph, want 0", len(got))
}
}
// TestSimulateSuppressesWrites runs the graph through the dry-run sandbox and
// asserts node events are still emitted (alwaysDebug) but NO real write occurs.
func TestSimulateSuppressesWrites(t *testing.T) {
e, src, _ := setupConfigEngine(t)
obs := &fakeObserver{}
e.SetDebugObserver(obs)
// Deliberately leave the watch set empty: simulate must emit regardless.
e.SetDebugWatch(map[string]bool{})
stop := e.StartSimulate(thresholdWriteGraph("sim"))
time.Sleep(250 * time.Millisecond) // let the 100 ms timer fire at least once
stop()
if _, ok := src.get("OUT"); ok {
t.Errorf("simulate performed a real write to OUT, want none")
}
wEv, ok := obs.last("w")
if !ok {
t.Fatal("simulate produced no event for write node 'w'")
}
if !wEv.HasValue || wEv.Value != 42 {
t.Errorf("simulate write event = %+v, want value 42 hasValue true", wEv)
}
if wEv.GraphID != "sim" {
t.Errorf("simulate event GraphID = %q, want sim", wEv.GraphID)
}
}
+433 -14
View File
@@ -2,17 +2,38 @@ package controllogic
import (
"context"
"errors"
"fmt"
"log/slog"
"math"
"regexp"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/uopi/uopi/internal/audit"
"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).
const (
maxSteps = 100000
@@ -25,26 +46,66 @@ const (
type Engine struct {
broker *broker.Broker
store *Store
cfg *confmgr.Store
audit audit.Recorder
log *slog.Logger
root context.Context
mu sync.Mutex
cancel context.CancelFunc // cancels the current generation
wg *sync.WaitGroup // tracks the current generation's goroutines
mu sync.Mutex
cancel context.CancelFunc // cancels the current generation
wg *sync.WaitGroup // tracks the current generation's goroutines
// notifier delivers action.dialog requests to connected clients. Stored in
// an atomic so flow goroutines can read it without taking e.mu (which Reload
// holds while it waits for those same goroutines to drain).
notifier atomic.Value // notifierBox
// debugObs receives per-node execution events for the live debug view, and
// debugWatch is the set of graph ids with at least one live subscriber. Both
// are read lock-free by flow goroutines (same trick as notifier); emitDebug
// short-circuits cheaply when the firing graph has no watcher.
debugObs atomic.Value // debugObsBox
debugWatch atomic.Value // map[string]bool (immutable snapshot)
// fireChs maps a debug route id (live graph id or simulate sandbox id) to its
// compiled graph's manual-fire channel, so the debug UI can force a trigger to
// run. Entries are added/removed as generations (and simulate sandboxes) come
// and go; FireTrigger does a non-blocking send so a torn-down graph drops.
fireMu sync.Mutex
fireChs map[string]chan string
// Shared live signal cache for the current generation (key "ds\0name").
liveMu sync.RWMutex
live map[string]float64
}
// NewEngine creates an engine bound to root. Call Reload to start it.
func NewEngine(root context.Context, brk *broker.Broker, store *Store, log *slog.Logger) *Engine {
// notifierBox wraps a Notifier so atomic.Value always sees one concrete type.
type notifierBox struct{ n Notifier }
// dialogSeq generates unique action.dialog ids across all graphs.
var dialogSeq uint64
// SetNotifier installs the sink for action.dialog requests. Safe to call once
// at startup before or after Reload; it is read lock-free by running flows.
func (e *Engine) SetNotifier(n Notifier) {
e.notifier.Store(notifierBox{n: n})
}
// NewEngine creates an engine bound to root. Call Reload to start it. rec records
// the writes performed by flows; pass audit.Nop() to disable auditing.
func NewEngine(root context.Context, brk *broker.Broker, store *Store, cfg *confmgr.Store, rec audit.Recorder, log *slog.Logger) *Engine {
if rec == nil {
rec = audit.Nop()
}
return &Engine{
broker: brk,
store: store,
log: log,
root: root,
live: map[string]float64{},
cfg: cfg,
audit: rec,
log: log,
root: root,
live: map[string]float64{},
fireChs: map[string]chan string{},
}
}
@@ -141,6 +202,7 @@ func (e *Engine) Reload() {
cg.engine = e
cg.genCtx = genCtx
cg.wg = wg
e.registerFire(cg.id, cg.fireCh)
}
// One shared updates channel feeds a single dispatch goroutine; every
@@ -163,6 +225,9 @@ func (e *Engine) Reload() {
for _, u := range unsubs {
u()
}
for _, cg := range compiled {
e.unregisterFire(cg.id, cg.fireCh)
}
}()
// Dispatch goroutine: keep the live cache fresh and drive level/edge triggers.
@@ -222,14 +287,284 @@ func (e *Engine) write(cg *compiledGraph, target string, val float64) {
cg.setLocal(name, val)
return
}
if cg.dryRun {
return // simulate: no real data-source write
}
src, ok := e.broker.Source(ds)
if !ok {
e.log.Warn("control logic: write to unknown data source", "ds", ds, "signal", name)
return
}
ev := audit.Event{
Actor: cg.name,
ActorType: audit.ActorSystem,
Action: "signal.write",
DS: ds,
Signal: name,
Value: strconv.FormatFloat(val, 'g', -1, 64),
Detail: "control logic: " + cg.name,
Outcome: audit.OutcomeOK,
}
if err := src.Write(e.root, name, val); err != nil {
e.log.Warn("control logic: write failed", "ds", ds, "signal", name, "err", err)
ev.Outcome = audit.OutcomeError
ev.Error = err.Error()
}
e.audit.Record(ev)
}
// 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 ─────────────────────────────────────────────────────────────
@@ -245,15 +580,28 @@ type compiledGraph struct {
genCtx context.Context
wg *sync.WaitGroup
name string
byId map[string]Node
out map[string][]wireOut
inc map[string][]string // incoming source ids per node (for gates)
refs map[string]RefLite // unique signals to subscribe (excl. sys/local)
// dryRun suppresses real side effects (data-source writes, config mutations,
// dialogs) — used by the debug "simulate" sandbox. Local-variable writes stay
// live so the flow still computes correctly. alwaysDebug forces emitDebug to
// fire regardless of debugWatch (the sandbox is its own subscriber).
dryRun bool
alwaysDebug bool
id string
name string
byId map[string]Node
out map[string][]wireOut
inc map[string][]string // incoming source ids per node (for gates)
refs map[string]RefLite // unique signals to subscribe (excl. sys/local)
watchers map[string][]string // signal key → trigger node ids
luaNodes map[string]*luaRuntime
// fireCh receives node ids of triggers the debug UI wants to fire manually.
// Drained by a generation goroutine (started in startTriggers) so the wg.Add
// in activate always happens on a tracked goroutine.
fireCh chan string
stateMu sync.Mutex
levelState map[string]bool // current truth of level triggers (threshold/alarm)
prevBool map[string]bool // edge detection for threshold/alarm
@@ -265,6 +613,7 @@ type compiledGraph struct {
func compile(g Graph) *compiledGraph {
cg := &compiledGraph{
id: g.ID,
name: g.Name,
byId: map[string]Node{},
out: map[string][]wireOut{},
@@ -278,6 +627,7 @@ func compile(g Graph) *compiledGraph {
hasVal: map[string]bool{},
lastFire: map[string]int64{},
locals: map[string]float64{},
fireCh: make(chan string, 16),
}
for _, n := range g.Nodes {
cg.byId[n.ID] = n
@@ -350,6 +700,24 @@ func (cg *compiledGraph) getLocal(name string) float64 {
// startTriggers launches timer and cron trigger goroutines for the generation.
func (cg *compiledGraph) startTriggers() {
// Manual-fire listener: drain fireCh on a tracked goroutine so activate's
// wg.Add never races the generation teardown's wg.Wait. Only fires nodes that
// are actual triggers in this graph.
cg.wg.Add(1)
go func() {
defer cg.wg.Done()
for {
select {
case id := <-cg.fireCh:
if n, ok := cg.byId[id]; ok && strings.HasPrefix(n.Kind, "trigger.") {
cg.activate(id)
}
case <-cg.genCtx.Done():
return
}
}
}()
hasCron := false
for _, n := range cg.byId {
switch n.Kind {
@@ -543,6 +911,8 @@ func (cg *compiledGraph) activate(triggerID string) {
}
}
cg.emitDebug(triggerID, 0, false)
cg.wg.Add(1)
go func() {
defer cg.wg.Done()
@@ -575,6 +945,8 @@ func (cg *compiledGraph) run(nodeID string, ctx *runCtx) {
default:
}
cg.emitDebug(node.ID, 0, false)
switch node.Kind {
case "gate.and":
if cg.gateSatisfied(node.ID, ctx.fired) {
@@ -583,9 +955,15 @@ func (cg *compiledGraph) run(nodeID string, ctx *runCtx) {
case "flow.if":
branch := "else"
if EvalBool(node.param("cond"), ctx.resolve) {
pass := EvalBool(node.param("cond"), ctx.resolve)
if pass {
branch = "then"
}
v := 0.0
if pass {
v = 1
}
cg.emitDebug(node.ID, v, true)
cg.follow(node.ID, branch, ctx)
case "flow.loop":
@@ -609,9 +987,43 @@ func (cg *compiledGraph) run(nodeID string, ctx *runCtx) {
case "action.write":
val := EvalExpr(node.param("expr"), ctx.resolve)
cg.emitDebug(node.ID, val, true)
cg.engine.write(cg, node.param("target"), val)
cg.follow(node.ID, "out", ctx)
case "action.config.apply":
if !cg.dryRun {
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.emitDebug(node.ID, val, true)
if !cg.dryRun {
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":
if !cg.dryRun {
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":
if !cg.dryRun {
cg.engine.snapshotConfig(cg, strings.TrimSpace(node.param("set")), strings.TrimSpace(node.param("name")))
}
cg.follow(node.ID, "out", ctx)
case "action.delay":
ms := 0
if v, err := strconv.Atoi(strings.TrimSpace(node.param("ms"))); err == nil && v > 0 {
@@ -630,6 +1042,7 @@ func (cg *compiledGraph) run(nodeID string, ctx *runCtx) {
case "action.log":
val := EvalExpr(node.param("expr"), ctx.resolve)
cg.emitDebug(node.ID, val, true)
label := strings.TrimSpace(node.param("label"))
cg.engine.log.Info("control logic log", "graph", cg.name, "label", label, "value", val)
cg.follow(node.ID, "out", ctx)
@@ -638,6 +1051,12 @@ func (cg *compiledGraph) run(nodeID string, ctx *runCtx) {
cg.runLua(node.ID, ctx)
cg.follow(node.ID, "out", ctx)
case "action.dialog":
if !cg.dryRun {
cg.engine.emitDialog(node)
}
cg.follow(node.ID, "out", ctx)
default:
cg.follow(node.ID, "out", ctx)
}
+19 -5
View File
@@ -29,6 +29,8 @@ package controllogic
// action.delay — waits `ms` before continuing.
// action.log — logs an expression value to the server log.
// action.lua — runs a sandboxed Lua script with get/set/log host funcs.
// action.dialog — pushes an info/error/input dialog to connected clients
// filtered by user/group; input responses write `target`.
// Node is a single node in a control-logic graph. Params are stored as strings
// (matching the panel logic model) and parsed per-kind by the engine.
@@ -48,13 +50,25 @@ type Wire struct {
To string `json:"to"`
}
// NodeGroup is a cosmetic, editor-side grouping of nodes. The engine ignores it;
// it is stored and round-tripped so the editor keeps its visual organisation.
type NodeGroup struct {
ID string `json:"id"`
Label string `json:"label,omitempty"`
Members []string `json:"members"`
Collapsed bool `json:"collapsed,omitempty"`
}
// Graph is a named, independently-enableable control-logic flow.
type Graph struct {
ID string `json:"id"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
Nodes []Node `json:"nodes"`
Wires []Wire `json:"wires"`
ID string `json:"id"`
Name string `json:"name"`
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"`
Wires []Wire `json:"wires"`
Groups []NodeGroup `json:"groups,omitempty"`
}
func (n Node) param(key string) string {
+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
}
+68 -8
View File
@@ -7,6 +7,7 @@ import (
"os"
"path/filepath"
"sync"
"time"
)
const definitionsFile = "controllogic.json"
@@ -16,17 +17,25 @@ var ErrNotFound = errors.New("control logic graph not found")
// Store persists control-logic graphs as a single JSON file in the storage dir.
// Writes are atomic (tmp file + rename); all access is mutex-guarded.
//
// 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 {
mu sync.RWMutex
path string
items map[string]Graph
mu sync.RWMutex
path string
trashDir string
versionsDir string
items map[string]Graph
}
// NewStore opens (or initialises) the control-logic store under storageDir.
func NewStore(storageDir string) (*Store, error) {
s := &Store{
path: filepath.Join(storageDir, definitionsFile),
items: map[string]Graph{},
path: filepath.Join(storageDir, definitionsFile),
trashDir: filepath.Join(storageDir, "trash", "controllogic"),
versionsDir: filepath.Join(storageDir, "controllogic_versions"),
items: map[string]Graph{},
}
if err := s.load(); err != nil {
return nil, err
@@ -91,21 +100,72 @@ func (s *Store) Get(id string) (Graph, error) {
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 {
s.mu.Lock()
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
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 {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.items[id]; !ok {
g, ok := s.items[id]
if !ok {
return ErrNotFound
}
if err := s.trash(g); err != nil {
return fmt.Errorf("move control logic to trash: %w", err)
}
delete(s.items, id)
return s.saveLocked()
}
// trash writes a single graph as a timestamped JSON file under the trash folder.
func (s *Store) trash(g Graph) error {
if err := os.MkdirAll(s.trashDir, 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(g, "", " ")
if err != nil {
return err
}
dst := filepath.Join(s.trashDir, fmt.Sprintf("%s.%d.json", g.ID, time.Now().UnixMilli()))
return os.WriteFile(dst, data, 0o644)
}
+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,
Data: data,
Quality: q,
Severity: p.Severity,
Status: p.Status,
})
}
+9 -2
View File
@@ -22,7 +22,7 @@ import (
//
//export goCAMonitorCallback
func goCAMonitorCallback(handle C.uintptr_t, dbrType C.int, count C.long,
dbr unsafe.Pointer, severity C.int, epicsTimeSecs C.double) {
dbr unsafe.Pointer, severity C.int, status C.int, epicsTimeSecs C.double) {
h := uintptr(handle)
@@ -79,7 +79,13 @@ func goCAMonitorCallback(handle C.uintptr_t, dbrType C.int, count C.long,
data = float64(0)
}
v := datasource.Value{Timestamp: ts, Data: data, Quality: q}
v := datasource.Value{
Timestamp: ts,
Data: data,
Quality: q,
Severity: int(severity),
Status: int(status),
}
// Look up the subscription channel under the global handle table lock and
// perform a non-blocking send so we never stall the CA callback thread.
@@ -129,6 +135,7 @@ func goCAConnectionCallback(handle C.uintptr_t, connected C.int) {
Timestamp: time.Now(),
Data: float64(0),
Quality: datasource.QualityBad,
Severity: 3, // INVALID
}
select {
case ch <- v:
+10 -3
View File
@@ -13,7 +13,7 @@
* goCAConnectionCallback is called when a channel connects or disconnects.
*/
extern void goCAMonitorCallback(uintptr_t handle, int dbrType, long count,
void *dbr, int severity,
void *dbr, int severity, int status,
double epicsTimeSecs);
extern void goCAConnectionCallback(uintptr_t handle, int connected);
@@ -30,9 +30,10 @@ static void caMonitorCallbackShim(struct event_handler_args args) {
double timeSecs = 0.0;
int severity = 0;
int status = 0;
/*
* Determine the timestamp and alarm severity from the DBR type.
* Determine the timestamp and alarm severity/status from the DBR type.
* We request DBR_TIME_* types so the timestamp is embedded in the value
* buffer right after the alarm fields (struct dbr_time_double et al.).
*/
@@ -41,36 +42,42 @@ static void caMonitorCallbackShim(struct event_handler_args args) {
const struct dbr_time_double *p = (const struct dbr_time_double *)args.dbr;
timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9;
severity = (int)p->severity;
status = (int)p->status;
break;
}
case DBR_TIME_FLOAT: {
const struct dbr_time_float *p = (const struct dbr_time_float *)args.dbr;
timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9;
severity = (int)p->severity;
status = (int)p->status;
break;
}
case DBR_TIME_LONG: {
const struct dbr_time_long *p = (const struct dbr_time_long *)args.dbr;
timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9;
severity = (int)p->severity;
status = (int)p->status;
break;
}
case DBR_TIME_SHORT: {
const struct dbr_time_short *p = (const struct dbr_time_short *)args.dbr;
timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9;
severity = (int)p->severity;
status = (int)p->status;
break;
}
case DBR_TIME_STRING: {
const struct dbr_time_string *p = (const struct dbr_time_string *)args.dbr;
timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9;
severity = (int)p->severity;
status = (int)p->status;
break;
}
case DBR_TIME_ENUM: {
const struct dbr_time_enum *p = (const struct dbr_time_enum *)args.dbr;
timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9;
severity = (int)p->severity;
status = (int)p->status;
break;
}
default:
@@ -78,7 +85,7 @@ static void caMonitorCallbackShim(struct event_handler_args args) {
}
goCAMonitorCallback((uintptr_t)args.usr, (int)args.type, (long)args.count,
(void *)args.dbr, severity, timeSecs);
(void *)args.dbr, severity, status, timeSecs);
}
/*
+2
View File
@@ -275,6 +275,8 @@ func (e *EPICS) timeValueToDS(signal string, tv proto.TimeValue) datasource.Valu
Timestamp: tv.Timestamp,
Data: data,
Quality: quality,
Severity: int(tv.Severity),
Status: int(tv.Status),
}
}
+2
View File
@@ -45,6 +45,8 @@ type Value struct {
Timestamp time.Time
Data any // float64 | []float64 | string | int64 | bool
Quality Quality
Severity int // raw EPICS alarm severity (0=NO_ALARM,1=MINOR,2=MAJOR,3=INVALID); 0 for sources without alarm info
Status int // raw EPICS alarm status (.STAT); 0 for sources without alarm info
MetaUpdate bool // if true, metadata was refreshed — dispatcher should re-send meta
}
+23
View File
@@ -180,6 +180,7 @@ func structToValue(sv pvdata.StructValue) datasource.Value {
val := fieldByName(sv, "value")
ts := extractTimestamp(sv)
quality := extractQuality(sv)
severity, status := extractSeverityStatus(sv)
var data any
if val != nil {
@@ -190,6 +191,8 @@ func structToValue(sv pvdata.StructValue) datasource.Value {
Timestamp: ts,
Data: data,
Quality: quality,
Severity: severity,
Status: status,
}
}
@@ -329,6 +332,26 @@ func extractQuality(sv pvdata.StructValue) datasource.Quality {
}
}
// extractSeverityStatus pulls the raw EPICS alarm severity and status from the
// NTScalar "alarm" struct. Returns (0, 0) when no alarm struct is present.
func extractSeverityStatus(sv pvdata.StructValue) (severity, status int) {
alarm := structByName(sv, "alarm")
if alarm == nil {
return 0, 0
}
if v := fieldByName(*alarm, "severity"); v != nil {
if s, ok := v.(int32); ok {
severity = int(s)
}
}
if v := fieldByName(*alarm, "status"); v != nil {
if s, ok := v.(int32); ok {
status = int(s)
}
}
return severity, status
}
func toFloat64(v any) (float64, bool) {
switch x := v.(type) {
case float64:
+212
View File
@@ -0,0 +1,212 @@
// Package servervar provides a small persistent key/value data source for
// "server variables": named scalar values that the server-side control-logic
// engine writes (e.g. the state of a sequence) and that interface panels can
// read live. Panels may read any variable; writes from panels are gated to
// control-logic editors in the WebSocket write handler.
package servervar
import (
"context"
"encoding/json"
"os"
"path/filepath"
"sort"
"sync"
"time"
"github.com/uopi/uopi/internal/datasource"
)
const fileName = "servervars.json"
type variable struct {
value float64
ts time.Time
}
// Source is the "srv" data source. Variables are created on first write and
// persisted so their last value survives a restart.
type Source struct {
path string
mu sync.RWMutex
vars map[string]*variable
subs map[string]map[int]chan<- datasource.Value
nextID int
}
// New opens (or initialises) the server-variable store under storageDir.
func New(storageDir string) (*Source, error) {
s := &Source{
path: filepath.Join(storageDir, fileName),
vars: map[string]*variable{},
subs: map[string]map[int]chan<- datasource.Value{},
}
if err := s.load(); err != nil {
return nil, err
}
return s, nil
}
func (s *Source) load() error {
data, err := os.ReadFile(s.path)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return err
}
var raw map[string]float64
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
now := time.Now()
for name, v := range raw {
s.vars[name] = &variable{value: v, ts: now}
}
return nil
}
// saveLocked persists the current values atomically. Caller holds s.mu.
func (s *Source) saveLocked() {
raw := make(map[string]float64, len(s.vars))
for name, v := range s.vars {
raw[name] = v.value
}
data, err := json.MarshalIndent(raw, "", " ")
if err != nil {
return
}
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, data, 0o644); err != nil {
return
}
_ = os.Rename(tmp, s.path)
}
func meta(name string) datasource.Metadata {
return datasource.Metadata{
Name: name,
Type: datasource.TypeFloat64,
Description: "Server variable",
Writable: true,
}
}
// Name implements datasource.DataSource.
func (s *Source) Name() string { return "srv" }
// Connect is a no-op — the store is opened in New.
func (s *Source) Connect(_ context.Context) error { return nil }
// ListSignals returns metadata for every defined server variable.
func (s *Source) ListSignals(_ context.Context) ([]datasource.Metadata, error) {
s.mu.RLock()
defer s.mu.RUnlock()
names := make([]string, 0, len(s.vars))
for name := range s.vars {
names = append(names, name)
}
sort.Strings(names)
out := make([]datasource.Metadata, 0, len(names))
for _, name := range names {
out = append(out, meta(name))
}
return out, nil
}
// GetMetadata returns metadata for a single variable. Unknown names still report
// writable metadata so that control logic / authorised panels may create them.
func (s *Source) GetMetadata(_ context.Context, signal string) (datasource.Metadata, error) {
return meta(signal), nil
}
// Subscribe registers ch for updates and immediately delivers the current value.
func (s *Source) Subscribe(ctx context.Context, signal string, ch chan<- datasource.Value) (datasource.CancelFunc, error) {
s.mu.Lock()
if s.subs[signal] == nil {
s.subs[signal] = map[int]chan<- datasource.Value{}
}
id := s.nextID
s.nextID++
s.subs[signal][id] = ch
cur, ok := s.vars[signal]
var first datasource.Value
if ok {
first = datasource.Value{Timestamp: cur.ts, Data: cur.value, Quality: datasource.QualityGood}
}
s.mu.Unlock()
if ok {
go func() {
select {
case ch <- first:
case <-ctx.Done():
}
}()
}
return func() {
s.mu.Lock()
if m := s.subs[signal]; m != nil {
delete(m, id)
if len(m) == 0 {
delete(s.subs, signal)
}
}
s.mu.Unlock()
}, nil
}
func toFloat64(v any) float64 {
switch x := v.(type) {
case float64:
return x
case float32:
return float64(x)
case int:
return float64(x)
case int64:
return float64(x)
case bool:
if x {
return 1
}
return 0
case json.Number:
f, _ := x.Float64()
return f
default:
return 0
}
}
// Write sets a variable (creating it if needed), persists, and fans the new value
// out to all subscribers.
func (s *Source) Write(_ context.Context, signal string, value any) error {
v := toFloat64(value)
s.mu.Lock()
now := time.Now()
s.vars[signal] = &variable{value: v, ts: now}
s.saveLocked()
subs := make([]chan<- datasource.Value, 0, len(s.subs[signal]))
for _, ch := range s.subs[signal] {
subs = append(subs, ch)
}
s.mu.Unlock()
upd := datasource.Value{Timestamp: now, Data: v, Quality: datasource.QualityGood}
for _, ch := range subs {
select {
case ch <- upd:
default:
}
}
return nil
}
// History is not supported.
func (s *Source) History(_ context.Context, _ string, _, _ time.Time, _ int) ([]datasource.Value, error) {
return nil, datasource.ErrHistoryUnavailable
}
@@ -0,0 +1,74 @@
package servervar
import (
"context"
"testing"
"time"
"github.com/uopi/uopi/internal/datasource"
)
func TestWriteSubscribePersist(t *testing.T) {
dir := t.TempDir()
s, err := New(dir)
if err != nil {
t.Fatalf("New: %v", err)
}
ctx := context.Background()
if err := s.Write(ctx, "seq_state", 3.0); err != nil {
t.Fatalf("Write: %v", err)
}
// Subscribe should deliver the current value immediately.
ch := make(chan datasource.Value, 4)
cancel, err := s.Subscribe(ctx, "seq_state", ch)
if err != nil {
t.Fatalf("Subscribe: %v", err)
}
defer cancel()
select {
case v := <-ch:
if got := v.Data.(float64); got != 3.0 {
t.Fatalf("initial value = %v, want 3", got)
}
case <-time.After(time.Second):
t.Fatal("no initial value delivered")
}
// A later write fans out to the subscriber.
if err := s.Write(ctx, "seq_state", 7.0); err != nil {
t.Fatalf("Write 2: %v", err)
}
select {
case v := <-ch:
if got := v.Data.(float64); got != 7.0 {
t.Fatalf("updated value = %v, want 7", got)
}
case <-time.After(time.Second):
t.Fatal("no update delivered")
}
// ListSignals reports the variable.
sigs, err := s.ListSignals(ctx)
if err != nil || len(sigs) != 1 || sigs[0].Name != "seq_state" {
t.Fatalf("ListSignals = %+v, err %v", sigs, err)
}
// A fresh store over the same dir recovers the last value.
s2, err := New(dir)
if err != nil {
t.Fatalf("reopen: %v", err)
}
ch2 := make(chan datasource.Value, 1)
cancel2, _ := s2.Subscribe(ctx, "seq_state", ch2)
defer cancel2()
select {
case v := <-ch2:
if got := v.Data.(float64); got != 7.0 {
t.Fatalf("persisted value = %v, want 7", got)
}
case <-time.After(time.Second):
t.Fatal("persisted value not delivered")
}
}
@@ -0,0 +1,222 @@
package synthetic
import (
"context"
"log/slog"
"math"
"os"
"testing"
"time"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/datasource"
"github.com/uopi/uopi/internal/dsp"
)
// evalSampleDef compiles a SignalDef and evaluates it against per-source
// Samples keyed by source node id, returning the output Sample.
func evalSampleDef(t *testing.T, def SignalDef, srcVals map[string]dsp.Sample) dsp.Sample {
t.Helper()
rg, err := compileGraph(def)
if err != nil {
t.Fatalf("compileGraph: %v", err)
}
out, err := rg.evalSample(srcVals)
if err != nil {
t.Fatalf("evalSample: %v", err)
}
return out
}
// TestArrayElementwiseChain runs an array source through an elementwise op
// (gain) and asserts the output stays an array, broadcast per element.
func TestArrayElementwiseChain(t *testing.T) {
def := SignalDef{
Name: "scaled",
Graph: &Graph{
Output: "out",
Nodes: []GraphNode{
{ID: "a", Kind: "source", DS: "x", Signal: "wave"},
{ID: "g", Kind: "op", Op: "gain", Inputs: []string{"a"}, Params: map[string]any{"gain": 2.0}},
{ID: "out", Kind: "output", Inputs: []string{"g"}},
},
},
}
out := evalSampleDef(t, def, map[string]dsp.Sample{"a": dsp.Array([]float64{1, 2, 3})})
if !out.IsArray {
t.Fatalf("want array output, got %v", out)
}
want := []float64{2, 4, 6}
for i, v := range want {
if out.Arr[i] != v {
t.Errorf("scaled[%d]: want %v, got %v", i, v, out.Arr[i])
}
}
}
// TestArrayReductionToScalar runs an array source into mean (array→scalar) and
// asserts a scalar output and an array-output compile type for the producer.
func TestArrayReductionToScalar(t *testing.T) {
def := SignalDef{
Name: "avg",
Graph: &Graph{
Output: "out",
Nodes: []GraphNode{
{ID: "a", Kind: "source", DS: "x", Signal: "wave"},
{ID: "m", Kind: "op", Op: "mean", Inputs: []string{"a"}},
{ID: "out", Kind: "output", Inputs: []string{"m"}},
},
},
}
out := evalSampleDef(t, def, map[string]dsp.Sample{"a": dsp.Array([]float64{2, 4, 6, 8})})
if out.IsArray {
t.Fatalf("want scalar output, got array %v", out.Arr)
}
if math.Abs(out.F-5) > 1e-9 {
t.Errorf("mean: want 5, got %v", out.F)
}
}
// TestArrayOutTypeMetadata verifies compileGraph reports an array output type
// for a pure-elementwise array graph and scalar for a reduction graph.
func TestArrayOutTypeMetadata(t *testing.T) {
arrayGraph := SignalDef{
Name: "fftout",
Graph: &Graph{
Output: "out",
Nodes: []GraphNode{
{ID: "a", Kind: "source", DS: "x", Signal: "wave"},
{ID: "f", Kind: "op", Op: "fft", Inputs: []string{"a"}},
{ID: "out", Kind: "output", Inputs: []string{"f"}},
},
},
}
rg, err := compileGraph(arrayGraph)
if err != nil {
t.Fatalf("compileGraph: %v", err)
}
if rg.outType != dsp.ValArray {
t.Errorf("fft graph outType: want ValArray, got %v", rg.outType)
}
reduction := SignalDef{
Name: "sumout",
Graph: &Graph{
Output: "out",
Nodes: []GraphNode{
{ID: "a", Kind: "source", DS: "x", Signal: "wave"},
{ID: "s", Kind: "op", Op: "sum", Inputs: []string{"a"}},
{ID: "out", Kind: "output", Inputs: []string{"s"}},
},
},
}
rg2, err := compileGraph(reduction)
if err != nil {
t.Fatalf("compileGraph: %v", err)
}
if rg2.outType != dsp.ValScalar {
t.Errorf("sum graph outType: want ValScalar, got %v", rg2.outType)
}
}
// TestStatefulRejectsArray verifies a stateful op (moving_average) errors when
// fed an array input at runtime.
func TestStatefulRejectsArray(t *testing.T) {
def := SignalDef{
Name: "ma",
Graph: &Graph{
Output: "out",
Nodes: []GraphNode{
{ID: "a", Kind: "source", DS: "x", Signal: "wave"},
{ID: "m", Kind: "op", Op: "moving_average", Inputs: []string{"a"}, Params: map[string]any{"window": 3.0}},
{ID: "out", Kind: "output", Inputs: []string{"m"}},
},
},
}
rg, err := compileGraph(def)
if err != nil {
t.Fatalf("compileGraph: %v", err)
}
if _, err := rg.evalSample(map[string]dsp.Sample{"a": dsp.Array([]float64{1, 2, 3})}); err == nil {
t.Error("expected moving_average to reject an array input")
}
}
// TestSubscribeArrayPassthrough is an end-to-end check that a synthetic with an
// array-valued source and an elementwise op emits a []float64 over the broker,
// and that GetMetadata reports the waveform type.
func TestSubscribeArrayPassthrough(t *testing.T) {
log := slog.New(slog.NewTextHandler(os.Stderr, nil))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
base := time.Date(2026, 6, 19, 10, 0, 0, 0, time.UTC)
src := &seqSource{name: "src", seq: []datasource.Value{
{Timestamp: base.Add(1 * time.Second), Data: []float64{1, 2, 3}, Quality: datasource.QualityGood},
{Timestamp: base.Add(2 * time.Second), Data: []float64{4, 5, 6}, Quality: datasource.QualityGood},
}}
brk := broker.New(ctx, log)
brk.Register(src)
syn := New(t.TempDir(), brk, log)
if err := syn.Connect(ctx); err != nil {
t.Fatal(err)
}
// gain x2 elementwise keeps the value an array.
if err := syn.AddSignal(SignalDef{
Name: "scaled",
Graph: &Graph{Output: "out", Nodes: []GraphNode{
{ID: "a", Kind: "source", DS: "src", Signal: "x"},
{ID: "g", Kind: "op", Op: "gain", Inputs: []string{"a"}, Params: map[string]any{"gain": 2.0}},
{ID: "out", Kind: "output", Inputs: []string{"g"}},
}},
}); err != nil {
t.Fatal(err)
}
// An fft-based signal has a statically-known array output type (the source's
// runtime type need not be known), so its metadata reports the waveform type.
if err := syn.AddSignal(SignalDef{
Name: "spectrum",
Graph: &Graph{Output: "out", Nodes: []GraphNode{
{ID: "a", Kind: "source", DS: "src", Signal: "x"},
{ID: "f", Kind: "op", Op: "fft", Inputs: []string{"a"}},
{ID: "out", Kind: "output", Inputs: []string{"f"}},
}},
}); err != nil {
t.Fatal(err)
}
meta, err := syn.GetMetadata(ctx, "spectrum")
if err != nil {
t.Fatal(err)
}
if meta.Type != datasource.TypeFloat64Array {
t.Errorf("metadata type: want TypeFloat64Array, got %v", meta.Type)
}
ch := make(chan datasource.Value, 8)
if _, err := syn.Subscribe(ctx, "scaled", ch); err != nil {
t.Fatal(err)
}
want := [][]float64{{2, 4, 6}, {8, 10, 12}}
for i, w := range want {
select {
case v := <-ch:
arr, ok := v.Data.([]float64)
if !ok {
t.Fatalf("emit #%d: want []float64, got %T", i, v.Data)
}
if len(arr) != len(w) {
t.Fatalf("emit #%d: want len %d, got %d", i, len(w), len(arr))
}
for k, val := range w {
if arr[k] != val {
t.Errorf("emit #%d [%d]: want %v, got %v", i, k, val, arr[k])
}
}
case <-time.After(2 * time.Second):
t.Fatalf("timeout waiting for emit #%d", i)
}
}
}
@@ -9,6 +9,7 @@ type SignalDef struct {
Signal string `json:"signal"` // upstream signal name (or "" for constant)
Inputs []InputRef `json:"inputs"` // alternative multi-input format
Pipeline []NodeDef `json:"pipeline"` // ordered list of DSP nodes
Graph *Graph `json:"graph,omitempty"` // DAG form (preferred when present)
Meta MetaOverride `json:"meta"` // optional metadata overrides
// Visibility controls who sees this signal in the signal tree:
@@ -20,6 +21,12 @@ type SignalDef struct {
Visibility string `json:"visibility,omitempty"`
Owner string `json:"owner,omitempty"` // creator identity (stamped server-side)
Panel string `json:"panel,omitempty"` // bound interface id for "panel" visibility
// 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.
@@ -34,6 +41,48 @@ type NodeDef struct {
Params map[string]any `json:"params,omitempty"`
}
// Graph is the DAG form of a synthetic signal: a set of nodes (sources, ops and
// one output) wired together by explicit per-node ordered input lists. It
// supersedes the linear Inputs+Pipeline form: when SignalDef.Graph is set it is
// authoritative; otherwise the legacy linear fields are converted into an
// equivalent graph at load time (see toGraph).
type Graph struct {
Nodes []GraphNode `json:"nodes"`
Output string `json:"output"` // id of the output node
// Groups is cosmetic editor metadata (node grouping/collapsing). It has no
// effect on evaluation; stored and round-tripped so the editor keeps shape.
Groups []NodeGroup `json:"groups,omitempty"`
}
// NodeGroup is a cosmetic, editor-side grouping of graph nodes (no eval effect).
type NodeGroup struct {
ID string `json:"id"`
Label string `json:"label,omitempty"`
Members []string `json:"members"`
Collapsed bool `json:"collapsed,omitempty"`
}
// GraphNode is one node in a Graph.
//
// kind=="source": carries DS+Signal; has no Inputs (a graph root).
// kind=="op": carries Op + Params; Inputs lists upstream node IDs in the
// order the op receives them (input 0, 1, … e.g. ab, a÷b).
// kind=="output": Inputs has a single upstream node whose value is the result.
//
// X/Y are the editor layout coordinates, persisted so a reloaded graph keeps its
// shape; they have no effect on evaluation.
type GraphNode struct {
ID string `json:"id"`
Kind string `json:"kind"`
Op string `json:"op,omitempty"`
Params map[string]any `json:"params,omitempty"`
DS string `json:"ds,omitempty"`
Signal string `json:"signal,omitempty"`
Inputs []string `json:"inputs,omitempty"`
X float64 `json:"x,omitempty"`
Y float64 `json:"y,omitempty"`
}
// MetaOverride allows the synthetic signal to override display metadata.
type MetaOverride struct {
Unit string `json:"unit,omitempty"`
+49 -14
View File
@@ -38,21 +38,32 @@ func stringParam(params map[string]any, key string) string {
return s
}
// BuildPipeline converts a []NodeDef (from JSON) into a []dsp.Node ready for
// execution. JSON numbers are float64, so all numeric params are handled as
// float64 regardless of the final type needed.
func BuildPipeline(defs []NodeDef) ([]dsp.Node, error) {
nodes := make([]dsp.Node, 0, len(defs))
for i, d := range defs {
n, err := buildNode(d)
if err != nil {
return nil, fmt.Errorf("pipeline node %d (%q): %w", i, d.Type, err)
}
nodes = append(nodes, n)
// stringSliceParam extracts a []string from params; JSON arrays decode to []any,
// so each element is coerced via its string value. Returns nil if missing.
func stringSliceParam(params map[string]any, key string) []string {
if params == nil {
return nil
}
return nodes, nil
v, ok := params[key]
if !ok {
return nil
}
arr, ok := v.([]any)
if !ok {
return nil
}
out := make([]string, 0, len(arr))
for _, e := range arr {
if s, ok := e.(string); ok && s != "" {
out = append(out, s)
}
}
return out
}
// buildNode converts a single NodeDef (from JSON) into a dsp.Node ready for
// execution. JSON numbers are float64, so all numeric params are handled as
// float64 regardless of the final type needed.
func buildNode(d NodeDef) (dsp.Node, error) {
p := d.Params
switch d.Type {
@@ -100,7 +111,7 @@ func buildNode(d NodeDef) (dsp.Node, error) {
}, nil
case "expr":
return &dsp.ExprNode{Expr: stringParam(p, "expr")}, nil
return &dsp.ExprNode{Expr: stringParam(p, "expr"), Vars: stringSliceParam(p, "vars")}, nil
case "lowpass":
order := int(floatParam(p, "order"))
@@ -113,7 +124,31 @@ func buildNode(d NodeDef) (dsp.Node, error) {
}, nil
case "lua":
return &dsp.LuaNode{Script: stringParam(p, "script")}, nil
return &dsp.LuaNode{Script: stringParam(p, "script"), Vars: stringSliceParam(p, "vars")}, nil
case "index":
return &dsp.IndexNode{I: int(floatParam(p, "i"))}, nil
case "slice":
return &dsp.SliceNode{Start: int(floatParam(p, "start")), End: int(floatParam(p, "end"))}, nil
case "sum":
return &dsp.SumNode{}, nil
case "mean":
return &dsp.MeanNode{}, nil
case "min":
return &dsp.MinNode{}, nil
case "max":
return &dsp.MaxNode{}, nil
case "length":
return &dsp.LengthNode{}, nil
case "fft":
return &dsp.FFTNode{}, nil
default:
return nil, fmt.Errorf("unknown node type %q", d.Type)
+276
View File
@@ -0,0 +1,276 @@
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, err := rg.evalSampleTrace(sourceVals)
if err != nil {
return dsp.Sample{}, err
}
return vals[rg.outputID], nil
}
// evalSampleTrace runs a full forward pass like evalSample but returns the value
// computed for *every* node (sources, ops and the output), keyed by node id. It
// is used by the editor's live/debug trace to show each node's current value.
// On an op error it returns the values computed so far together with the error,
// so partial results can still be displayed.
func (rg *runtimeGraph) evalSampleTrace(sourceVals map[string]dsp.Sample) (map[string]dsp.Sample, error) {
vals := make(map[string]dsp.Sample, len(rg.order)+len(sourceVals))
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 vals, 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, 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")
}
}
+157 -95
View File
@@ -20,9 +20,8 @@ const definitionsFile = "synthetic.json"
// signalState holds everything needed to run one synthetic signal.
type signalState struct {
def SignalDef
nodes []dsp.Node
states []map[string]any // one map per node, persistent across calls
def SignalDef
rg *runtimeGraph // compiled DAG; op-node state persists across evaluations
// cancel stops the goroutine driving this signal.
cancel context.CancelFunc
@@ -80,7 +79,7 @@ func (s *Synthetic) ListSignals(_ context.Context) ([]datasource.Metadata, error
out := make([]datasource.Metadata, 0, len(s.signals))
for _, st := range s.signals {
out = append(out, defToMetadata(st.def))
out = append(out, defToMetadata(st.def, outTypeOf(st)))
}
return out, nil
}
@@ -95,7 +94,7 @@ func (s *Synthetic) FilteredMetadata(keep func(SignalDef) bool) []datasource.Met
out := make([]datasource.Metadata, 0, len(s.signals))
for _, st := range s.signals {
if keep(st.def) {
out = append(out, defToMetadata(st.def))
out = append(out, defToMetadata(st.def, outTypeOf(st)))
}
}
return out
@@ -110,7 +109,7 @@ func (s *Synthetic) GetMetadata(_ context.Context, signal string) (datasource.Me
if !ok {
return datasource.Metadata{}, datasource.ErrNotFound
}
return defToMetadata(st.def), nil
return defToMetadata(st.def, outTypeOf(st)), nil
}
// Subscribe registers ch to receive computed values for the named signal.
@@ -123,19 +122,25 @@ func (s *Synthetic) Subscribe(ctx context.Context, signal string, ch chan<- data
return nil, datasource.ErrNotFound
}
// Collect the upstream references for this signal.
refs := upstreamRefs(st.def)
// Collect the source node references for this signal's DAG.
refs := st.rg.sourceRefs()
if len(refs) == 0 {
return nil, fmt.Errorf("synthetic: signal %q has no upstream inputs", signal)
}
// Source node ids, index-aligned with refs, so updates map to graph inputs.
srcIDs := make([]string, len(st.rg.sources))
for i, s := range st.rg.sources {
srcIDs[i] = s.id
}
ctx, cancel := context.WithCancel(ctx)
go func() {
defer cancel()
// Latest value per upstream input index.
latest := make([]float64, len(refs))
// Latest value and timestamp per source node id.
latest := make(map[string]dsp.Sample, len(refs))
latestTs := make([]time.Time, len(refs))
ready := make([]bool, len(refs))
// Subscribe to every upstream ref via the broker.
@@ -159,7 +164,7 @@ func (s *Synthetic) Subscribe(ctx context.Context, signal string, ch chan<- data
if !ok {
return
}
val := toFloat64(u.Value.Data)
val := toSample(u.Value.Data)
select {
case updateCh <- indexedUpdate{idx: idx, val: val, ts: u.Value.Timestamp}:
default:
@@ -184,7 +189,8 @@ func (s *Synthetic) Subscribe(ctx context.Context, signal string, ch chan<- data
return
case upd := <-updateCh:
latest[upd.idx] = upd.val
latest[srcIDs[upd.idx]] = upd.val
latestTs[upd.idx] = upd.ts
ready[upd.idx] = true
// Only compute once we have at least one value for every input.
@@ -199,7 +205,19 @@ func (s *Synthetic) Subscribe(ctx context.Context, signal string, ch chan<- data
continue
}
// Run the pipeline.
// The output is computed from the latest value of every input, so
// its timestamp is the most recent contributing sample time. Using
// the triggering update's timestamp instead would drag the output
// back in time whenever a slow/stale input fired, producing
// non-monotonic or duplicated timestamps on plots.
outTs := latestTs[0]
for _, ts := range latestTs[1:] {
if ts.After(outTs) {
outTs = ts
}
}
// Evaluate the DAG.
s.mu.RLock()
cur, stillExists := s.signals[signal]
s.mu.RUnlock()
@@ -207,15 +225,15 @@ func (s *Synthetic) Subscribe(ctx context.Context, signal string, ch chan<- data
return
}
result, err := runPipeline(cur.nodes, cur.states, latest)
result, err := cur.rg.evalSample(latest)
if err != nil {
s.log.Warn("synthetic: pipeline error", "signal", signal, "err", err)
continue
}
v := datasource.Value{
Timestamp: upd.ts,
Data: result,
Timestamp: outTs,
Data: result.AsAny(),
Quality: datasource.QualityGood,
}
select {
@@ -245,10 +263,13 @@ func (s *Synthetic) AddSignal(def SignalDef) error {
if def.Name == "" {
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 {
return fmt.Errorf("build pipeline: %w", err)
return fmt.Errorf("compile graph: %w", err)
}
s.mu.Lock()
@@ -257,16 +278,7 @@ func (s *Synthetic) AddSignal(def SignalDef) error {
return fmt.Errorf("signal %q already exists", def.Name)
}
states := make([]map[string]any, len(nodes))
for i := range states {
states[i] = make(map[string]any)
}
st := &signalState{
def: def,
nodes: nodes,
states: states,
}
st := &signalState{def: def, rg: rg}
s.signals[def.Name] = st
s.mu.Unlock()
@@ -320,14 +332,9 @@ func (s *Synthetic) UpdateSignal(def SignalDef) error {
return errors.New("signal name must not be empty")
}
nodes, err := BuildPipeline(def.Pipeline)
rg, err := compileGraph(def)
if err != nil {
return fmt.Errorf("build pipeline: %w", err)
}
states := make([]map[string]any, len(nodes))
for i := range states {
states[i] = make(map[string]any)
return fmt.Errorf("compile graph: %w", err)
}
s.mu.Lock()
@@ -336,10 +343,20 @@ func (s *Synthetic) UpdateSignal(def SignalDef) error {
s.mu.Unlock()
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 {
old.cancel()
}
s.signals[def.Name] = &signalState{def: def, nodes: nodes, states: states}
s.signals[def.Name] = &signalState{def: def, rg: rg}
s.mu.Unlock()
if err := s.saveDefs(); err != nil {
@@ -402,78 +419,33 @@ func (s *Synthetic) saveDefs() error {
return os.WriteFile(s.defsFilePath(), data, 0o644)
}
// startSignal builds the pipeline for def and registers the signalState.
// startSignal compiles the DAG for def and registers the signalState.
// The actual goroutines are started lazily by Subscribe.
func (s *Synthetic) startSignal(def SignalDef) error {
nodes, err := BuildPipeline(def.Pipeline)
rg, err := compileGraph(def)
if err != nil {
return fmt.Errorf("build pipeline for %q: %w", def.Name, err)
}
states := make([]map[string]any, len(nodes))
for i := range states {
states[i] = make(map[string]any)
return fmt.Errorf("compile graph for %q: %w", def.Name, err)
}
s.mu.Lock()
s.signals[def.Name] = &signalState{
def: def,
nodes: nodes,
states: states,
}
s.signals[def.Name] = &signalState{def: def, rg: rg}
s.mu.Unlock()
s.log.Info("synthetic: signal registered", "name", def.Name)
return nil
}
// runPipeline executes all nodes in sequence. The output of node N becomes
// input[0] of node N+1. For the first node, inputs is the full upstream slice.
func runPipeline(nodes []dsp.Node, states []map[string]any, inputs []float64) (float64, error) {
if len(nodes) == 0 {
if len(inputs) == 0 {
return 0, nil
}
return inputs[0], nil
// defToMetadata converts a SignalDef into a datasource.Metadata. outType is the
// compiled graph's best-effort output type; an array output is reported as a
// waveform (TypeFloat64Array) so widgets can pick a compatible view.
func defToMetadata(def SignalDef, outType dsp.ValType) datasource.Metadata {
dt := datasource.TypeFloat64
if outType == dsp.ValArray {
dt = datasource.TypeFloat64Array
}
// First node receives all upstream inputs.
cur := inputs
var result float64
var err error
for i, node := range nodes {
result, err = node.Process(cur, states[i])
if err != nil {
return 0, fmt.Errorf("node %d (%s): %w", i, node.Type(), err)
}
// Subsequent nodes receive only the single output of the previous node.
cur = []float64{result}
}
return result, nil
}
// upstreamRefs returns the broker.SignalRef list for a SignalDef.
// If Inputs is set, those take precedence; otherwise DS+Signal is used.
func upstreamRefs(def SignalDef) []broker.SignalRef {
if len(def.Inputs) > 0 {
refs := make([]broker.SignalRef, len(def.Inputs))
for i, inp := range def.Inputs {
refs[i] = broker.SignalRef{DS: inp.DS, Name: inp.Signal}
}
return refs
}
if def.DS != "" && def.Signal != "" {
return []broker.SignalRef{{DS: def.DS, Name: def.Signal}}
}
return nil
}
// defToMetadata converts a SignalDef into a datasource.Metadata.
func defToMetadata(def SignalDef) datasource.Metadata {
return datasource.Metadata{
Name: def.Name,
Type: datasource.TypeFloat64,
Type: dt,
Unit: def.Meta.Unit,
Description: def.Meta.Description,
DisplayLow: def.Meta.DisplayLow,
@@ -482,7 +454,97 @@ 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
}
// TraceNode is a single node's computed value in a debug trace.
type TraceNode struct {
Value any `json:"value"`
Type string `json:"type"` // "scalar" | "array"
Approx bool `json:"approx,omitempty"`
}
// TraceResult is the per-node outcome of a single-shot evaluation of an
// (unsaved) synthetic graph, used by the editor's live/debug overlay.
type TraceResult struct {
Nodes map[string]TraceNode `json:"nodes"`
Error string `json:"error,omitempty"`
}
// Trace compiles def and evaluates it once with fresh state, returning the value
// computed for every node. Source node values are obtained via read(ds, name)
// (typically a broker ReadNow snapshot); a source that cannot be read defaults to
// scalar 0. Stateful ops are flagged Approx since their true running value
// depends on accumulated state this single-shot pass does not have. A per-node
// evaluation error is returned in Error with the partial values still populated.
func (s *Synthetic) Trace(def SignalDef, read func(ds, name string) (any, error)) (TraceResult, error) {
rg, err := compileGraph(def)
if err != nil {
return TraceResult{}, err
}
sourceVals := make(map[string]dsp.Sample, len(rg.sources))
for _, src := range rg.sources {
raw, rerr := read(src.ref.DS, src.ref.Name)
if rerr != nil || raw == nil {
sourceVals[src.id] = dsp.Scalar(0)
continue
}
sourceVals[src.id] = toSample(raw)
}
vals, evalErr := rg.evalSampleTrace(sourceVals)
opType := make(map[string]string, len(rg.order))
for _, n := range rg.order {
if n.kind == "op" && n.op != nil {
opType[n.id] = n.op.Type()
}
}
res := TraceResult{Nodes: make(map[string]TraceNode, len(vals))}
for id, sample := range vals {
tn := TraceNode{Value: sample.AsAny(), Type: "scalar"}
if sample.IsArray {
tn.Type = "array"
}
if ot, ok := opType[id]; ok && dsp.IsStateful(ot) {
tn.Approx = true
}
res.Nodes[id] = tn
}
if evalErr != nil {
res.Error = evalErr.Error()
}
return res, nil
}
// toSample coerces a datasource.Value.Data into a dsp.Sample: arrays become
// array Samples (waveforms), everything else a scalar Sample.
func toSample(v any) dsp.Sample {
switch val := v.(type) {
case []float64:
return dsp.Array(val)
case []float32:
out := make([]float64, len(val))
for i, e := range val {
out[i] = float64(e)
}
return dsp.Array(out)
case []int:
out := make([]float64, len(val))
for i, e := range val {
out[i] = float64(e)
}
return dsp.Array(out)
default:
return dsp.Scalar(toFloat64(v))
}
}
// toFloat64 coerces any numeric scalar value from a datasource.Value.Data to float64.
func toFloat64(v any) float64 {
switch val := v.(type) {
case float64:
@@ -508,6 +570,6 @@ func toFloat64(v any) float64 {
// indexedUpdate carries a value from one upstream goroutine to the pipeline runner.
type indexedUpdate struct {
idx int
val float64
val dsp.Sample
ts time.Time
}
@@ -0,0 +1,154 @@
package synthetic
import (
"context"
"log/slog"
"os"
"testing"
"time"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/datasource"
)
// seqSource is a test DataSource that emits a fixed sequence of values, each
// carrying its own timestamp, so tests can control upstream sample times.
type seqSource struct {
name string
seq []datasource.Value
}
func (s *seqSource) Name() string { return s.name }
func (s *seqSource) Connect(context.Context) error { return nil }
func (s *seqSource) ListSignals(context.Context) ([]datasource.Metadata, error) { return nil, nil }
func (s *seqSource) GetMetadata(context.Context, string) (datasource.Metadata, error) {
return datasource.Metadata{Name: "x", Type: datasource.TypeFloat64}, nil
}
func (s *seqSource) Write(context.Context, string, any) error { return datasource.ErrNotWritable }
func (s *seqSource) History(context.Context, string, time.Time, time.Time, int) ([]datasource.Value, error) {
return nil, datasource.ErrHistoryUnavailable
}
func (s *seqSource) Subscribe(ctx context.Context, _ string, ch chan<- datasource.Value) (datasource.CancelFunc, error) {
go func() {
for _, v := range s.seq {
select {
case ch <- v:
case <-ctx.Done():
return
}
time.Sleep(8 * time.Millisecond)
}
}()
return func() {}, nil
}
// TestSubscribePreservesUpstreamTimestamp verifies a single-source synthetic
// emits each computed value with the upstream sample's timestamp.
func TestSubscribePreservesUpstreamTimestamp(t *testing.T) {
log := slog.New(slog.NewTextHandler(os.Stderr, nil))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
base := time.Date(2026, 6, 19, 10, 0, 0, 0, time.UTC)
src := &seqSource{name: "src", seq: []datasource.Value{
{Timestamp: base.Add(1 * time.Second), Data: 1.0, Quality: datasource.QualityGood},
{Timestamp: base.Add(2 * time.Second), Data: 2.0, Quality: datasource.QualityGood},
{Timestamp: base.Add(3 * time.Second), Data: 3.0, Quality: datasource.QualityGood},
}}
brk := broker.New(ctx, log)
brk.Register(src)
syn := New(t.TempDir(), brk, log)
if err := syn.Connect(ctx); err != nil {
t.Fatal(err)
}
if err := syn.AddSignal(SignalDef{
Name: "g", DS: "src", Signal: "x",
Pipeline: []NodeDef{{Type: "gain", Params: map[string]any{"gain": 10.0}}},
}); err != nil {
t.Fatal(err)
}
ch := make(chan datasource.Value, 8)
if _, err := syn.Subscribe(ctx, "g", ch); err != nil {
t.Fatal(err)
}
want := []time.Time{base.Add(1 * time.Second), base.Add(2 * time.Second), base.Add(3 * time.Second)}
for i, w := range want {
select {
case v := <-ch:
if !v.Timestamp.Equal(w) {
t.Errorf("emit #%d timestamp: want %s, got %s", i, w, v.Timestamp)
}
case <-time.After(2 * time.Second):
t.Fatalf("timeout waiting for emit #%d", i)
}
}
}
// TestSubscribeMultiSourceUsesLatestTimestamp verifies that a synthetic combining
// two independent sources stamps each output with the MOST RECENT contributing
// sample time — not the timestamp of whichever source happened to trigger the
// computation. A slow source carrying a stale timestamp must not drag the output
// backwards in time (which previously produced wrong/non-monotonic plot points).
func TestSubscribeMultiSourceUsesLatestTimestamp(t *testing.T) {
log := slog.New(slog.NewTextHandler(os.Stderr, nil))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
now := time.Date(2026, 6, 19, 12, 0, 0, 0, time.UTC)
// Fast source A with current timestamps.
a := &seqSource{name: "A", seq: []datasource.Value{
{Timestamp: now.Add(10 * time.Second), Data: 1.0, Quality: datasource.QualityGood},
{Timestamp: now.Add(11 * time.Second), Data: 2.0, Quality: datasource.QualityGood},
{Timestamp: now.Add(12 * time.Second), Data: 3.0, Quality: datasource.QualityGood},
{Timestamp: now.Add(13 * time.Second), Data: 4.0, Quality: datasource.QualityGood},
}}
// Slow source B: a single sample with a much older timestamp.
b := &seqSource{name: "B", seq: []datasource.Value{
{Timestamp: now.Add(1 * time.Second), Data: 100.0, Quality: datasource.QualityGood},
}}
brk := broker.New(ctx, log)
brk.Register(a)
brk.Register(b)
syn := New(t.TempDir(), brk, log)
if err := syn.Connect(ctx); err != nil {
t.Fatal(err)
}
if err := syn.AddSignal(SignalDef{
Name: "diff",
Graph: &Graph{Output: "out", Nodes: []GraphNode{
{ID: "sa", Kind: "source", DS: "A", Signal: "x"},
{ID: "sb", Kind: "source", DS: "B", Signal: "x"},
{ID: "sub", Kind: "op", Op: "subtract", Inputs: []string{"sa", "sb"}},
{ID: "out", Kind: "output", Inputs: []string{"sub"}},
}},
}); err != nil {
t.Fatal(err)
}
ch := make(chan datasource.Value, 16)
if _, err := syn.Subscribe(ctx, "diff", ch); err != nil {
t.Fatal(err)
}
var last time.Time
for i := 0; i < 4; i++ {
select {
case v := <-ch:
// The stale source-B timestamp (t=1s) must never be used: every output
// is stamped with the newest input time, so emits stay monotonic.
if v.Timestamp.Equal(now.Add(1 * time.Second)) {
t.Errorf("emit #%d used the stale source-B timestamp %s", i, v.Timestamp)
}
if !last.IsZero() && v.Timestamp.Before(last) {
t.Errorf("emit #%d went backwards: %s before previous %s", i, v.Timestamp, last)
}
last = v.Timestamp
case <-time.After(2 * time.Second):
t.Fatalf("timeout waiting for emit #%d", i)
}
}
}
+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 evaluates a simple arithmetic expression with variables a, b, c, d
// bound to inputs[0..3]. It uses a hand-written recursive descent parser.
// ExprNode evaluates a simple arithmetic expression. Inputs are bound to named
// variables: Vars[i] -> inputs[i]. When Vars is empty it defaults to a, b, c, d
// (bound to inputs[0..3]) for backward compatibility. It uses a hand-written
// recursive descent parser.
type ExprNode struct {
Expr string
Vars []string
}
// defaultVarNames returns the variable names for an expr/lua node: the explicit
// list when set, otherwise the legacy a,b,c,d.
func defaultVarNames(vars []string) []string {
if len(vars) > 0 {
return vars
}
return []string{"a", "b", "c", "d"}
}
func (n *ExprNode) Type() string { return "expr" }
func (n *ExprNode) Process(inputs []float64, _ map[string]any) (float64, error) {
vars := map[string]float64{}
names := []string{"a", "b", "c", "d"}
names := defaultVarNames(n.Vars)
for i, name := range names {
if i < len(inputs) {
vars[name] = inputs[i]
@@ -507,13 +519,10 @@ func (p *exprParser) parseCall() (float64, error) {
}
}
// Not a function call — must be a single-letter variable.
if len(name) != 1 {
return 0, fmt.Errorf("unknown identifier %q (use ad for variables, or a known function name)", name)
}
// Not a function call — must be a declared input variable.
val, ok := p.vars[name]
if !ok {
return 0, fmt.Errorf("unknown variable %q (allowed: a, b, c, d)", name)
return 0, fmt.Errorf("unknown variable %q (declare it as a named input)", name)
}
return val, nil
}
@@ -628,10 +637,12 @@ func (n *LowPassNode) Process(inputs []float64, state map[string]any) (float64,
// ── LuaNode ───────────────────────────────────────────────────────────────────
// LuaNode runs a Lua script in a sandboxed gopher-lua VM.
// Inputs are bound to globals a, b, c, d. The script's return value is the output.
// Inputs are bound to globals named by Vars (Vars[i] -> inputs[i]); when Vars is
// empty it defaults to a, b, c, d. The script's return value is the output.
// The os, io, package, and debug libraries are disabled.
type LuaNode struct {
Script string
Vars []string
}
func (n *LuaNode) Type() string { return "lua" }
@@ -682,7 +693,7 @@ func (n *LuaNode) Process(inputs []float64, state map[string]any) (result float6
L.SetTop(0)
// Bind inputs.
names := []string{"a", "b", "c", "d"}
names := defaultVarNames(n.Vars)
for i, name := range names {
if i < len(inputs) {
L.SetGlobal(name, lua.LNumber(inputs[i]))
+151
View File
@@ -0,0 +1,151 @@
package dsp
import "fmt"
// ValType is the data type of a Sample: a scalar float, a float array
// (waveform), or — at graph-compile time, before a source's real type is
// known — unknown.
type ValType uint8
const (
ValUnknown ValType = iota
ValScalar
ValArray
)
func (t ValType) String() string {
switch t {
case ValScalar:
return "scalar"
case ValArray:
return "array"
default:
return "unknown"
}
}
// Sample is a value flowing through the synthetic DSP graph: either a scalar
// float64 or a float64 array (waveform). It is the array-aware counterpart of
// the bare float64 the legacy scalar Node interface uses.
type Sample struct {
F float64
Arr []float64
IsArray bool
}
// Scalar wraps a float64 as a scalar Sample.
func Scalar(f float64) Sample { return Sample{F: f} }
// Array wraps a []float64 as an array Sample.
func Array(a []float64) Sample { return Sample{Arr: a, IsArray: true} }
// Type reports whether the sample is a scalar or an array.
func (s Sample) Type() ValType {
if s.IsArray {
return ValArray
}
return ValScalar
}
// AsAny returns the value in the form datasource.Value.Data expects: a
// []float64 for arrays, a float64 for scalars.
func (s Sample) AsAny() any {
if s.IsArray {
return s.Arr
}
return s.F
}
// AsArray returns the sample's data as a slice: the array itself, or a
// single-element slice for a scalar. Used by reductions that accept either.
func (s Sample) AsArray() []float64 {
if s.IsArray {
return s.Arr
}
return []float64{s.F}
}
// ArrayNode is an optional extension of Node implemented by ops that operate
// natively on Samples (reductions array→scalar, producers array→array, etc.).
// eval prefers ProcessSample when a node implements it.
type ArrayNode interface {
Node
ProcessSample(inputs []Sample, state map[string]any) (Sample, error)
}
// statelessElementwise lists scalar ops that are safe to broadcast element-wise
// over array inputs: they hold no per-evaluation state, so running the legacy
// Process once per array lane is well-defined. Stateful ops (moving_average,
// rms, lowpass, derivative, integrate) and lua are excluded — a single shared
// state map cannot be meaningfully split across lanes.
var statelessElementwise = map[string]bool{
"gain": true, "offset": true, "add": true, "subtract": true,
"multiply": true, "divide": true, "clamp": true, "threshold": true,
"expr": true,
}
// StatelessElementwise reports whether a scalar op type may be broadcast over
// array inputs via ApplyElementwise.
func StatelessElementwise(nodeType string) bool { return statelessElementwise[nodeType] }
// scalarInputs wraps a legacy float64 input slice as scalar Samples.
func scalarInputs(in []float64) []Sample {
out := make([]Sample, len(in))
for i, v := range in {
out[i] = Scalar(v)
}
return out
}
// ApplyElementwise runs a stateless scalar Node over Sample inputs. If every
// input is scalar it calls Process once and wraps the result. If any input is
// an array it broadcasts: scalar inputs act as constants, all array inputs must
// share a common length (else an error), and Process is invoked once per index.
//
// The node MUST be stateless (see StatelessElementwise) — a shared state map
// cannot be split across array lanes.
func ApplyElementwise(n Node, inputs []Sample, state map[string]any) (Sample, error) {
// Determine the array length, if any input is an array.
length := -1
for _, s := range inputs {
if !s.IsArray {
continue
}
if length == -1 {
length = len(s.Arr)
} else if len(s.Arr) != length {
return Sample{}, fmt.Errorf("%s: array length mismatch (%d vs %d)", n.Type(), length, len(s.Arr))
}
}
if length == -1 {
// All scalar — single legacy call.
row := make([]float64, len(inputs))
for i, s := range inputs {
row[i] = s.F
}
r, err := n.Process(row, state)
if err != nil {
return Sample{}, err
}
return Scalar(r), nil
}
out := make([]float64, length)
row := make([]float64, len(inputs))
for i := 0; i < length; i++ {
for j, s := range inputs {
if s.IsArray {
row[j] = s.Arr[i]
} else {
row[j] = s.F
}
}
r, err := n.Process(row, state)
if err != nil {
return Sample{}, err
}
out[i] = r
}
return Array(out), nil
}
+86
View File
@@ -0,0 +1,86 @@
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,
}
// statefulOps accumulate state across evaluations, so a single-shot trace
// (fresh state) only approximates their true running value. lua is included
// because a script may persist state in its state table.
statefulOps = map[string]bool{
"moving_average": true, "rms": true, "lowpass": true,
"derivative": true, "integrate": true, "lua": true,
}
)
// IsStateful reports whether an op accumulates state across evaluations. The
// editor's live/debug trace flags such nodes as "approx" since it evaluates a
// single tick with fresh state.
func IsStateful(op string) bool { return statefulOps[op] }
// 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
}
}
}
+24
View File
@@ -38,6 +38,30 @@ func IncWrites() { writeOps.Add(1) }
// IncHistoryReqs increments the history request counter.
func IncHistoryReqs() { historyReqs.Add(1) }
// Stats is a point-in-time snapshot of the in-process counters, for rendering
// as JSON in the admin pane (the Prometheus Handler renders the same data as
// text).
type Stats struct {
UptimeSeconds float64 `json:"uptimeSeconds"`
WsConnections int64 `json:"wsConnections"`
MsgIn int64 `json:"msgIn"`
MsgOut int64 `json:"msgOut"`
WriteOps int64 `json:"writeOps"`
HistoryReqs int64 `json:"historyReqs"`
}
// Snapshot returns the current values of all counters and gauges.
func Snapshot() Stats {
return Stats{
UptimeSeconds: time.Since(startTime).Seconds(),
WsConnections: wsConns.Load(),
MsgIn: msgIn.Load(),
MsgOut: msgOut.Load(),
WriteOps: writeOps.Load(),
HistoryReqs: historyReqs.Load(),
}
}
// Handler returns an http.HandlerFunc that renders Prometheus-format metrics.
// activeSubs is called each request to read the current number of unique signal
// subscriptions from the broker; pass nil to omit the metric.
+187
View File
@@ -0,0 +1,187 @@
package server
import (
"encoding/json"
"log/slog"
"strconv"
"sync"
"sync/atomic"
"github.com/uopi/uopi/internal/controllogic"
)
// DebugHub fans control-logic node-execution events out to the editors watching
// them, and owns the per-client debug subscriptions. It implements
// controllogic.DebugObserver; the engine (and simulate sandboxes) call Observe
// on every node execution.
//
// Two subscription modes share one event shape:
// - "live" — observe the running, enabled graph by its id.
// - "simulate" — dry-run an unsaved graph in a throwaway sandbox. Each
// simulate sub gets a unique route id so its events never mix with the live
// graph's (which may share the same persisted id).
//
// Routing is by event GraphID: live events carry the real graph id (only
// emitted while that id is in the engine's debugWatch set, which the hub keeps
// in sync with its live subs), simulate events carry the sandbox's unique id.
type DebugHub struct {
engine *controllogic.Engine
log *slog.Logger
seq uint64 // unique simulate route ids
mu sync.Mutex
subs map[*wsClient]*debugSub // one debug sub per client
routes map[string]map[*wsClient]struct{} // route id → watching clients
liveCount map[string]int // live-subscribed graph id → count
}
type debugSub struct {
route string // graph id (live) or unique sandbox id (simulate)
live bool // true for "live", false for "simulate"
stop func() // simulate sandbox teardown (nil for live)
}
// NewDebugHub builds an empty hub bound to the engine it observes.
func NewDebugHub(engine *controllogic.Engine, log *slog.Logger) *DebugHub {
return &DebugHub{
engine: engine,
log: log,
subs: map[*wsClient]*debugSub{},
routes: map[string]map[*wsClient]struct{}{},
liveCount: map[string]int{},
}
}
// debugOut is the client-bound JSON form of a node-execution event.
type debugOut struct {
Type string `json:"type"` // always "debugNode"
GraphID string `json:"graphId"`
NodeID string `json:"nodeId"`
Value float64 `json:"value"`
HasValue bool `json:"hasValue"`
TS int64 `json:"ts"`
}
// Observe implements controllogic.DebugObserver: push the event to every client
// watching its route (drop-on-full so a slow editor never stalls the engine).
func (h *DebugHub) Observe(ev controllogic.DebugEvent) {
h.mu.Lock()
watchers := h.routes[ev.GraphID]
if len(watchers) == 0 {
h.mu.Unlock()
return
}
targets := make([]*wsClient, 0, len(watchers))
for c := range watchers {
targets = append(targets, c)
}
h.mu.Unlock()
b, err := json.Marshal(debugOut{
Type: "debugNode",
GraphID: ev.GraphID,
NodeID: ev.NodeID,
Value: ev.Value,
HasValue: ev.HasValue,
TS: ev.TS,
})
if err != nil {
return
}
for _, c := range targets {
select {
case c.outCh <- b:
default:
}
}
}
// subscribeLive starts (or replaces) a client's live observation of graphID.
func (h *DebugHub) subscribeLive(c *wsClient, graphID string) {
h.unsubscribe(c)
if graphID == "" {
return
}
sub := &debugSub{route: graphID, live: true}
h.mu.Lock()
h.addRoute(c, sub)
h.liveCount[graphID]++
watch := h.snapshotWatch()
h.mu.Unlock()
h.engine.SetDebugWatch(watch)
}
// subscribeSimulate starts (or replaces) a client's dry-run of an unsaved graph.
func (h *DebugHub) subscribeSimulate(c *wsClient, g controllogic.Graph) {
h.unsubscribe(c)
id := "sim-" + strconv.FormatUint(atomic.AddUint64(&h.seq, 1), 10)
g.ID = id // route sandbox events under a unique id (never collides with live)
stop := h.engine.StartSimulate(g)
sub := &debugSub{route: id, live: false, stop: stop}
h.mu.Lock()
h.addRoute(c, sub)
h.mu.Unlock()
}
// fire forces nodeID's trigger to run in the graph the client is currently
// debugging (live or simulate), routed by that session's id. No-op if the
// client has no active debug session.
func (h *DebugHub) fire(c *wsClient, nodeID string) {
h.mu.Lock()
sub := h.subs[c]
h.mu.Unlock()
if sub == nil {
return
}
h.engine.FireTrigger(sub.route, nodeID)
}
// addRoute records sub for c; caller holds h.mu.
func (h *DebugHub) addRoute(c *wsClient, sub *debugSub) {
h.subs[c] = sub
if h.routes[sub.route] == nil {
h.routes[sub.route] = map[*wsClient]struct{}{}
}
h.routes[sub.route][c] = struct{}{}
}
// unsubscribe tears down a client's debug sub (stopping its sandbox if any) and
// refreshes the engine's watch set. Safe when the client has no sub.
func (h *DebugHub) unsubscribe(c *wsClient) {
h.mu.Lock()
sub := h.subs[c]
if sub == nil {
h.mu.Unlock()
return
}
delete(h.subs, c)
if m := h.routes[sub.route]; m != nil {
delete(m, c)
if len(m) == 0 {
delete(h.routes, sub.route)
}
}
if sub.live {
if h.liveCount[sub.route]--; h.liveCount[sub.route] <= 0 {
delete(h.liveCount, sub.route)
}
}
watch := h.snapshotWatch()
h.mu.Unlock()
if sub.stop != nil {
sub.stop()
}
h.engine.SetDebugWatch(watch)
}
// snapshotWatch builds a fresh immutable set of live-watched graph ids. Caller
// holds h.mu; the result is published to the engine which reads it lock-free.
func (h *DebugHub) snapshotWatch() map[string]bool {
w := make(map[string]bool, len(h.liveCount))
for k := range h.liveCount {
w[k] = true
}
return w
}
+199
View File
@@ -0,0 +1,199 @@
package server
import (
"context"
"encoding/json"
"log/slog"
"strconv"
"strings"
"sync"
"github.com/uopi/uopi/internal/access"
"github.com/uopi/uopi/internal/audit"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/controllogic"
"github.com/uopi/uopi/internal/datasource"
)
// DialogHub fans control-logic dialog requests out to connected WebSocket
// clients whose identity matches the dialog's user/group filter, and routes
// input responses back to the dialog's target server variable.
//
// It implements controllogic.Notifier; the engine calls Notify when an
// action.dialog node runs. Input dialogs are remembered as pending so a later
// dialogResponse can be correlated by id and validated against its recipient
// filter — this is why panels can write the response target even though direct
// srv writes are otherwise gated to control-logic editors.
type DialogHub struct {
broker *broker.Broker
policy *access.Policy
audit audit.Recorder
log *slog.Logger
mu sync.Mutex
clients map[*wsClient]struct{}
pending map[string]controllogic.Dialog // input dialogs awaiting a response
}
// NewDialogHub builds an empty hub. rec is never nil after construction.
func NewDialogHub(brk *broker.Broker, policy *access.Policy, rec audit.Recorder, log *slog.Logger) *DialogHub {
if rec == nil {
rec = audit.Nop()
}
return &DialogHub{
broker: brk,
policy: policy,
audit: rec,
log: log,
clients: map[*wsClient]struct{}{},
pending: map[string]controllogic.Dialog{},
}
}
func (h *DialogHub) add(c *wsClient) {
h.mu.Lock()
h.clients[c] = struct{}{}
h.mu.Unlock()
}
func (h *DialogHub) remove(c *wsClient) {
h.mu.Lock()
delete(h.clients, c)
h.mu.Unlock()
}
// dialogOut is the client-bound JSON form of a dialog request.
type dialogOut struct {
Type string `json:"type"` // always "dialog"
ID string `json:"id"`
Kind string `json:"kind"` // "info" | "error" | "input"
Title string `json:"title,omitempty"`
Message string `json:"message,omitempty"`
}
// Notify implements controllogic.Notifier: serialise the dialog and push it to
// every connected client matching its user/group filter.
func (h *DialogHub) Notify(d controllogic.Dialog) {
b, err := json.Marshal(dialogOut{
Type: "dialog",
ID: d.ID,
Kind: d.Kind,
Title: d.Title,
Message: d.Message,
})
if err != nil {
return
}
h.mu.Lock()
if d.Kind == "input" && strings.TrimSpace(d.Target) != "" {
h.pending[d.ID] = d
}
var targets []*wsClient
for c := range h.clients {
if h.matches(c.user, d) {
targets = append(targets, c)
}
}
h.mu.Unlock()
for _, c := range targets {
select {
case c.outCh <- b:
default:
}
}
}
// matches reports whether user is a recipient of d. An empty user+group filter
// targets everyone; otherwise the user must be named or in a named group.
func (h *DialogHub) matches(user string, d controllogic.Dialog) bool {
if len(d.Users) == 0 && len(d.Groups) == 0 {
return true
}
if h.policy != nil {
user = h.policy.ResolveUser(user)
}
for _, u := range d.Users {
if u == user {
return true
}
}
if len(d.Groups) > 0 && h.policy != nil {
groups := map[string]bool{}
for _, g := range h.policy.GroupsOf(user) {
groups[g] = true
}
for _, g := range d.Groups {
if groups[g] {
return true
}
}
}
return false
}
// cancel drops a pending input dialog without writing (user dismissed it).
func (h *DialogHub) cancel(id string) {
h.mu.Lock()
delete(h.pending, id)
h.mu.Unlock()
}
// respond writes an input dialog's response to its target server variable. The
// dialog must be pending and the responding client must have been a recipient;
// this gate replaces the usual srv write-permission check for sanctioned
// control-logic responses.
func (h *DialogHub) respond(ctx context.Context, c *wsClient, id string, value float64) {
h.mu.Lock()
d, ok := h.pending[id]
if ok {
delete(h.pending, id)
}
h.mu.Unlock()
if !ok || !h.matches(c.user, d) {
return
}
ds, name, ok := parseDialogTarget(d.Target)
if !ok || ds == "local" {
return
}
src, ok := h.broker.Source(ds)
if !ok {
h.log.Warn("dialog response: unknown data source", "ds", ds, "target", d.Target)
return
}
ev := audit.Event{
Actor: c.user,
ActorType: audit.ActorUser,
Action: "signal.write",
DS: ds,
Signal: name,
Value: strconv.FormatFloat(value, 'g', -1, 64),
Detail: "control logic dialog response",
IP: c.ip,
Outcome: audit.OutcomeOK,
}
wctx := datasource.WithUser(ctx, c.user)
if err := src.Write(wctx, name, value); err != nil {
h.log.Warn("dialog response: write failed", "ds", ds, "signal", name, "err", err)
ev.Outcome = audit.OutcomeError
ev.Error = err.Error()
}
h.audit.Record(ev)
}
// parseDialogTarget splits a "ds:name" dialog target on the first ':'. A bare
// name (no ':') defaults to the persistent server-variable source "srv".
func parseDialogTarget(t string) (ds, name string, ok bool) {
t = strings.TrimSpace(t)
if t == "" {
return "", "", false
}
if i := strings.IndexByte(t, ':'); i >= 0 {
return t[:i], t[i+1:], true
}
return "srv", t, true
}
+8 -3
View File
@@ -10,7 +10,9 @@ import (
"github.com/uopi/uopi/internal/access"
"github.com/uopi/uopi/internal/api"
"github.com/uopi/uopi/internal/audit"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/confmgr"
"github.com/uopi/uopi/internal/controllogic"
"github.com/uopi/uopi/internal/datasource/synthetic"
"github.com/uopi/uopi/internal/metrics"
@@ -27,7 +29,10 @@ type Server struct {
// New creates the HTTP server, registers all routes, and returns a ready-to-start Server.
// synth may be nil if the synthetic data source is not enabled.
func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, policy *access.Policy, acl *panelacl.Store, ctrlLogic *controllogic.Store, ctrlEngine *controllogic.Engine, channelFinderURL, archiverURL, trustedUserHeader string, log *slog.Logger) *Server {
func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, cfgStore *confmgr.Store, policy *access.Policy, acl *panelacl.Store, ctrlLogic *controllogic.Store, ctrlEngine *controllogic.Engine, dialogs *DialogHub, debug *DebugHub, rec audit.Recorder, channelFinderURL, archiverURL, trustedUserHeader string, log *slog.Logger) *Server {
if rec == nil {
rec = audit.Nop()
}
mux := http.NewServeMux()
// Health check
@@ -37,7 +42,7 @@ func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Syntheti
})
// WebSocket endpoint
mux.Handle("/ws", &wsHandler{broker: brk, log: log, userHeader: trustedUserHeader, policy: policy})
mux.Handle("/ws", &wsHandler{broker: brk, log: log, userHeader: trustedUserHeader, policy: policy, audit: rec, dialogs: dialogs, debug: debug})
// Prometheus-format metrics
mux.HandleFunc("/metrics", metrics.Handler(brk.ActiveSubscriptions))
@@ -45,7 +50,7 @@ func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Syntheti
// REST API — registered on a dedicated mux so it can be wrapped with the
// access-control middleware (identity resolution + global level enforcement).
apiMux := http.NewServeMux()
api.New(brk, synth, store, policy, acl, ctrlLogic, ctrlEngine, channelFinderURL, archiverURL, log).Register(apiMux, apiPrefix)
api.New(brk, synth, store, cfgStore, policy, acl, ctrlLogic, ctrlEngine, rec, channelFinderURL, archiverURL, log).Register(apiMux, apiPrefix)
mux.Handle(apiPrefix+"/", accessMiddleware(policy, trustedUserHeader, apiMux))
// Embedded frontend — must be last (catch-all)
+178 -12
View File
@@ -5,7 +5,9 @@ import (
"encoding/json"
"errors"
"log/slog"
"net"
"net/http"
"strconv"
"strings"
"sync"
"time"
@@ -13,7 +15,9 @@ import (
"github.com/coder/websocket"
"github.com/uopi/uopi/internal/access"
"github.com/uopi/uopi/internal/audit"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/controllogic"
"github.com/uopi/uopi/internal/datasource"
"github.com/uopi/uopi/internal/metrics"
)
@@ -31,6 +35,17 @@ type inMsg struct {
Name string `json:"name,omitempty"`
Value json.RawMessage `json:"value,omitempty"`
// dialogResponse — id of the control-logic dialog being answered.
ID string `json:"id,omitempty"`
// debugSubscribe — live observation / dry-run of a control-logic graph.
Mode string `json:"mode,omitempty"` // "live" | "simulate"
GraphID string `json:"graphId,omitempty"` // live: id of the running graph
Graph json.RawMessage `json:"graph,omitempty"` // simulate: the unsaved graph
// fireTrigger — force a trigger node of the current debug session to run.
NodeID string `json:"nodeId,omitempty"`
// history
Start time.Time `json:"start"`
End time.Time `json:"end"`
@@ -48,11 +63,13 @@ type outMsg struct {
Type string `json:"type"`
// update
DS string `json:"ds,omitempty"`
Name string `json:"name,omitempty"`
TS string `json:"ts,omitempty"`
Value any `json:"value,omitempty"`
Quality string `json:"quality,omitempty"`
DS string `json:"ds,omitempty"`
Name string `json:"name,omitempty"`
TS string `json:"ts,omitempty"`
Value any `json:"value,omitempty"`
Quality string `json:"quality,omitempty"`
Severity int `json:"severity,omitempty"` // raw EPICS alarm severity (0=NO_ALARM)
Status int `json:"status,omitempty"` // raw EPICS alarm status
// meta
Meta *metaPayload `json:"meta,omitempty"`
@@ -94,6 +111,12 @@ type wsHandler struct {
userHeader string
// policy enforces the global access level (blacklist) on signal writes.
policy *access.Policy
// audit records signal writes (never nil; audit.Nop when disabled).
audit audit.Recorder
// dialogs fans control-logic dialogs to clients and routes responses.
dialogs *DialogHub
// debug fans control-logic node-execution events to watching editors.
debug *DebugHub
}
func (h *wsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -103,6 +126,12 @@ func (h *wsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if h.userHeader != "" {
user = strings.TrimSpace(r.Header.Get(h.userHeader))
}
// Resolve to the configured default_user when the header is absent so the
// session carries a real identity (used for audit + EPICS write attribution).
if h.policy != nil {
user = h.policy.ResolveUser(user)
}
clientIP := clientIP(r)
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
// Permissive origin check for LAN / SSH-tunnel use; tighten when auth lands.
@@ -118,17 +147,36 @@ func (h *wsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
rec := h.audit
if rec == nil {
rec = audit.Nop()
}
c := &wsClient{
conn: conn,
broker: h.broker,
user: user,
ip: clientIP,
policy: h.policy,
audit: rec,
dialogs: h.dialogs,
debug: h.debug,
outCh: make(chan []byte, 512),
updateCh: make(chan broker.Update, 1024),
subs: make(map[broker.SignalRef]func()),
log: h.log,
}
// Register for control-logic dialog pushes for this client's identity.
if h.dialogs != nil {
h.dialogs.add(c)
defer h.dialogs.remove(c)
}
// Tear down any debug subscription (and its simulate sandbox) on disconnect.
if h.debug != nil {
defer h.debug.unsubscribe(c)
}
var wg sync.WaitGroup
wg.Add(3)
go func() { defer wg.Done(); c.readLoop(ctx, cancel) }()
@@ -153,7 +201,11 @@ type wsClient struct {
broker *broker.Broker
log *slog.Logger
user string // end-user identity from the trusted proxy header ("" if none)
policy *access.Policy // global access-level enforcement
ip string // client address, for audit attribution
policy *access.Policy // global access-level enforcement
audit audit.Recorder // signal-write audit recorder (never nil)
dialogs *DialogHub // control-logic dialog fan-out (nil if disabled)
debug *DebugHub // control-logic debug fan-out (nil if disabled)
outCh chan []byte // serialised outgoing messages
updateCh chan broker.Update // raw updates from the broker
@@ -190,12 +242,14 @@ func (c *wsClient) dispatchLoop(ctx context.Context) {
continue
}
msg := outMsg{
Type: "update",
DS: u.Ref.DS,
Name: u.Ref.Name,
TS: u.Value.Timestamp.UTC().Format(time.RFC3339Nano),
Value: u.Value.Data,
Quality: u.Value.Quality.String(),
Type: "update",
DS: u.Ref.DS,
Name: u.Ref.Name,
TS: u.Value.Timestamp.UTC().Format(time.RFC3339Nano),
Value: u.Value.Data,
Quality: u.Value.Quality.String(),
Severity: u.Value.Severity,
Status: u.Value.Status,
}
b, err := json.Marshal(msg)
if err != nil {
@@ -248,6 +302,18 @@ func (c *wsClient) handleMessage(ctx context.Context, data []byte) {
c.handleWrite(ctx, msg)
case "history":
c.handleHistory(ctx, msg)
case "dialogResponse":
c.handleDialogResponse(ctx, msg)
case "debugSubscribe":
c.handleDebugSubscribe(ctx, msg)
case "debugUnsubscribe":
if c.debug != nil {
c.debug.unsubscribe(c)
}
case "fireTrigger":
if c.debug != nil {
c.debug.fire(c, msg.NodeID)
}
default:
c.sendError(ctx, "UNKNOWN_TYPE", "unknown message type: "+msg.Type)
}
@@ -303,6 +369,14 @@ func (c *wsClient) handleWrite(ctx context.Context, msg inMsg) {
return
}
// Server variables are read-any but write only for control-logic editors, so a
// panel cannot mutate sequence state unless its user owns control-logic access.
if msg.DS == "srv" && c.policy != nil && !c.policy.CanEditLogic(c.policy.ResolveUser(c.user)) {
c.log.Warn("write: server variable denied", "name", msg.Name, "user", c.user)
c.sendError(ctx, "ACCESS_DENIED", "writing server variables requires control-logic access")
return
}
ds, ok := c.broker.Source(msg.DS)
if !ok {
c.log.Warn("write: unknown data source", "ds", msg.DS, "name", msg.Name)
@@ -336,10 +410,64 @@ func (c *wsClient) handleWrite(ctx context.Context, msg inMsg) {
// Attribute the write to the connecting end-user so data sources (EPICS) can
// act under that identity rather than the server's.
ctx = datasource.WithUser(ctx, c.user)
ev := audit.Event{
Actor: c.user,
ActorType: audit.ActorUser,
Action: "signal.write",
DS: msg.DS,
Signal: msg.Name,
Value: formatAuditValue(value),
IP: c.ip,
Outcome: audit.OutcomeOK,
}
if err := ds.Write(ctx, msg.Name, value); err != nil {
c.log.Warn("write: ds.Write failed", "ds", msg.DS, "name", msg.Name, "err", err)
ev.Outcome = audit.OutcomeError
ev.Error = err.Error()
c.audit.Record(ev)
c.sendError(ctx, "WRITE_ERROR", err.Error())
return
}
c.audit.Record(ev)
}
// handleDialogResponse routes the answer to a control-logic input dialog. An
// empty/null value means the user dismissed the dialog (drop it without
// writing); otherwise the numeric value is written to the dialog's target.
func (c *wsClient) handleDialogResponse(ctx context.Context, msg inMsg) {
if c.dialogs == nil || msg.ID == "" {
return
}
if len(msg.Value) == 0 || string(msg.Value) == "null" {
c.dialogs.cancel(msg.ID)
return
}
var num float64
if err := json.Unmarshal(msg.Value, &num); err != nil {
c.dialogs.cancel(msg.ID)
return
}
c.dialogs.respond(ctx, c, msg.ID, num)
}
// handleDebugSubscribe starts a control-logic debug session for this client.
// mode "live" observes the running graph identified by GraphID; mode "simulate"
// dry-runs the unsaved Graph payload in a sandbox (no real writes). Either way
// the previous session (if any) is replaced; node events arrive as "debugNode".
func (c *wsClient) handleDebugSubscribe(ctx context.Context, msg inMsg) {
if c.debug == nil {
return
}
if msg.Mode == "simulate" {
var g controllogic.Graph
if err := json.Unmarshal(msg.Graph, &g); err != nil {
c.sendError(ctx, "DEBUG_ERROR", "invalid graph: "+err.Error())
return
}
c.debug.subscribeSimulate(c, g)
return
}
c.debug.subscribeLive(c, msg.GraphID)
}
func (c *wsClient) handleHistory(ctx context.Context, msg inMsg) {
@@ -416,6 +544,44 @@ func (c *wsClient) sendError(ctx context.Context, code, message string) {
// ── Helpers ───────────────────────────────────────────────────────────────────
// clientIP returns the best-effort client address for audit attribution,
// preferring the X-Forwarded-For / X-Real-IP headers set by a reverse proxy and
// falling back to the TCP peer address.
func clientIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if i := strings.IndexByte(xff, ','); i >= 0 {
return strings.TrimSpace(xff[:i])
}
return strings.TrimSpace(xff)
}
if xr := r.Header.Get("X-Real-IP"); xr != "" {
return strings.TrimSpace(xr)
}
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
return host
}
return r.RemoteAddr
}
// formatAuditValue renders a written value as a compact string for the audit log.
func formatAuditValue(v any) string {
switch x := v.(type) {
case string:
return x
case float64:
return strconv.FormatFloat(x, 'g', -1, 64)
case bool:
return strconv.FormatBool(x)
case nil:
return ""
default:
if b, err := json.Marshal(v); err == nil {
return string(b)
}
return ""
}
}
func metadataToPayload(m datasource.Metadata) *metaPayload {
return &metaPayload{
Type: dataTypeName(m.Type),
+36 -6
View File
@@ -22,6 +22,8 @@ type InterfaceMeta struct {
ID string `json:"id"`
Name string `json:"name"`
Version int `json:"version"`
// Kind is "plot" for split-layout plot panels, empty for free-form panels.
Kind string `json:"kind,omitempty"`
}
// VersionMeta describes a single persisted revision of an interface.
@@ -134,6 +136,7 @@ type rootAttrs struct {
Name string `xml:"name,attr"`
Version int `xml:"version,attr"`
Tag string `xml:"tag,attr"`
Kind string `xml:"kind,attr"`
}
func (s *Store) readMeta(id string) (InterfaceMeta, error) {
@@ -145,7 +148,7 @@ func (s *Store) readMeta(id string) (InterfaceMeta, error) {
if err := xml.Unmarshal(data, &root); err != nil {
return InterfaceMeta{}, err
}
return InterfaceMeta{ID: id, Name: root.Name, Version: root.Version}, nil
return InterfaceMeta{ID: id, Name: root.Name, Version: root.Version, Kind: root.Kind}, nil
}
// Get returns the raw XML bytes for the interface with the given ID.
@@ -474,16 +477,43 @@ func setXMLAttribute(data []byte, attr, value string) ([]byte, error) {
return []byte(s[:valStart] + value + s[valStart+valEnd:]), nil
}
// Delete removes the interface with the given ID.
// Delete moves the interface with the given ID — and all of its version
// backups — into a timestamped trash folder so it can be recovered if needed.
// Nothing is destroyed; recovery is a matter of moving the files back.
func (s *Store) Delete(id string) error {
if err := validateID(id); err != nil {
return ErrNotFound
}
err := os.Remove(s.filePath(id))
if errors.Is(err, os.ErrNotExist) {
return ErrNotFound
if _, err := os.Stat(s.filePath(id)); err != nil {
if errors.Is(err, os.ErrNotExist) {
return ErrNotFound
}
return err
}
return err
trashDir := filepath.Join(s.rootDir, "trash", "interfaces", fmt.Sprintf("%s.%d", id, time.Now().UnixMilli()))
if err := os.MkdirAll(trashDir, 0o755); err != nil {
return fmt.Errorf("create trash dir: %w", err)
}
// Move the current file plus every versioned backup (id.vN.xml), preserving
// their original names so a restore is a straight move-back.
entries, err := os.ReadDir(s.dir)
if err != nil {
return err
}
for _, e := range entries {
name := e.Name()
if e.IsDir() {
continue
}
if name == id+".xml" || (strings.HasPrefix(name, id+".v") && isVersioned(name)) {
if err := os.Rename(filepath.Join(s.dir, name), filepath.Join(trashDir, name)); err != nil {
return fmt.Errorf("move %s to trash: %w", name, err)
}
}
}
return nil
}
// Clone duplicates an interface, assigning a new unique ID.
+26 -16
View File
@@ -11,23 +11,33 @@ trusted_user_header = "" # e.g. "X-Forwarded-User"
# Identity used when the trusted header is absent/empty. Empty = anonymous.
default_user = ""
# Every user is trusted with full write access by default. The blacklist
# downgrades specific users. Levels: "readonly" (view only, no writes) or
# "noaccess" (denied entirely).
# [[server.blacklist]]
# user = "guest"
# level = "readonly"
# Named sets of users, referenced by per-panel sharing rules.
# Role-based access through group memberships. Each [[groups]] block lists
# members by role; a user's effective global capability is the highest role
# across all their memberships, along this ladder:
# viewer < operator < logiceditor < auditor < admin
# Capabilities: operator+ may write signals; logiceditor+ may edit panel and
# control logic; auditor+ may view the audit log; admin may open the admin pane.
#
# The built-in "public" group is implicit (every user is a viewer of it). A
# config with NO roles assigned anywhere is fully open (everyone is admin), for
# trusted-LAN/dev use; assigning any role switches to strict mode where unlisted
# users are read-only viewers. Once changed at runtime via the admin pane,
# {storage_dir}/access.json supersedes the groups below.
#
# Groups may nest via "parent": a member of a parent group inherits its role on
# every descendant group too (unless overridden lower).
# [[groups]]
# name = "operators"
# members = ["alice", "bob"]
# Optional: restrict who may add or edit panel logic (the <logic> block of
# interfaces) AND server-side control logic. Entries are usernames or group
# names. Empty/unset = no restriction (any writer may edit logic). Also settable
# via UOPI_SERVER_LOGIC_EDITORS (space-separated).
# logic_editors = ["operators", "alice"]
# name = "public"
# admins = ["alice"] # alice is a global admin
# [[groups]]
# name = "operations"
# operators = ["bob"]
# auditors = ["carol"]
# [[groups]]
# name = "engineers"
# parent = "operations" # bob inherits operator here
# logiceditors = ["dave"]
# viewers = ["erin"]
[datasource.epics]
enabled = true
+487
View File
@@ -0,0 +1,487 @@
import { h } from 'preact';
import { useState, useEffect, useCallback, useMemo } from 'preact/hooks';
// ── Types (mirror internal/access AccessSnapshot + the /admin/stats payload) ──
interface MemberInfo { user: string; role: string; }
interface GroupInfo {
name: string;
parent: string;
members: MemberInfo[];
}
interface UserInfo {
name: string;
effectiveRole: string;
roles: Record<string, string>; // group → role token
}
interface AccessSnapshot {
defaultUser: string;
publicGroup: string;
roles: string[]; // role ladder, low → high
configured: boolean;
users: UserInfo[];
groups: GroupInfo[];
}
interface ServerStats {
uptimeSeconds: number;
wsConnections: number;
observedSignals: number;
msgIn: number;
msgOut: number;
writeOps: number;
historyReqs: number;
goroutines: number;
memAllocBytes: number;
memSysBytes: number;
heapInuseBytes: number;
loadAvg: number[] | null;
dataSources: string[];
}
interface Props { onClose: () => void; }
const msg = (err: unknown): string => (err instanceof Error ? err.message : String(err));
async function apiJSON<T = any>(url: string, opts?: RequestInit): Promise<T | null> {
const res = await fetch(url, opts);
if (!res.ok && res.status !== 204) {
let 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();
}
const jsonPut = (body: any): RequestInit => ({ method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
const jsonPost = (body: any): RequestInit => ({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
const ROLE_LABELS: Record<string, string> = {
viewer: 'Viewer',
operator: 'Operator',
logiceditor: 'Logic editor',
auditor: 'Auditor',
admin: 'Admin',
};
const roleLabel = (r: string): string => ROLE_LABELS[r] ?? r;
/** Order group names so children follow their parent, and compute nesting depth. */
function orderGroups(groups: GroupInfo[]): { group: GroupInfo; depth: number }[] {
const byParent = new Map<string, GroupInfo[]>();
for (const g of groups) {
const key = g.parent || '';
(byParent.get(key) ?? byParent.set(key, []).get(key)!).push(g);
}
for (const list of byParent.values()) list.sort((a, b) => a.name.localeCompare(b.name));
const out: { group: GroupInfo; depth: number }[] = [];
const names = new Set(groups.map(g => g.name));
const walk = (parent: string, depth: number) => {
for (const g of byParent.get(parent) ?? []) {
out.push({ group: g, depth });
walk(g.name, depth + 1);
}
};
walk('', 0);
// Orphans (parent missing) rendered at root.
for (const g of groups) {
if (g.parent && !names.has(g.parent) && !out.some(o => o.group.name === g.name)) {
out.push({ group: g, depth: 0 });
}
}
return out;
}
export default function AdminPane({ onClose }: Props) {
const [tab, setTab] = useState<'users' | 'groups' | 'stats'>('users');
const [snap, setSnap] = useState<AccessSnapshot | null>(null);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
setError(null);
try {
setSnap(await apiJSON<AccessSnapshot>('/api/v1/admin/access'));
} catch (err) { setError(msg(err)); }
}, []);
useEffect(() => { load(); }, [load]);
return (
<div class="cl-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div class="cl-modal cl-modal-full">
<header class="cl-header">
<span class="cl-title">Admin</span>
<span class="hint cl-subtitle">Manage role-based access through group memberships, and view live server statistics.</span>
<div class="cl-header-actions">
<button class="panel-btn" onClick={onClose}>Close</button>
</div>
</header>
<div class="cfg-tabs">
<button class={`cfg-tab${tab === 'users' ? ' cfg-tab-active' : ''}`} onClick={() => setTab('users')}>Users</button>
<button class={`cfg-tab${tab === 'groups' ? ' cfg-tab-active' : ''}`} onClick={() => setTab('groups')}>Groups</button>
<button class={`cfg-tab${tab === 'stats' ? ' cfg-tab-active' : ''}`} onClick={() => setTab('stats')}>Server stats</button>
</div>
{error && <div class="cl-error">{error}</div>}
{snap && !snap.configured && tab !== 'stats' && (
<div class="hint admin-banner">
No roles assigned yet access is fully open (everyone is admin). Assigning any role
switches to strict mode where unlisted users are read-only viewers.
</div>
)}
{tab === 'users' && snap && <UsersManager snap={snap} onSnap={setSnap} onError={setError} />}
{tab === 'groups' && snap && <GroupsManager snap={snap} onSnap={setSnap} onError={setError} />}
{tab === 'stats' && <StatsView onError={setError} />}
</div>
</div>
);
}
interface ManagerProps {
snap: AccessSnapshot;
onSnap: (s: AccessSnapshot) => void;
onError: (e: string | null) => void;
}
// ── Users ─────────────────────────────────────────────────────────────────────
function UsersManager({ snap, onSnap, onError }: ManagerProps) {
const [selected, setSelected] = useState<string | null>(null);
// Draft maps group → role token for the selected user (only groups with a role).
const [draft, setDraft] = useState<Record<string, string> | null>(null);
const [newName, setNewName] = useState('');
const [busy, setBusy] = useState(false);
const ordered = useMemo(() => orderGroups(snap.groups), [snap.groups]);
function select(name: string) {
const u = snap.users.find(x => x.name === name);
setSelected(name);
setDraft({ ...(u?.roles ?? {}) });
}
function addUser() {
const n = newName.trim();
if (!n) return;
setNewName('');
select(n);
}
const setGroupRole = (group: string, role: string) => setDraft(d => {
const next = { ...(d ?? {}) };
if (role === '') delete next[group];
else next[group] = role;
return next;
});
async function save() {
if (!draft || !selected) return;
setBusy(true);
onError(null);
try {
const updated = await apiJSON<AccessSnapshot>(
`/api/v1/admin/users/${encodeURIComponent(selected)}`, jsonPut({ roles: draft }));
if (updated) onSnap(updated);
} catch (err) { onError(msg(err)); }
finally { setBusy(false); }
}
const effective = selected ? snap.users.find(u => u.name === selected)?.effectiveRole : undefined;
return (
<div class="cl-body">
<div class="cl-list">
<div class="cl-list-head">
<input class="audit-filter" type="text" placeholder="Add user…" value={newName}
onInput={(e) => setNewName((e.target as HTMLInputElement).value)}
onKeyDown={(e) => { if (e.key === 'Enter') addUser(); }} />
<button class="panel-btn" onClick={addUser}>Add</button>
</div>
{snap.users.length === 0 && <div class="hint cl-list-empty">No users with explicit roles. Everyone is a viewer of the public group.</div>}
{snap.users.map(u => (
<div key={u.name} class={`cl-list-item${selected === u.name ? ' cl-list-item-active' : ''}`} onClick={() => select(u.name)}>
<span class="cl-list-name" title={u.name}>{u.name}</span>
<span class={`admin-role-tag admin-role-${u.effectiveRole}`}>{roleLabel(u.effectiveRole)}</span>
</div>
))}
</div>
<div class="cl-editor-wrap">
{!draft && <div class="hint admin-empty">Select a user, or add one, to assign roles per group.</div>}
{draft && (
<div class="admin-editor">
<h3 class="admin-editor-title">
{selected}
{effective && <span class={`admin-role-tag admin-role-${effective}`}>{roleLabel(effective)}</span>}
</h3>
<p class="hint">Effective access is the highest role across all groups. Every user is at least a viewer via the public group.</p>
<label class="admin-field-label">Role per group</label>
<div class="admin-role-table">
{ordered.map(({ group, depth }) => (
<div key={group.name} class="admin-role-row">
<span class="admin-role-group" style={{ paddingLeft: `${depth * 1.1}rem` }}>
{depth > 0 && <span class="admin-tree-mark"> </span>}
{group.name}
{group.name === snap.publicGroup && <span class="hint"> (everyone)</span>}
</span>
<select class="prop-select admin-role-select"
value={draft[group.name] ?? ''}
onChange={(e) => setGroupRole(group.name, (e.target as HTMLSelectElement).value)}>
<option value=""> none </option>
{snap.roles.map(r => <option key={r} value={r}>{roleLabel(r)}</option>)}
</select>
</div>
))}
</div>
<div class="admin-editor-actions">
<button class="panel-btn" disabled={busy} onClick={save}>{busy ? 'Saving…' : 'Save'}</button>
</div>
</div>
)}
</div>
</div>
);
}
// ── Groups ──────────────────────────────────────────────────────────────────
function descendantsOf(groups: GroupInfo[], name: string): Set<string> {
const out = new Set<string>();
const walk = (parent: string) => {
for (const g of groups) {
if (g.parent === parent && !out.has(g.name)) {
out.add(g.name);
walk(g.name);
}
}
};
walk(name);
return out;
}
function GroupsManager({ snap, onSnap, onError }: ManagerProps) {
const [selected, setSelected] = useState<string | null>(null);
const [name, setName] = useState('');
const [parent, setParent] = useState('');
const [members, setMembers] = useState<MemberInfo[]>([]);
const [newMember, setNewMember] = useState('');
const [newGroup, setNewGroup] = useState('');
const [busy, setBusy] = useState(false);
const ordered = useMemo(() => orderGroups(snap.groups), [snap.groups]);
const isPublic = selected === snap.publicGroup;
function select(gname: string) {
const g = snap.groups.find(x => x.name === gname);
if (!g) return;
setSelected(gname);
setName(g.name);
setParent(g.parent);
setMembers(g.members.map(m => ({ ...m })));
setNewMember('');
}
// Parent options exclude the group itself and its descendants (cycle guard) and public.
const parentOptions = useMemo(() => {
if (!selected) return [];
const blocked = descendantsOf(snap.groups, selected);
blocked.add(selected);
return snap.groups.filter(g => !blocked.has(g.name)).map(g => g.name);
}, [snap.groups, selected]);
async function create() {
const n = newGroup.trim();
if (!n) return;
setBusy(true);
onError(null);
try {
const updated = await apiJSON<AccessSnapshot>('/api/v1/admin/groups', jsonPost({ name: n }));
if (updated) onSnap(updated);
setNewGroup('');
} catch (err) { onError(msg(err)); }
finally { setBusy(false); }
}
function addMember() {
const u = newMember.trim();
if (!u || members.some(m => m.user === u)) { setNewMember(''); return; }
setMembers(ms => [...ms, { user: u, role: 'operator' }]);
setNewMember('');
}
const setMemberRole = (user: string, role: string) =>
setMembers(ms => ms.map(m => m.user === user ? { ...m, role } : m));
const removeMember = (user: string) => setMembers(ms => ms.filter(m => m.user !== user));
async function save() {
if (!selected) return;
setBusy(true);
onError(null);
try {
const memberMap: Record<string, string> = {};
for (const m of members) memberMap[m.user] = m.role;
const body = { name: name.trim() || selected, parent: isPublic ? '' : parent, members: memberMap };
const updated = await apiJSON<AccessSnapshot>(
`/api/v1/admin/groups/${encodeURIComponent(selected)}`, jsonPut(body));
if (updated) { onSnap(updated); setSelected(body.name); }
} catch (err) { onError(msg(err)); }
finally { setBusy(false); }
}
async function remove() {
if (!selected || isPublic || !confirm(`Delete group "${selected}"? Members keep their other groups.`)) return;
setBusy(true);
onError(null);
try {
const updated = await apiJSON<AccessSnapshot>(
`/api/v1/admin/groups/${encodeURIComponent(selected)}`, { method: 'DELETE' });
if (updated) onSnap(updated);
setSelected(null);
} catch (err) { onError(msg(err)); }
finally { setBusy(false); }
}
return (
<div class="cl-body">
<div class="cl-list">
<div class="cl-list-head">
<input class="audit-filter" type="text" placeholder="New group…" value={newGroup}
onInput={(e) => setNewGroup((e.target as HTMLInputElement).value)}
onKeyDown={(e) => { if (e.key === 'Enter') create(); }} />
<button class="panel-btn" disabled={busy} onClick={create}>Create</button>
</div>
{ordered.map(({ group, depth }) => (
<div key={group.name} class={`cl-list-item${selected === group.name ? ' cl-list-item-active' : ''}`} onClick={() => select(group.name)}>
<span class="cl-list-name" title={group.name} style={{ paddingLeft: `${depth * 0.9}rem` }}>
{depth > 0 && <span class="admin-tree-mark"> </span>}
{group.name}
</span>
<span class="hint">{group.members.length}</span>
</div>
))}
</div>
<div class="cl-editor-wrap">
{!selected && <div class="hint admin-empty">Select a group, or create one, to manage its parent and member roles.</div>}
{selected && (
<div class="admin-editor">
<label class="admin-field-label">Name</label>
<input class="admin-input" type="text" value={name} disabled={isPublic}
onInput={(e) => setName((e.target as HTMLInputElement).value)} />
{isPublic && <p class="hint">The built-in public group cannot be renamed, reparented or deleted; every user is an implicit viewer member.</p>}
<label class="admin-field-label">Parent group (nesting)</label>
<select class="prop-select admin-input" value={parent} disabled={isPublic}
onChange={(e) => setParent((e.target as HTMLSelectElement).value)}>
<option value=""> none (top level) </option>
{parentOptions.map(p => <option key={p} value={p}>{p}</option>)}
</select>
{!isPublic && <p class="hint">Members of a parent group inherit their role on this group too, unless overridden here.</p>}
<label class="admin-field-label">Members</label>
<div class="admin-member-add">
<input class="admin-input" type="text" placeholder="Add member…" value={newMember}
onInput={(e) => setNewMember((e.target as HTMLInputElement).value)}
onKeyDown={(e) => { if (e.key === 'Enter') addMember(); }} />
<button class="panel-btn" onClick={addMember}>Add</button>
</div>
{members.length === 0 && <div class="hint">No explicit members.</div>}
<div class="admin-role-table">
{members.map(m => (
<div key={m.user} class="admin-role-row">
<span class="admin-role-group">{m.user}</span>
<select class="prop-select admin-role-select" value={m.role}
onChange={(e) => setMemberRole(m.user, (e.target as HTMLSelectElement).value)}>
{snap.roles.map(r => <option key={r} value={r}>{roleLabel(r)}</option>)}
</select>
<button class="admin-row-remove" title="Remove member" onClick={() => removeMember(m.user)}></button>
</div>
))}
</div>
<div class="admin-editor-actions">
<button class="panel-btn" disabled={busy} onClick={save}>{busy ? 'Saving…' : 'Save'}</button>
{!isPublic && <button class="panel-btn" disabled={busy} onClick={remove}>Delete</button>}
</div>
</div>
)}
</div>
</div>
);
}
// ── Server stats ─────────────────────────────────────────────────────────────
function fmtUptime(s: number): string {
s = Math.floor(s);
const d = Math.floor(s / 86400); s -= d * 86400;
const h = Math.floor(s / 3600); s -= h * 3600;
const m = Math.floor(s / 60); s -= m * 60;
const parts: string[] = [];
if (d) parts.push(`${d}d`);
if (h || d) parts.push(`${h}h`);
parts.push(`${m}m`, `${s}s`);
return parts.join(' ');
}
const fmtMB = (b: number): string => `${(b / (1024 * 1024)).toFixed(1)} MB`;
function StatsView({ onError }: { onError: (e: string | null) => void }) {
const [stats, setStats] = useState<ServerStats | null>(null);
useEffect(() => {
let live = true;
const load = async () => {
try {
const s = await apiJSON<ServerStats>('/api/v1/admin/stats');
if (live && s) setStats(s);
} catch (err) { if (live) onError(msg(err)); }
};
load();
const t = setInterval(load, 2000);
return () => { live = false; clearInterval(t); };
}, [onError]);
if (!stats) return <div class="cl-body"><div class="hint admin-empty">Loading</div></div>;
const rows: [string, string][] = [
['Uptime', fmtUptime(stats.uptimeSeconds)],
['WebSocket connections', String(stats.wsConnections)],
['Observed signals', String(stats.observedSignals)],
['Messages in', String(stats.msgIn)],
['Messages out', String(stats.msgOut)],
['Signal writes', String(stats.writeOps)],
['History requests', String(stats.historyReqs)],
['Goroutines', String(stats.goroutines)],
['Memory allocated', fmtMB(stats.memAllocBytes)],
['Memory from OS', fmtMB(stats.memSysBytes)],
['Heap in use', fmtMB(stats.heapInuseBytes)],
['Load average', stats.loadAvg ? stats.loadAvg.map(n => n.toFixed(2)).join(' / ') : '—'],
];
return (
<div class="cl-body">
<div class="admin-stats">
<table class="audit-table admin-stats-table">
<tbody>
{rows.map(([k, v]) => (
<tr key={k}><th class="admin-stat-key">{k}</th><td class="admin-stat-val">{v}</td></tr>
))}
</tbody>
</table>
<div class="admin-stats-ds">
<h4 class="admin-field-label">Data sources ({stats.dataSources.length})</h4>
<div class="admin-ds-list">
{stats.dataSources.map(d => <span key={d} class="admin-ds-tag">{d}</span>)}
</div>
</div>
</div>
</div>
);
}
+2
View File
@@ -4,6 +4,7 @@ import { wsClient } from './lib/ws';
import ViewMode from './ViewMode';
import EditMode from './EditMode';
import FullscreenMode from './FullscreenMode';
import ControlDialogs from './ControlDialogs';
import { applyZoom, getStoredZoom } from './ZoomControl';
import { AuthContext, DEFAULT_ME, fetchMe, canRead } from './lib/auth';
import type { Interface, Me } from './lib/types';
@@ -69,6 +70,7 @@ export default function App() {
return (
<AuthContext.Provider value={me}>
<div class="app-root">
<ControlDialogs />
{wsStatus === 'disconnected' && (
<div class="connection-banner">WebSocket disconnected reconnecting</div>
)}
+169
View File
@@ -0,0 +1,169 @@
import { h } from 'preact';
import { useState, useEffect, useCallback } from 'preact/hooks';
// AuditEvent mirrors the JSON returned by GET /api/v1/audit (internal/audit.Event).
interface AuditEvent {
time: string;
actor: string;
actorType: string; // "user" | "system"
action: string;
ds?: string;
signal?: string;
value?: string;
detail?: string;
ip?: string;
outcome: string; // "ok" | "error"
error?: string;
}
interface Props { onClose: () => void; }
/** Convert an RFC3339/ISO timestamp to a compact local datetime string. */
function fmtTime(iso: string): string {
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
/** Convert a datetime-local input value (local time) to an RFC3339 string. */
function localToRFC3339(v: string): string {
if (!v) return '';
const d = new Date(v);
return isNaN(d.getTime()) ? '' : d.toISOString();
}
export default function AuditViewer({ onClose }: Props) {
const [events, setEvents] = useState<AuditEvent[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Filters
const [user, setUser] = useState('');
const [action, setAction] = useState('');
const [signal, setSignal] = useState('');
const [start, setStart] = useState('');
const [end, setEnd] = useState('');
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const q = new URLSearchParams();
if (user.trim()) q.set('user', user.trim());
if (action) q.set('action', action);
if (signal.trim()) q.set('signal', signal.trim());
const s = localToRFC3339(start);
const e = localToRFC3339(end);
if (s) q.set('start', s);
if (e) q.set('end', e);
const res = await fetch(`/api/v1/audit?${q.toString()}`);
if (res.status === 403) throw new Error('You are not permitted to view the audit log.');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
setEvents(Array.isArray(data) ? data : []);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setEvents([]);
} finally {
setLoading(false);
}
}, [user, action, signal, start, end]);
// Initial load.
useEffect(() => { load(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
function resetFilters() {
setUser(''); setAction(''); setSignal(''); setStart(''); setEnd('');
}
return (
<div class="cl-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div class="cl-modal audit-modal">
<header class="cl-header">
<span class="cl-title">Audit log</span>
<span class="hint cl-subtitle">Every system-affecting action (signal writes, control-logic and interface changes).</span>
<div class="cl-header-actions">
<button class="panel-btn" disabled={loading} onClick={load}>{loading ? 'Loading…' : 'Refresh'}</button>
<button class="panel-btn" onClick={onClose}>Close</button>
</div>
</header>
<div class="audit-filters">
<input class="audit-filter" type="text" placeholder="User" value={user}
onInput={(e) => setUser((e.target as HTMLInputElement).value)} />
<select class="audit-filter" value={action}
onChange={(e) => setAction((e.target as HTMLSelectElement).value)}>
<option value="">All actions</option>
<option value="signal.write">signal.write</option>
<option value="interface.create">interface.create</option>
<option value="interface.update">interface.update</option>
<option value="interface.delete">interface.delete</option>
<option value="controllogic.create">controllogic.create</option>
<option value="controllogic.update">controllogic.update</option>
<option value="controllogic.delete">controllogic.delete</option>
</select>
<input class="audit-filter" type="text" placeholder="Signal contains…" value={signal}
onInput={(e) => setSignal((e.target as HTMLInputElement).value)} />
<label class="audit-filter-label">From
<input class="audit-filter" type="datetime-local" value={start}
onInput={(e) => setStart((e.target as HTMLInputElement).value)} />
</label>
<label class="audit-filter-label">To
<input class="audit-filter" type="datetime-local" value={end}
onInput={(e) => setEnd((e.target as HTMLInputElement).value)} />
</label>
<button class="panel-btn" disabled={loading} onClick={load}>Apply</button>
<button class="panel-btn" onClick={resetFilters}>Reset</button>
</div>
{error && <div class="cl-error">{error}</div>}
<div class="audit-body">
{!error && events.length === 0 && !loading && (
<div class="hint audit-empty">No matching audit entries.</div>
)}
{events.length > 0 && (
<table class="audit-table">
<thead>
<tr>
<th>Time</th>
<th>Actor</th>
<th>Action</th>
<th>Source</th>
<th>Signal / target</th>
<th>Value</th>
<th>Detail</th>
<th>Client</th>
<th>Result</th>
</tr>
</thead>
<tbody>
{events.map((ev, i) => (
<tr key={i} class={ev.outcome === 'error' ? 'audit-row-error' : ''}>
<td class="audit-time">{fmtTime(ev.time)}</td>
<td>
<span class={`audit-actor-tag audit-actor-${ev.actorType}`}>{ev.actorType}</span>
{' '}{ev.actor || '—'}
</td>
<td>{ev.action}</td>
<td>{ev.ds || '—'}</td>
<td class="audit-signal" title={ev.signal}>{ev.signal || '—'}</td>
<td class="audit-value" title={ev.value}>{ev.value ?? '—'}</td>
<td class="audit-detail" title={ev.detail}>{ev.detail || '—'}</td>
<td>{ev.ip || '—'}</td>
<td>
{ev.outcome === 'error'
? <span class="audit-result-err" title={ev.error}>error</span>
: <span class="audit-result-ok">ok</span>}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
</div>
);
}
+57 -2
View File
@@ -12,14 +12,19 @@ import BarV from './widgets/BarV';
import Led from './widgets/Led';
import MultiLed from './widgets/MultiLed';
import SetValue from './widgets/SetValue';
import Toggle from './widgets/Toggle';
import Button from './widgets/Button';
import PlotWidget from './widgets/PlotWidget';
import ImageWidget from './widgets/ImageWidget';
import LinkWidget from './widgets/LinkWidget';
import ConfigSelect from './widgets/ConfigSelect';
import Container from './widgets/Container';
import TableWidget from './widgets/TableWidget';
import ContextMenu from './ContextMenu';
import InfoPanel from './InfoPanel';
import SplitLayout from './SplitLayout';
import LogicDialogs from './LogicDialogs';
import { centreInside, widgetTab, tabPanes } from './lib/containers';
const COMPONENTS: Record<string, any> = {
textview: TextView,
@@ -30,12 +35,24 @@ const COMPONENTS: Record<string, any> = {
led: Led,
multiled: MultiLed,
setvalue: SetValue,
toggle: Toggle,
button: Button,
plot: PlotWidget,
image: ImageWidget,
link: LinkWidget,
configselect: ConfigSelect,
container: Container,
table: TableWidget,
};
// Container panes are decorative frames placed behind other widgets; render them
// first so they paint underneath.
function renderOrder(widgets: Widget[]): Widget[] {
const containers = widgets.filter(w => w.type === 'container');
const rest = widgets.filter(w => w.type !== 'container');
return [...containers, ...rest];
}
interface CtxState {
visible: boolean;
x: number;
@@ -80,6 +97,26 @@ export default function Canvas({ iface, onNavigate, timeRange }: Props) {
visible: false, x: 0, y: 0, signal: null,
});
const [infoSignal, setInfoSignal] = useState<SignalRef | null>(null);
// View-mode collapse state for collapsible container panes (ephemeral, by id).
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set());
// View-mode active-tab state for tabbed container panes (ephemeral, by id).
const [activeTabs, setActiveTabs] = useState<Map<string, number>>(() => new Map());
function toggleCollapse(id: string) {
setCollapsed(prev => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
}
function selectTab(id: string, i: number) {
setActiveTabs(prev => {
const next = new Map(prev);
next.set(id, i);
return next;
});
}
// Instantiate this panel's local state variables from their initial values.
useEffect(() => {
@@ -153,10 +190,24 @@ export default function Canvas({ iface, onNavigate, timeRange }: Props) {
);
}
// Collapsible panes currently in the collapsed state — their contents are hidden.
const collapsedPanes = iface.widgets.filter(
w => w.type === 'container' && w.options['collapsible'] === 'true' && collapsed.has(w.id),
);
// Tabbed panes — widgets inside that aren't on the active tab are hidden.
const tPanes = tabPanes(iface.widgets);
return (
<div class="canvas-container">
<div class="canvas-area" style={`width:${iface.w}px;height:${iface.h}px;`}>
{iface.widgets.map(widget => {
<div class="canvas-area canvas-view-bare" style={`width:${iface.w}px;height:${iface.h}px;`}>
{renderOrder(iface.widgets).map(widget => {
// Hide widgets that fall inside a currently-collapsed container pane.
const hidden = collapsedPanes.some(c => c.id !== widget.id && centreInside(widget, c));
// Hide widgets inside a tabbed pane that aren't on its active tab.
const tabHidden = tPanes.some(c =>
c.id !== widget.id && centreInside(widget, c) && widgetTab(widget) !== (activeTabs.get(c.id) ?? 0),
);
if (hidden || tabHidden) return null;
const Comp = COMPONENTS[widget.type];
const inner = Comp
? <Comp
@@ -164,6 +215,10 @@ export default function Canvas({ iface, onNavigate, timeRange }: Props) {
onContextMenu={(e: MouseEvent) => onCtxMenu(e, widget)}
onNavigate={onNavigate}
timeRange={timeRange}
collapsed={collapsed.has(widget.id)}
onToggleCollapse={() => toggleCollapse(widget.id)}
activeTab={activeTabs.get(widget.id) ?? 0}
onSelectTab={(i: number) => selectTab(widget.id, i)}
/>
: (
<div
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>
);
}
File diff suppressed because it is too large Load Diff
+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>
);
}
+67 -10
View File
@@ -9,19 +9,33 @@ import BarV from './widgets/BarV';
import Led from './widgets/Led';
import MultiLed from './widgets/MultiLed';
import SetValue from './widgets/SetValue';
import Toggle from './widgets/Toggle';
import Button from './widgets/Button';
import PlotWidget from './widgets/PlotWidget';
import ImageWidget from './widgets/ImageWidget';
import LinkWidget from './widgets/LinkWidget';
import Container from './widgets/Container';
import TableWidget from './widgets/TableWidget';
import WidgetTypePicker from './WidgetTypePicker';
import { centreInside, widgetTab, tabPanes, withContainedWidgets } from './lib/containers';
const COMPONENTS: Record<string, any> = {
textview: TextView, textlabel: TextLabel, gauge: Gauge,
barh: BarH, barv: BarV, led: Led, multiled: MultiLed,
setvalue: SetValue, button: Button, plot: PlotWidget,
image: ImageWidget, link: LinkWidget,
setvalue: SetValue, toggle: Toggle, button: Button, plot: PlotWidget,
image: ImageWidget, link: LinkWidget, container: Container,
table: TableWidget,
};
// Container panes are decorative frames that other widgets sit over, so they
// must paint behind everything else. Render them first (their relative order
// preserved) regardless of their position in the widget array.
function renderOrder(widgets: Widget[]): Widget[] {
const containers = widgets.filter(w => w.type === 'container');
const rest = widgets.filter(w => w.type !== 'container');
return [...containers, ...rest];
}
export const DEFAULT_SIZES: Record<string, [number, number]> = {
textview: [200, 50],
textlabel: [150, 36],
@@ -31,16 +45,20 @@ export const DEFAULT_SIZES: Record<string, [number, number]> = {
led: [60, 60],
multiled: [200, 80],
setvalue: [200, 60],
toggle: [140, 50],
button: [120, 50],
plot: [400, 200],
image: [200, 150],
link: [140, 50],
configselect: [220, 50],
container: [320, 220],
table: [280, 160],
};
// Widget types that support multiple signals (signal can be appended)
const MULTI_SIGNAL_TYPES = new Set(['plot', 'multiled']);
const MULTI_SIGNAL_TYPES = new Set(['plot', 'multiled', 'table']);
// Widget types that have exactly one signal (signal can be replaced)
const SINGLE_SIGNAL_TYPES = new Set(['textview', 'gauge', 'barh', 'barv', 'led', 'setvalue', 'button']);
const SINGLE_SIGNAL_TYPES = new Set(['textview', 'gauge', 'barh', 'barv', 'led', 'setvalue', 'toggle', 'button']);
interface PickerState {
visible: boolean;
@@ -131,6 +149,17 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna
const [signalDropMenu, setSignalDropMenu] = useState<SignalDropMenuState | null>(null);
// Which tab is being edited per tabbed container (ephemeral). Widgets not on
// the active tab are hidden so each tab's content can be laid out separately.
const [activeTabs, setActiveTabs] = useState<Map<string, number>>(() => new Map());
function selectTab(id: string, i: number) {
setActiveTabs(prev => {
const next = new Map(prev);
next.set(id, i);
return next;
});
}
useEffect(() => {
function onMouseMove(e: MouseEvent) {
const grid = snapGridRef.current;
@@ -247,8 +276,10 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna
const { cx, cy } = getCanvasPos(e);
// Check if drop lands on an existing widget
const hit = iface.widgets.find(w =>
// Check if drop lands on an existing widget. Ignore container panes so a
// signal dropped onto a widget that overlaps a pane still targets the widget
// (and a drop on bare pane area creates a new widget there).
const hit = iface.widgets.filter(w => w.type !== 'container').find(w =>
cx >= w.x && cx <= w.x + w.w && cy >= w.y && cy <= w.y + w.h
);
@@ -302,6 +333,12 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna
signals: [picker.signal],
options: {},
};
// If dropped inside a tabbed container, assign it to the active tab so it
// shows up alongside that tab's other content.
if (type !== 'container') {
const host = tPanes.find(c => centreInside(newWidget, c));
if (host) newWidget.options.tab = String(activeTabs.get(host.id) ?? 0);
}
onChange([...iface.widgets, newWidget]);
onSelect([newWidget.id]);
setPicker(p => ({ ...p, visible: false }));
@@ -324,7 +361,11 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna
if (ctrl && isSelected) return;
const movers = nextIds.includes(widget.id) ? nextIds : [widget.id];
// Dragging a container also drags the widgets sitting on it (snapshotted now).
const movers = withContainedWidgets(
nextIds.includes(widget.id) ? nextIds : [widget.id],
iface.widgets,
);
dragRef.current = {
startMouseX: e.clientX,
startMouseY: e.clientY,
@@ -354,6 +395,15 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna
onSelect(selectedIds.filter(s => s !== id));
}
// A widget is hidden in the editor when it sits inside a tabbed container but
// belongs to a tab other than the one currently being edited.
const tPanes = tabPanes(iface.widgets);
function tabHidden(widget: Widget): boolean {
return tPanes.some(c =>
c.id !== widget.id && centreInside(widget, c) && widgetTab(widget) !== (activeTabs.get(c.id) ?? 0),
);
}
return (
<div
class="edit-canvas-container"
@@ -364,10 +414,16 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna
>
<div class="canvas-area" style={`width:${iface.w}px;height:${iface.h}px;`}>
{(iface.widgets || []).map(widget => {
{renderOrder(iface.widgets || []).map(widget => {
if (tabHidden(widget)) return null;
const Comp = COMPONENTS[widget.type];
return Comp
? <Comp key={widget.id} widget={widget} />
? <Comp
key={widget.id}
widget={widget}
activeTab={activeTabs.get(widget.id) ?? 0}
onSelectTab={(i: number) => selectTab(widget.id, i)}
/>
: (
<div key={widget.id} class="unknown-widget"
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}>
@@ -376,7 +432,8 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna
);
})}
{(iface.widgets || []).map(widget => {
{renderOrder(iface.widgets || []).map(widget => {
if (tabHidden(widget)) return null;
const isSelected = selectedIds.includes(widget.id);
return (
+130 -114
View File
@@ -8,28 +8,46 @@ import EditCanvas, { genWidgetId, DEFAULT_SIZES } from './EditCanvas';
import PlotPanelCanvas from './PlotPanelCanvas';
import LogicEditor from './LogicEditor';
import PropertiesPane from './PropertiesPane';
import ContextualHelp from './ContextualHelp';
import HelpModal from './HelpModal';
import ZoomControl from './ZoomControl';
import { useAuth, canWrite } from './lib/auth';
import { withContainedWidgets } from './lib/containers';
import { VersionTree, DiffViewer } from './VersionHistory';
interface Props {
initial: Interface | null;
onDone: (iface: Interface | null) => void;
}
interface VersionMeta {
version: number;
name: string;
tag?: string;
current: boolean;
savedAt: string;
}
function blankInterface(): Interface {
return { id: '', name: 'New Interface', version: 1, w: 1200, h: 800, widgets: [] };
}
// Rename a widget id everywhere it is referenced so logic actions and plot
// layouts keep pointing at the same widget after the user customizes its id.
function renameInLayout(l: PlotLayout, oldId: string, newId: string): PlotLayout {
if (l.type === 'leaf') return l.widget === oldId ? { ...l, widget: newId } : l;
return { ...l, a: renameInLayout(l.a, oldId, newId), b: renameInLayout(l.b, oldId, newId) };
}
function renameWidgetId(f: Interface, oldId: string, newId: string): Interface {
const next: Interface = {
...f,
widgets: (f.widgets || []).map(w => (w.id === oldId ? { ...w, id: newId } : w)),
};
if (f.layout) next.layout = renameInLayout(f.layout, oldId, newId);
if (f.logic) {
next.logic = {
...f.logic,
nodes: f.logic.nodes.map(n =>
n.kind === 'action.widget' && n.params.widget === oldId
? { ...n, params: { ...n.params, widget: newId } }
: n),
};
}
return next;
}
// ── Align / distribute helpers ──────────────────────────────────────────────
function alignWidgets(widgets: Widget[], ids: string[], mode: string): Widget[] {
@@ -84,6 +102,9 @@ const STATIC_WIDGET_TYPES = [
{ type: 'button', label: 'Action Button' },
{ type: 'image', label: 'Image' },
{ type: 'link', label: 'Link' },
{ type: 'configselect', label: 'Config Selector' },
{ type: 'container', label: 'Container Pane' },
{ type: 'table', label: 'Table' },
];
export default function EditMode({ initial, onDone }: Props) {
@@ -97,6 +118,7 @@ export default function EditMode({ initial, onDone }: Props) {
const [snapGrid, setSnapGrid] = useState(10);
const [showSnapGrid, setShowSnapGrid] = useState(true);
const [showInsertMenu, setShowInsertMenu] = useState(false);
const [showGroupMenu, setShowGroupMenu] = useState(false);
const [showHelp, setShowHelp] = useState(false);
const [helpSection, setHelpSection] = useState('edit');
const [leftW, setLeftW] = useState(220);
@@ -106,8 +128,9 @@ export default function EditMode({ initial, onDone }: Props) {
// History panel
const [showHistory, setShowHistory] = useState(false);
const [versions, setVersions] = useState<VersionMeta[]>([]);
const [historyLoading, setHistoryLoading] = useState(false);
const [viewingVersion, setViewingVersion] = useState<number | null>(null);
const [diff, setDiff] = useState<{ av: number; bv: number } | null>(null);
const [verReload, setVerReload] = useState(0);
const [tag, setTag] = useState('');
// Bumped whenever the editor content is replaced wholesale (version load /
// promote) to force a full canvas remount, so no stale widget state lingers.
@@ -201,6 +224,13 @@ export default function EditMode({ initial, onDone }: Props) {
setDirty(true);
}, [pushUndo]);
const handleWidgetRename = useCallback((oldId: string, newId: string) => {
pushUndo();
setIface(f => renameWidgetId(f, oldId, newId));
setSelectedIds(ids => ids.map(id => id === oldId ? newId : id));
setDirty(true);
}, [pushUndo]);
const handleIfaceChange = useCallback((updated: Interface) => {
setIface(updated);
setDirty(true);
@@ -290,8 +320,10 @@ export default function EditMode({ initial, onDone }: Props) {
const step = e.shiftKey ? (snapGrid || 10) * 2 : (snapGrid || 1);
const dx = e.key === 'ArrowLeft' ? -step : e.key === 'ArrowRight' ? step : 0;
const dy = e.key === 'ArrowUp' ? -step : e.key === 'ArrowDown' ? step : 0;
// Nudging a container also nudges the widgets sitting on it.
const movers = withContainedWidgets(selectedIds, curWidgets);
handleWidgetsChange(curWidgets.map(w =>
selectedIds.includes(w.id) ? { ...w, x: w.x + dx, y: w.y + dy } : w
movers.includes(w.id) ? { ...w, x: w.x + dx, y: w.y + dy } : w
));
}
}, [selectedIds, snapGrid, undo, redo, handleWidgetsChange, openHelp, pushUndo, iface.kind, centerTab]);
@@ -313,6 +345,43 @@ export default function EditMode({ initial, onDone }: Props) {
handleWidgetsChange(distributed);
}, [iface.widgets, selectedIds, handleWidgetsChange]);
// ── Group selection into a container ────────────────────────────────────────
// Wrap the selected widgets in a new container sized to their bounding box.
// The container is appended so it paints behind the grouped widgets (the
// canvas always renders containers first); membership is geometric, so simply
// enclosing the widgets places them inside. Extra top inset leaves room for
// the title / tab bar above the grouped content.
const groupIntoContainer = useCallback((variant: 'pane' | 'tabs') => {
setShowGroupMenu(false);
const widgets = iface.widgets || [];
const sel = widgets.filter(w => selectedIds.includes(w.id));
if (sel.length < 2) return;
const minX = Math.min(...sel.map(w => w.x));
const minY = Math.min(...sel.map(w => w.y));
const maxX = Math.max(...sel.map(w => w.x + w.w));
const maxY = Math.max(...sel.map(w => w.y + w.h));
const PAD = 12;
const TOP = 36; // room for the title / tab bar above the grouped widgets
const container: Widget = {
id: genWidgetId(),
type: 'container',
x: minX - PAD,
y: minY - TOP,
w: (maxX - minX) + PAD * 2,
h: (maxY - minY) + TOP + PAD,
signals: [],
options: variant === 'tabs'
? { variant: 'tabs', tabs: 'Tab 1,Tab 2', bg: 'true' }
: { variant: 'pane', title: 'Group', collapsible: 'false', bg: 'true' },
};
// In tab mode, place the grouped widgets on the first tab so they show by default.
const rest = variant === 'tabs'
? widgets.map(w => selectedIds.includes(w.id) ? { ...w, options: { ...w.options, tab: '0' } } : w)
: widgets;
handleWidgetsChange([...rest, container]);
setSelectedIds([container.id]);
}, [iface.widgets, selectedIds, handleWidgetsChange]);
// ── Insert static widget ───────────────────────────────────────────────────
const insertWidget = useCallback((type: string) => {
@@ -329,6 +398,9 @@ export default function EditMode({ initial, onDone }: Props) {
options:
type === 'textlabel' ? { label: 'Label' } :
type === 'button' ? { label: 'Button', mode: 'oneshot' } :
type === 'configselect' ? { label: 'Config', set: '', output: '', instances: '' } :
type === 'container' ? { title: 'Pane', collapsible: 'false', bg: 'true' } :
type === 'table' ? { columns: 'name,value,unit', header: 'true' } :
{},
};
handleWidgetsChange([...(iface.widgets || []), newWidget]);
@@ -360,7 +432,8 @@ export default function EditMode({ initial, onDone }: Props) {
setDirty(false);
setTag('');
window.dispatchEvent(new CustomEvent('uopi:refresh-interfaces'));
if (showHistory) loadVersions();
setViewingVersion(null);
setVerReload(k => k + 1);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
@@ -371,26 +444,12 @@ export default function EditMode({ initial, onDone }: Props) {
// ── Version history ────────────────────────────────────────────────────────
const loadVersions = useCallback(async () => {
if (!iface.id) { setVersions([]); return; }
setHistoryLoading(true);
try {
const res = await fetch(`/api/v1/interfaces/${encodeURIComponent(iface.id)}/versions`);
if (!res.ok) throw new Error(`Load versions failed (${res.status})`);
setVersions(await res.json());
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setHistoryLoading(false);
}
}, [iface.id]);
const toggleHistory = useCallback(() => {
setShowHistory(s => {
if (!s) loadVersions();
if (!s) setVerReload(k => k + 1);
return !s;
});
}, [loadVersions]);
}, []);
// Load a past revision into the editor non-destructively: the current id is
// preserved, so saving the restored content creates a new revision on top.
@@ -404,45 +463,26 @@ export default function EditMode({ initial, onDone }: Props) {
restored.id = iface.id; // keep editing the same interface
setIface(restored);
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);
undoStack.current = [];
redoStack.current = [];
setHistoryLen(0);
setViewingVersion(version);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
}, [iface.id, dirty]);
// Edit (or clear) the label of an existing revision in place.
const editVersionTag = useCallback(async (version: number, currentTag: string) => {
// Reload the now-current interface content into the editor (after a promote,
// which the VersionTree performs server-side).
const reloadCurrentIface = useCallback(async () => {
if (!iface.id) return;
const next = prompt('Label for this version (leave empty to clear):', currentTag ?? '');
if (next === null) return;
try {
const res = await fetch(
`/api/v1/interfaces/${encodeURIComponent(iface.id)}/versions/${version}/tag?tag=${encodeURIComponent(next)}`,
{ method: 'PUT' },
);
if (!res.ok) throw new Error(`Tag update failed (${res.status})`);
loadVersions();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
}, [iface.id, loadVersions]);
// Make a past revision the current one (saved as a new revision on top).
const promoteVersion = useCallback(async (version: number) => {
if (!iface.id) return;
if (dirty && !confirm('You have unsaved changes that will be discarded. Set this version as current?')) return;
try {
const res = await fetch(
`/api/v1/interfaces/${encodeURIComponent(iface.id)}/versions/${version}/promote`,
{ method: 'POST' },
);
if (!res.ok) throw new Error(`Promote failed (${res.status})`);
// Reload the now-current interface content into the editor.
const cur = await fetch(`/api/v1/interfaces/${encodeURIComponent(iface.id)}`);
if (!cur.ok) throw new Error(`Reload failed (${cur.status})`);
const reloaded = parseInterface(await cur.text());
setIface(reloaded);
setSelectedIds([]);
@@ -451,25 +491,9 @@ export default function EditMode({ initial, onDone }: Props) {
undoStack.current = [];
redoStack.current = [];
setHistoryLen(0);
setViewingVersion(null);
window.dispatchEvent(new CustomEvent('uopi:refresh-interfaces'));
loadVersions();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
}, [iface.id, dirty, loadVersions]);
// Fork a revision into a brand-new interface.
const forkVersion = useCallback(async (version: number) => {
if (!iface.id) return;
try {
const res = await fetch(
`/api/v1/interfaces/${encodeURIComponent(iface.id)}/versions/${version}/fork`,
{ method: 'POST' },
);
if (!res.ok) throw new Error(`Fork failed (${res.status})`);
const json = await res.json();
window.dispatchEvent(new CustomEvent('uopi:refresh-interfaces'));
alert(`Forked into new interface "${json.id}". Open it from the interface list.`);
setVerReload(k => k + 1);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
@@ -618,6 +642,16 @@ export default function EditMode({ initial, onDone }: Props) {
<button class="toolbar-btn icon-only" onClick={() => doDistribute('v')} title="Distribute V"></button>
</div>
)}
<span class="toolbar-sep" />
<div class="toolbar-dropdown">
<button class="toolbar-btn" onClick={() => setShowGroupMenu(m => !m)} title="Group selection into a container"> Group </button>
{showGroupMenu && (
<div class="toolbar-dropdown-menu" onClick={() => setShowGroupMenu(false)}>
<button class="ctx-item" onClick={() => groupIntoContainer('pane')}>Into Pane</button>
<button class="ctx-item" onClick={() => groupIntoContainer('tabs')}>Into Tabs</button>
</div>
)}
</div>
</div>
)}
@@ -626,7 +660,6 @@ export default function EditMode({ initial, onDone }: Props) {
<div class="toolbar-right">
<ZoomControl />
<ContextualHelp mode="edit" onOpenManual={openHelp} />
<button class="icon-btn help-manual-btn" onClick={() => openHelp('edit')} title="Open user manual">📖</button>
<button class="toolbar-btn" onClick={handleImport}>Import</button>
<button class="toolbar-btn" onClick={handleExport}>Export</button>
@@ -713,6 +746,7 @@ export default function EditMode({ initial, onDone }: Props) {
multiCount={multiSelected ? selectedIds.length : 0}
iface={iface}
onChange={handleWidgetChange}
onRenameId={handleWidgetRename}
onIfaceChange={handleIfaceChange}
width={rightW}
/>
@@ -735,49 +769,31 @@ export default function EditMode({ initial, onDone }: Props) {
Save snapshot
</button>
</div>
<div class="history-list">
{historyLoading && <div class="history-empty">Loading</div>}
{!historyLoading && versions.length === 0 && (
<div class="history-empty">No saved versions yet.</div>
)}
{!historyLoading && versions.map(v => (
<div
key={v.version}
class={`history-item${v.current ? ' history-item-current' : ''}`}
>
<div class="history-item-main">
<span class="history-version">v{v.version}</span>
{v.tag && <span class="history-tag">{v.tag}</span>}
{v.current && <span class="history-current-badge">current</span>}
</div>
<div class="history-item-meta">
{new Date(v.savedAt).toLocaleString()}
</div>
<div class="history-item-actions">
{!v.current && (
<button class="history-action-btn" onClick={() => loadVersion(v.version)} title="Load this version into the editor (does not change history)">
Load
</button>
)}
{!v.current && (
<button class="history-action-btn" onClick={() => promoteVersion(v.version)} title="Make this version the current one">
Set current
</button>
)}
<button class="history-action-btn" onClick={() => forkVersion(v.version)} title="Create a new interface from this version">
Fork
</button>
<button class="history-action-btn" onClick={() => editVersionTag(v.version, v.tag ?? '')} title="Edit this version's label">
Label
</button>
</div>
</div>
))}
</div>
{!iface.id ? (
<div class="history-empty">Save the interface first to enable history.</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} />
)}
</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 && (
<HelpModal initialSection={helpSection} onClose={() => setShowHelp(false)} />
)}
+107 -5
View File
@@ -403,6 +403,7 @@ function DiagramSignalTree() {
const SECTIONS = [
{ id: 'start', label: 'Getting Started' },
{ id: 'tips', label: 'Quick Tips' },
{ id: 'view', label: 'View Mode' },
{ id: 'edit', label: 'Edit Mode' },
{ id: 'widgets', label: 'Widgets' },
@@ -411,10 +412,68 @@ const SECTIONS = [
{ id: 'logic', label: 'Panel Logic' },
{ id: 'control', label: 'Control Logic' },
{ id: 'sharing', label: 'Sharing & Access' },
{ id: 'audit', label: 'Audit Log' },
{ id: 'history', label: 'Historical Data' },
{ id: 'shortcuts', label: 'Keyboard Shortcuts' },
];
// Quick tips, grouped by the mode they apply to. Each links to the manual
// section that covers it in depth. (Previously surfaced via a floating
// contextual-help popover; now consolidated here in the manual.)
const TIPS: Record<'view' | 'edit', Array<{ text: string; section: string }>> = {
view: [
{ text: 'Click any interface in the left panel to load it on the canvas.', section: 'view' },
{ text: 'Right-click a widget to view signal info or export data as CSV.', section: 'view' },
{ text: 'The ● Live button shows live streaming is active. Use the date pickers to load historical data.', section: 'history' },
{ text: 'Click Edit (top-right) to open the drag-and-drop panel builder.', section: 'edit' },
{ text: 'Use the Share button on a panel to grant access to users or groups.', section: 'sharing' },
{ text: 'The ⚙ Control logic button opens server-side automation that runs even with no panel open.', section: 'control' },
{ text: 'The 🛡 Audit button (for permitted users) opens the log of system-affecting actions.', section: 'audit' },
{ text: 'The status chip turns green and shows your username when the WebSocket is connected.', section: 'view' },
],
edit: [
{ text: 'Drag a signal from the left tree onto the canvas to create a widget.', section: 'edit' },
{ text: 'Click a widget to select it, then adjust its properties on the right.', section: 'edit' },
{ text: 'Ctrl+click to select multiple widgets; then use the align toolbar.', section: 'edit' },
{ text: 'Ctrl+Z to undo, Ctrl+Y to redo. Up to 50 steps are remembered.', section: 'shortcuts' },
{ text: 'Use "+ Synthetic" in the signal tree to create computed/filtered signals.', section: 'signals' },
{ text: 'Switch to the Logic tab to add interactive behaviour with a node-graph flow editor.', section: 'logic' },
{ text: 'Add Local variables for set-points and counters that live inside the panel.', section: 'edit' },
{ text: 'Arrow keys nudge the selected widget by 1 px (Shift for grid size).', section: 'shortcuts' },
],
};
function SectionTips({ onNavigate }: { onNavigate: (section: string) => void }) {
const groups: Array<['view' | 'edit', string]> = [
['view', 'In View mode'],
['edit', 'In Edit mode'],
];
return (
<div>
<p class="help-lead">
A handful of quick pointers to get the most out of uopi. Click any tip to jump to the
section that explains it in detail.
</p>
{groups.map(([mode, title]) => (
<div key={mode}>
<h3 class="help-h3">{title}</h3>
<ul class="help-tips-list">
{TIPS[mode].map(tip => (
<li key={tip.text}>
<button class="help-tip-item" onClick={() => onNavigate(tip.section)}>
<span class="help-tip-icon">💡</span>
<span class="help-tip-text">{tip.text}</span>
<span class="help-tip-go"></span>
</button>
</li>
))}
</ul>
</div>
))}
</div>
);
}
function SectionStart() {
return (
<div>
@@ -481,8 +540,10 @@ function SectionView() {
<h3 class="help-h3">Connection status</h3>
<p>
The coloured chip in the top-right shows the WebSocket connection state.
A red <em>"disconnected"</em> banner appears at the top while reconnecting live values will resume automatically.
The coloured chip in the top-right shows the WebSocket connection state. When connected it
reads <em>"connected as &lt;your username&gt;"</em>, so you can confirm the identity your
actions are attributed to in the audit log. A red <em>"disconnected"</em> banner appears at
the top while reconnecting live values will resume automatically.
</p>
</div>
);
@@ -677,6 +738,44 @@ function SectionSharing() {
);
}
function SectionAudit() {
return (
<div>
<p class="help-lead">
When the server is configured with <code>[audit] enabled = true</code>, every
<strong> system-affecting action</strong> is appended to an append-only log so it can be
reviewed later. Permitted users open it with the <strong>🛡 Audit</strong> button in the
view-mode toolbar.
</p>
<h3 class="help-h3">What gets recorded</h3>
<ul class="help-list">
<li><strong>Signal writes</strong> — both user-initiated (set-value widgets, buttons, panel
logic) and automated writes from the control-logic engine.</li>
<li><strong>Interface changes</strong> — panels created, updated, or deleted.</li>
<li><strong>Control-logic changes</strong> — graphs created, updated, or deleted.</li>
</ul>
<p>Each entry records the time, the actor (username, or the graph name for automated
actions), the action, the affected data source / signal and value, the client address, and
whether it succeeded.</p>
<h3 class="help-h3">Viewing the log</h3>
<ul class="help-list">
<li>The viewer lists the most recent events first.</li>
<li>Filter by user, action, signal, or a start/end time range.</li>
<li>System (automated) actions are tagged distinctly from user actions.</li>
</ul>
<p class="help-callout">
Access to the audit log is gated by an allowlist (<code>audit.readers</code> in the config),
following the same model as logic editing: an empty list means every user may view it,
otherwise only the listed users or groups can. The 🛡 Audit button is hidden for users who
are not permitted.
</p>
</div>
);
}
function SectionWidgets() {
return (
<div>
@@ -858,8 +957,9 @@ function SectionShortcuts() {
);
}
const SECTION_COMPONENTS: Record<string, () => JSX.Element> = {
const SECTION_COMPONENTS: Record<string, (props: { onNavigate: (section: string) => void }) => JSX.Element> = {
start: SectionStart,
tips: SectionTips,
view: SectionView,
edit: SectionEdit,
widgets: SectionWidgets,
@@ -868,6 +968,7 @@ const SECTION_COMPONENTS: Record<string, () => JSX.Element> = {
logic: SectionLogic,
control: SectionControl,
sharing: SectionSharing,
audit: SectionAudit,
history: SectionHistory,
shortcuts: SectionShortcuts,
};
@@ -877,7 +978,8 @@ const SECTION_COMPONENTS: Record<string, () => JSX.Element> = {
export default function HelpModal({ initialSection = 'start', onClose }: Props) {
const [active, setActive] = useState(initialSection);
const Content = SECTION_COMPONENTS[active] ?? SectionStart;
const Content: (props: { onNavigate: (section: string) => void }) => JSX.Element =
SECTION_COMPONENTS[active] ?? SectionStart;
// Close on backdrop click
function handleBackdrop(e: MouseEvent) {
@@ -915,7 +1017,7 @@ export default function HelpModal({ initialSection = 'start', onClose }: Props)
{/* Content area */}
<div class="help-content">
<h2 class="help-h2">{SECTIONS.find(s => s.id === active)?.label}</h2>
<Content />
<Content onNavigate={setActive} />
</div>
</div>
</div>
+30 -3
View File
@@ -15,6 +15,30 @@ interface Props {
onToggleCollapse: () => void;
}
// Monochrome (currentColor) panel-kind glyph. Inline SVG avoids font-dependent
// emoji that may render in colour or as a missing-glyph box.
function KindIcon({ kind }: { kind?: 'panel' | 'plot' }) {
const plot = kind === 'plot';
return (
<span class="iface-item-icon" title={plot ? 'Plot panel' : 'HMI panel'}>
{plot ? (
<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M2.5 2.5 v11 h11" />
<path d="M4.5 11 L7 7.5 L9 9.5 L13 4.5" />
</svg>
) : (
<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="2" y="2.5" width="12" height="11" rx="1.5" />
<path d="M4.5 6 h7" />
<circle cx="6" cy="6" r="1.2" fill="currentColor" stroke="none" />
<path d="M4.5 10 h7" />
<circle cx="10" cy="10" r="1.2" fill="currentColor" stroke="none" />
</svg>
)}
</span>
);
}
export default function InterfaceList({ onLoad, onSelect, onEdit, onNewPlot, onEditId, width, collapsed, onToggleCollapse }: Props) {
const [interfaces, setInterfaces] = useState<InterfaceListItem[]>([]);
const [folders, setFolders] = useState<Folder[]>([]);
@@ -87,6 +111,7 @@ export default function InterfaceList({ onLoad, onSelect, onEdit, onNewPlot, onE
id: String(item.id || item.ID || ''),
name: String(item.name || item.Name || ''),
version: Number(item.version || item.Version || 0),
kind: item.kind === 'plot' ? ('plot' as const) : undefined,
owner: item.owner ? String(item.owner) : '',
folder: item.folder ? String(item.folder) : '',
order: Number(item.order || 0),
@@ -171,19 +196,21 @@ export default function InterfaceList({ onLoad, onSelect, onEdit, onNewPlot, onE
return (
<li
key={item.id}
class={`iface-item${dropTarget === `p:${item.id}` ? ' drop-target' : ''}`}
class={`iface-item iface-item-clickable${dropTarget === `p:${item.id}` ? ' drop-target' : ''}`}
draggable={drag}
onClick={() => onSelect?.(item.id)}
onDragStart={drag ? (e) => { dragId.current = item.id; e.dataTransfer!.effectAllowed = 'move'; } : undefined}
onDragEnd={endDrag}
onDragOver={(e) => { if (dragId.current) { e.preventDefault(); e.stopPropagation(); setDropTarget(`p:${item.id}`); } }}
onDragLeave={() => setDropTarget(t => t === `p:${item.id}` ? null : t)}
onDrop={(e) => dropOnPanel(item, e)}
>
<span class="iface-item-name" onClick={() => onSelect?.(item.id)} title={item.owner ? `Owner: ${item.owner}` : undefined}>
<span class="iface-item-name" title={item.owner ? `Owner: ${item.owner}` : undefined}>
<KindIcon kind={item.kind} />
{item.name || item.id}
{item.perm === 'read' && <span class="iface-badge" title="Read-only">ro</span>}
</span>
<div class="iface-item-actions">
<div class="iface-item-actions" onClick={(e) => e.stopPropagation()}>
{item.perm === 'write' && <button class="icon-btn" title="Edit" onClick={() => onEditId?.(item.id)}></button>}
<button class="icon-btn" title="Open fullscreen in new tab" onClick={() => handleFullscreen(item.id)}></button>
{writable && <button class="icon-btn" title="Clone" onClick={() => handleClone(item.id)}></button>}
+460 -85
View File
@@ -1,8 +1,12 @@
import { h, Fragment } from 'preact';
import { useState, useEffect, useRef } from 'preact/hooks';
import SearchableSelect from './SearchableSelect';
import SignalPicker, { SignalOption } from './SignalPicker';
import { checkExpr } from './lib/expr';
import type { LogicGraph, LogicNode, LogicNodeKind, LogicWire, StateVar, Widget } from './lib/types';
import { groupBounds, groupContaining, genGroupId, pruneGroups, type NodeGroup, type Rect } from './lib/nodeGroups';
import { useFlowZoom, type Box } from './lib/flowZoom';
import { LogicEngine } from './lib/logic';
import { useFlowDebug, formatBadge, badgeTitle, nodeDebugClass, type DebugSnapshot } from './lib/flowDebug';
interface DataSource { name: string; }
interface SignalInfo { name: string; }
@@ -34,9 +38,21 @@ const REM = (() => {
return parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
})();
const NODE_W = 11.5 * REM; // node width
const CANVAS_W = 125 * REM; // logical canvas size (matches .flow-canvas-inner)
const CANVAS_H = 87.5 * REM;
const PORT_TOP = 2 * REM; // y of the first port row, relative to node top
const PORT_GAP = 1.375 * REM; // vertical spacing between stacked output ports
const PORT_R = 0.375 * REM; // port radius (half the 0.75rem dot)
// Ports sit inside the node's padding box, offset from the node border-box origin
// by the node borders (3px colored top accent, 1px sides). Anchors add the same
// offsets so wires meet the port centers, not their top edges.
const BORDER = 1;
const BORDER_TOP = 3;
// Group frame geometry.
const GROUP_PAD = 0.65 * REM; // padding around member nodes
const GROUP_HEADER = 1.65 * REM; // height of the group title bar
const GROUP_BOX_W = NODE_W; // collapsed-box width
const GROUP_BOX_H = GROUP_HEADER + 1.1 * REM; // collapsed-box height
interface PaletteEntry {
kind: LogicNodeKind;
@@ -65,6 +81,11 @@ const PALETTE: PaletteEntry[] = [
{ 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.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> = {
@@ -88,6 +109,11 @@ const KIND_LABEL: Record<LogicNodeKind, string> = {
'action.dialog.info': 'Info dialog',
'action.dialog.error': 'Error 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;
@@ -114,11 +140,11 @@ function nodeHeight(kind: LogicNodeKind): number {
function genId(): string { return 'n_' + Math.random().toString(36).slice(2, 9); }
// Port anchors in canvas coordinates.
function inAnchor(n: LogicNode) { return { x: n.x, y: n.y + PORT_TOP }; }
function inAnchor(n: LogicNode) { return { x: n.x + BORDER, y: n.y + BORDER_TOP + PORT_TOP }; }
function outAnchor(n: LogicNode, port: string) {
const ports = outputs(n.kind);
const i = Math.max(0, ports.findIndex(p => p.id === port));
return { x: n.x + NODE_W, y: n.y + PORT_TOP + i * PORT_GAP };
return { x: n.x + NODE_W - BORDER, y: n.y + BORDER_TOP + PORT_TOP + i * PORT_GAP };
}
function wirePathStr(x1: number, y1: number, x2: number, y2: number): string {
@@ -132,12 +158,23 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
const [selectedNode, setSelectedNode] = useState<string | null>(null);
const [selectedWire, setSelectedWire] = useState<number | null>(null);
// Multi-selection (Shift+click) used to build groups; selectedNode stays the
// "primary" node shown in the inspector.
const [selSet, setSelSet] = useState<Set<string>>(new Set());
const [selectedGroup, setSelectedGroup] = useState<string | null>(null);
const groups = graph.groups ?? [];
const [dataSources, setDataSources] = useState<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 dragNode = useRef<{ id: string; dx: number; dy: number; pushed: boolean } | null>(null);
const zoomRef = useRef(1);
const dragNode = useRef<
{ ids: string[]; ox: number; oy: number; starts: Map<string, { x: number; y: number }>; pushed: boolean } | null
>(null);
const [pendingWire, setPendingWire] = useState<{ from: string; port: string; x: number; y: number } | null>(null);
const pendingRef = useRef(pendingWire);
pendingRef.current = pendingWire;
@@ -155,11 +192,25 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
const canUndo = undoStack.current.length > 0;
const canRedo = redoStack.current.length > 0;
// Live / debug mode: a throwaway dry-run LogicEngine evaluates the edited graph
// against live signals with all side effects (writes, config, dialogs)
// suppressed, so each node lights with its value without touching production.
const [debug, setDebug] = useState(false);
const dbgEngine = useRef<LogicEngine | null>(null);
useEffect(() => {
fetch('/api/v1/datasources')
.then(r => r.ok ? r.json() : [])
.then((ds: DataSource[]) => setDataSources(ds.map(d => d.name)))
.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) {
@@ -180,6 +231,54 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
return dsSignals[ds] ?? [];
}
// Flatten every known ds:name into a single list for the fuzzy SignalPicker,
// and a hook to lazily load all data sources' signals when a picker opens.
function allSignalOptions(): SignalOption[] {
const out: SignalOption[] = [];
for (const ds of dsOptions) for (const name of signalOptions(ds)) out.push({ ds, name });
return out;
}
function openAllSignals() { dsOptions.forEach(ds => loadSignals(ds)); }
// 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;
useEffect(() => {
@@ -211,6 +310,8 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
undoStack.current = undoStack.current.slice(0, -1);
setSelectedNode(null);
setSelectedWire(null);
setSelectedGroup(null);
setSelSet(new Set());
onChange(prev);
bump();
}
@@ -232,8 +333,9 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
if (record) pushUndo();
onChange(next);
}
function setNodes(next: LogicNode[], record = true) { setGraph({ nodes: next, wires }, record); }
function setWires(next: LogicWire[]) { setGraph({ nodes, wires: next }); }
function setNodes(next: LogicNode[], record = true) { setGraph({ nodes: next, wires, ...(groups.length ? { groups } : {}) }, record); }
function setWires(next: LogicWire[]) { setGraph({ nodes, wires: next, ...(groups.length ? { groups } : {}) }); }
function setGroups(next: NodeGroup[], record = true) { setGraph({ nodes, wires, groups: next }, record); }
function addNode(entry: PaletteEntry, x?: number, y?: number) {
const node: LogicNode = {
@@ -243,23 +345,52 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
y: y ?? 40 + (nodes.length % 5) * 30,
params: { ...entry.params },
};
setGraph({ nodes: [...nodes, node], wires });
setGraph({ nodes: [...nodes, node], wires, ...(groups.length ? { groups } : {}) });
setSelectedNode(node.id);
setSelectedWire(null);
setSelSet(new Set([node.id]));
setSelectedGroup(null);
}
function patchParams(id: string, patch: Record<string, string>) {
setNodes(nodes.map(n => (n.id === id ? { ...n, params: { ...n.params, ...patch } } : n)));
}
function moveNode(id: string, x: number, y: number, record = false) {
setNodes(nodes.map(n => (n.id === id ? { ...n, x, y } : n)), record);
// Move several nodes at once (group / multi-select drag). `pos` maps id→{x,y}.
function moveNodes(pos: Map<string, { x: number; y: number }>, record = false) {
setNodes(nodes.map(n => (pos.has(n.id) ? { ...n, ...pos.get(n.id)! } : n)), record);
}
// ── Groups ──────────────────────────────────────────────────────────────────
function groupSelection() {
if (selSet.size < 1) return;
const members = nodes.filter(n => selSet.has(n.id)).map(n => n.id);
if (members.length < 1) return;
const g: NodeGroup = { id: genGroupId(), label: 'Group', members, collapsed: false };
setGroups([...groups, g]);
setSelectedGroup(g.id);
setSelectedNode(null);
setSelectedWire(null);
}
function ungroup(gid: string) {
setGroups(groups.filter(g => g.id !== gid));
if (selectedGroup === gid) setSelectedGroup(null);
}
function toggleCollapse(gid: string) {
setGroups(groups.map(g => (g.id === gid ? { ...g, collapsed: !g.collapsed } : g)));
}
function renameGroup(gid: string, label: string) {
setGroups(groups.map(g => (g.id === gid ? { ...g, label } : g)), false);
}
function deleteNode(id: string) {
const nextNodes = nodes.filter(n => n.id !== id);
const nextGroups = pruneGroups(groups, new Set(nextNodes.map(n => n.id)));
setGraph({
nodes: nodes.filter(n => n.id !== id),
nodes: nextNodes,
wires: wires.filter(w => w.from !== id && w.to !== id),
...(nextGroups.length ? { groups: nextGroups } : {}),
});
if (selectedNode === id) setSelectedNode(null);
if (selSet.has(id)) { const s = new Set(selSet); s.delete(id); setSelSet(s); }
}
function addWire(from: string, port: string, to: string) {
if (from === to) return;
@@ -292,7 +423,7 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
const newWires = clip.wires
.filter(w => idMap.has(w.from) && idMap.has(w.to))
.map(w => ({ ...w, from: idMap.get(w.from)!, to: idMap.get(w.to)! }));
setGraph({ nodes: [...cur.nodes, ...newNodes], wires: [...cur.wires, ...newWires] });
setGraph({ nodes: [...cur.nodes, ...newNodes], wires: [...cur.wires, ...newWires], ...((cur.groups?.length) ? { groups: cur.groups } : {}) });
if (newNodes.length === 1) { setSelectedNode(newNodes[0].id); setSelectedWire(null); }
}
@@ -300,19 +431,44 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
function toCanvas(e: MouseEvent): { x: number; y: number } {
const el = canvasRef.current!;
const rect = el.getBoundingClientRect();
return { x: e.clientX - rect.left + el.scrollLeft, y: e.clientY - rect.top + el.scrollTop };
const z = zoomRef.current;
return { x: (e.clientX - rect.left + el.scrollLeft) / z, y: (e.clientY - rect.top + el.scrollTop) / z };
}
// ── Node dragging ──────────────────────────────────────────────────────────
function startNodeDrag(e: MouseEvent, node: LogicNode) {
e.stopPropagation();
// Begin dragging `ids` (one node, a multi-selection, or a whole group). All
// members move together, keeping their relative offsets.
function beginDrag(e: MouseEvent, ids: string[]) {
const p = toCanvas(e);
dragNode.current = { id: node.id, dx: p.x - node.x, dy: p.y - node.y, pushed: false };
setSelectedNode(node.id);
setSelectedWire(null);
const starts = new Map<string, { x: number; y: number }>();
for (const id of ids) {
const n = nodes.find(x => x.id === id);
if (n) starts.set(id, { x: n.x, y: n.y });
}
dragNode.current = { ids: [...starts.keys()], ox: p.x, oy: p.y, starts, pushed: false };
window.addEventListener('mousemove', onNodeDragMove);
window.addEventListener('mouseup', onNodeDragUp);
}
function startNodeDrag(e: MouseEvent, node: LogicNode) {
e.stopPropagation();
// Shift+click toggles the node in the multi-selection without dragging.
if (e.shiftKey) {
const s = new Set(selSet);
if (s.has(node.id)) s.delete(node.id); else s.add(node.id);
setSelSet(s);
setSelectedNode(node.id);
setSelectedWire(null);
setSelectedGroup(null);
return;
}
setSelectedWire(null);
setSelectedGroup(null);
// Drag the whole multi-selection if this node is part of it, else just it.
const ids = selSet.has(node.id) && selSet.size > 1 ? [...selSet] : [node.id];
if (!(selSet.has(node.id) && selSet.size > 1)) setSelSet(new Set([node.id]));
setSelectedNode(node.id);
beginDrag(e, ids);
}
function onNodeDragMove(e: MouseEvent) {
const d = dragNode.current;
if (!d) return;
@@ -321,7 +477,10 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
// click-to-select doesn't create a spurious history step).
const record = !d.pushed;
d.pushed = true;
moveNode(d.id, Math.max(0, p.x - d.dx), Math.max(0, p.y - d.dy), record);
const dx = p.x - d.ox, dy = p.y - d.oy;
const pos = new Map<string, { x: number; y: number }>();
for (const [id, s] of d.starts) pos.set(id, { x: Math.max(0, s.x + dx), y: Math.max(0, s.y + dy) });
moveNodes(pos, record);
}
function onNodeDragUp() {
dragNode.current = null;
@@ -393,13 +552,16 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
if (mod && (e.key.toLowerCase() === 'y' || (e.key.toLowerCase() === 'z' && e.shiftKey))) { e.preventDefault(); redo(); return; }
if (mod && e.key.toLowerCase() === 'c') { e.preventDefault(); copySelection(); return; }
if (mod && e.key.toLowerCase() === 'v') { e.preventDefault(); pasteClipboard(); return; }
// 'g' groups the current multi-selection (Ctrl+G or plain g).
if (e.key.toLowerCase() === 'g' && selSet.size >= 1) { e.preventDefault(); groupSelection(); return; }
if (e.key !== 'Delete' && e.key !== 'Backspace') return;
if (selectedWire !== null) deleteWire(selectedWire);
if (selectedGroup !== null) ungroup(selectedGroup);
else if (selectedWire !== null) deleteWire(selectedWire);
else if (selectedNode) deleteNode(selectedNode);
}
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [selectedNode, selectedWire, nodes, wires]);
}, [selectedNode, selectedWire, selectedGroup, selSet, nodes, wires, groups]);
const byId = new Map(nodes.map(n => [n.id, n]));
@@ -415,12 +577,85 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
patchParams(id, { [field]: `${cur}{${ds}:${sig}}` });
}
// ── Group geometry (for the current render) ─────────────────────────────────
const rectOf = (id: string): Rect | null => {
const n = byId.get(id);
return n ? { x: n.x, y: n.y, w: NODE_W, h: nodeHeight(n.kind) } : null;
};
const groupGeom = groups.map(g => {
const bounds = groupBounds(g.members, rectOf, GROUP_PAD, GROUP_HEADER);
const box: Rect | null = bounds ? { x: bounds.x, y: bounds.y, w: GROUP_BOX_W, h: GROUP_BOX_H } : null;
return { g, bounds, box };
});
const hidden = new Set<string>();
const boxByGroup = new Map<string, Rect>();
for (const gg of groupGeom) if (gg.g.collapsed && gg.box) {
boxByGroup.set(gg.g.id, gg.box);
for (const m of gg.g.members) hidden.add(m);
}
const collapsedBoxOf = (id: string): Rect | null => {
const g = groupContaining(groups, id);
return g && g.collapsed ? (boxByGroup.get(g.id) ?? null) : null;
};
const getRects = (): Box[] => graph.nodes.map(n => ({ x: n.x, y: n.y, w: NODE_W, h: nodeHeight(n.kind) }));
const { zoom, zoomIn, zoomOut, zoomHome, zoomFit, innerStyle, zoomStyle } = useFlowZoom(canvasRef, zoomRef, CANVAS_W, CANVAS_H, getRects);
// Create/tear down the dry-run engine as debug mode toggles.
useEffect(() => {
if (!debug) return;
const eng = new LogicEngine();
eng.setDryRun(true);
eng.load(graphRef.current);
dbgEngine.current = eng;
return () => { eng.clear(); dbgEngine.current = null; };
}, [debug]);
// Reload the dry-run engine when the edited graph changes (debounced so rapid
// edits settle before re-running triggers).
useEffect(() => {
if (!debug || !dbgEngine.current) return;
const h = setTimeout(() => dbgEngine.current?.load(graphRef.current), 300);
return () => clearTimeout(h);
}, [debug, graph]);
// Poll the dry-run engine's per-node state into the shared overlay.
const debugSnap: DebugSnapshot = useFlowDebug(debug, async () => {
const eng = dbgEngine.current;
if (!eng) return null;
const snap: DebugSnapshot = new Map();
for (const [id, s] of eng.getDebug()) snap.set(id, { value: s.value, active: s.active });
return snap;
});
const resolveOut = (n: LogicNode, port: string) => {
const box = collapsedBoxOf(n.id);
return box ? { x: box.x + box.w, y: box.y + box.h / 2 } : outAnchor(n, port);
};
const resolveIn = (n: LogicNode) => {
const box = collapsedBoxOf(n.id);
return box ? { x: box.x, y: box.y + box.h / 2 } : inAnchor(n);
};
// A wire is hidden when both ends collapse into the same group.
const wireHidden = (from: string, to: string) => {
const gf = groupContaining(groups, from), gt = groupContaining(groups, to);
return !!gf && gf === gt && gf.collapsed;
};
return (
<div class="flow-editor">
<div class="flow-palette">
<div class="flow-palette-toolbar">
<button class="toolbar-btn" disabled={!canUndo} onClick={undo} title="Undo (Ctrl+Z)"></button>
<button class="toolbar-btn" disabled={!canRedo} onClick={redo} title="Redo (Ctrl+Shift+Z)"></button>
<button class="toolbar-btn" disabled={selSet.size < 1} onClick={groupSelection}
title="Group selected nodes (G) — Shift+click nodes to multi-select"></button>
<button class={`toolbar-btn${debug ? ' flow-debug-on' : ''}`} onClick={() => setDebug(d => !d)}
title="Live debug — dry-run the flow and show each node's value (no real writes)"></button>
</div>
<div class="flow-palette-toolbar">
<button class="toolbar-btn" onClick={zoomOut} title="Zoom out"></button>
<button class="toolbar-btn flow-zoom-pct" onClick={zoomHome} title="Reset zoom (100%)">{Math.round(zoom * 100)}%</button>
<button class="toolbar-btn" onClick={zoomIn} title="Zoom in"></button>
<button class="toolbar-btn" onClick={zoomFit} title="Fit graph to view"></button>
</div>
<div class="flow-palette-title">Triggers</div>
{PALETTE.filter(e => category(e.kind) === 'trigger').map(paletteBtn)}
@@ -441,17 +676,61 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
</div>
<div class="flow-canvas" ref={canvasRef}
onMouseDown={() => { setSelectedNode(null); setSelectedWire(null); }}
onMouseDown={() => { setSelectedNode(null); setSelectedWire(null); setSelectedGroup(null); setSelSet(new Set()); }}
onDragOver={onCanvasDragOver}
onDrop={onCanvasDrop}>
<div class="flow-canvas-inner">
<div class="flow-canvas-inner" style={innerStyle}>
<div class="flow-canvas-zoom" style={zoomStyle}>
{/* Group frames (behind nodes). Collapsed groups render a compact box. */}
{groupGeom.map(({ g, bounds, box }) => {
if (!bounds) return null;
const sel = selectedGroup === g.id;
if (g.collapsed && box) {
return (
<div key={g.id}
class={`flow-group-box${sel ? ' flow-group-selected' : ''}`}
style={`left:${box.x}px; top:${box.y}px; width:${box.w}px; height:${box.h}px;`}
onMouseDown={(e) => { e.stopPropagation(); setSelectedGroup(g.id); setSelectedNode(null); setSelectedWire(null); beginDrag(e, g.members); }}>
<div class="flow-group-header">
<button class="flow-group-caret" title="Expand"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); toggleCollapse(g.id); }}></button>
<input class="flow-group-label" value={g.label}
onMouseDown={(e) => e.stopPropagation()}
onInput={(e) => renameGroup(g.id, (e.target as HTMLInputElement).value)} />
</div>
<div class="flow-group-count hint">{g.members.length} node{g.members.length === 1 ? '' : 's'}</div>
</div>
);
}
return (
<div key={g.id}
class={`flow-group-frame${sel ? ' flow-group-selected' : ''}`}
style={`left:${bounds.x}px; top:${bounds.y}px; width:${bounds.w}px; height:${bounds.h}px;`}
onMouseDown={(e) => { e.stopPropagation(); setSelectedGroup(g.id); setSelectedNode(null); setSelectedWire(null); beginDrag(e, g.members); }}>
<div class="flow-group-header">
<button class="flow-group-caret" title="Collapse"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); toggleCollapse(g.id); }}></button>
<input class="flow-group-label" value={g.label}
onMouseDown={(e) => e.stopPropagation()}
onInput={(e) => renameGroup(g.id, (e.target as HTMLInputElement).value)} />
<button class="flow-group-del" title="Ungroup"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); ungroup(g.id); }}></button>
</div>
</div>
);
})}
<svg class="flow-wires">
{wires.map((w, idx) => {
const a = byId.get(w.from);
const b = byId.get(w.to);
if (!a || !b) return null;
const p1 = outAnchor(a, w.fromPort ?? 'out');
const p2 = inAnchor(b);
if (wireHidden(w.from, w.to)) return null;
const p1 = resolveOut(a, w.fromPort ?? 'out');
const p2 = resolveIn(b);
return (
<path key={idx}
class={`flow-wire${selectedWire === idx ? ' flow-wire-selected' : ''}`}
@@ -467,12 +746,19 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
})()}
</svg>
{nodes.map(node => (
{nodes.filter(n => !hidden.has(n.id)).map(node => {
const dbg = debug ? debugSnap.get(node.id) : undefined;
const firable = debug && node.kind.startsWith('trigger.');
const grouped = groupContaining(groups, node.id) ? ' flow-node-grouped' : '';
return (
<div key={node.id}
class={`flow-node flow-node-${category(node.kind)}${selectedNode === node.id ? ' flow-node-selected' : ''}`}
class={`flow-node flow-node-${category(node.kind)}${selectedNode === node.id ? ' flow-node-selected' : ''}${selSet.has(node.id) && selSet.size > 1 ? ' flow-node-multi' : ''}${dbg ? ' ' + nodeDebugClass(dbg) : ''}${firable ? ' flow-node-firable' : ''}${grouped}`}
style={`left:${node.x}px; top:${node.y}px; width:${NODE_W}px; min-height:${nodeHeight(node.kind)}px;`}
title={firable ? 'Double-click to force this trigger to fire' : undefined}
onMouseDown={(e) => startNodeDrag(e, node)}
onDblClick={() => { if (firable) dbgEngine.current?.fireTrigger(node.id); }}
onMouseUp={() => { if (pendingRef.current) finishWire(node); }}>
{dbg && <span class={`flow-node-badge${dbg.approx ? ' approx' : ''}${dbg.error ? ' error' : ''}`} title={badgeTitle(dbg)}>{(dbg.approx ? '~' : '') + formatBadge(dbg)}</span>}
<div class="flow-node-header">
<span class="flow-node-title">{KIND_LABEL[node.kind]}</span>
<button class="flow-node-del" title="Delete node"
@@ -499,7 +785,9 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
</Fragment>
))}
</div>
))}
);
})}
</div>
</div>
</div>
@@ -520,19 +808,13 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
)}
{(selected.kind === 'trigger.threshold' || selected.kind === 'trigger.change') && (() => {
const { ds, name } = splitRef(selected.params.signal ?? '');
return (
<Fragment>
<div class="wizard-field">
<label>Data source</label>
<SearchableSelect value={ds} options={dsOptions}
onSelect={(newDs) => { loadSignals(newDs); patchParams(selected.id, { signal: `${newDs}:` }); }} />
</div>
<div class="wizard-field">
<label>Signal</label>
<SearchableSelect value={name} options={signalOptions(ds)}
onSelect={(sig) => patchParams(selected.id, { signal: `${ds}:${sig}` })}
placeholder={ds ? 'Search signals' : 'Select data source first'} />
<SignalPicker value={selected.params.signal ?? ''} options={allSignalOptions()}
onOpen={openAllSignals}
onChange={(ref) => patchParams(selected.id, { signal: ref })} />
</div>
{selected.kind === 'trigger.threshold' && (
<div class="wizard-field wizard-field-row">
@@ -573,7 +855,7 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
<ExprField label="Condition" value={selected.params.cond ?? ''}
onChange={(v) => patchParams(selected.id, { cond: v })}
onInsert={(ds, sig) => insertRef(selected.id, 'cond', ds, sig)}
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals}
allSignals={allSignalOptions()} onOpenSignals={openAllSignals}
hint="True → 'then' port, false → 'else' port. e.g. {stub:level} > 5 && temp < 100. {sys:time}=epoch seconds, {sys:dt}=seconds since the trigger last fired." />
)}
@@ -597,36 +879,29 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
<ExprField label="While condition" value={selected.params.cond ?? ''}
onChange={(v) => patchParams(selected.id, { cond: v })}
onInsert={(ds, sig) => insertRef(selected.id, 'cond', ds, sig)}
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals}
allSignals={allSignalOptions()} onOpenSignals={openAllSignals}
hint="Loops the 'body' port while true (capped). e.g. {stub:level} < 90" />
)}
<p class="hint">Runs the <b>body</b> port repeatedly, then continues on <b>done</b>.</p>
</Fragment>
)}
{selected.kind === 'action.write' && (() => {
const { ds, name } = splitRef(selected.params.target ?? '');
return (
<Fragment>
<div class="wizard-field">
<label>Target data source</label>
<SearchableSelect value={ds} options={dsOptions}
onSelect={(newDs) => { loadSignals(newDs); patchParams(selected.id, { target: `${newDs}:` }); }} />
</div>
<div class="wizard-field">
<label>Target signal</label>
<SearchableSelect value={name} options={signalOptions(ds)}
onSelect={(sig) => patchParams(selected.id, { target: `${ds}:${sig}` })}
placeholder={ds ? 'Search signals…' : 'Select data source first'} />
</div>
<ExprField label="Value (expression)" value={selected.params.expr ?? ''}
onChange={(v) => patchParams(selected.id, { expr: v })}
onInsert={(ds2, sig) => insertRef(selected.id, 'expr', ds2, sig)}
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals}
hint="e.g. ({stub:a} - {stub:b}) / {sys:dt} for a rate — or a constant like 42. {sys:time}=epoch seconds, {sys:dt}=seconds since the trigger last fired." />
</Fragment>
);
})()}
{selected.kind === 'action.write' && (
<Fragment>
<div class="wizard-field">
<label>Target signal / variable</label>
<SignalPicker value={selected.params.target ?? ''} options={allSignalOptions()}
onOpen={openAllSignals} allowFreeform
onChange={(ref) => patchParams(selected.id, { target: ref })} />
<p class="hint">Pick a signal, or type <code>local:name</code> to write a panel-local variable.</p>
</div>
<ExprField label="Value (expression)" value={selected.params.expr ?? ''}
onChange={(v) => patchParams(selected.id, { expr: v })}
onInsert={(ds2, sig) => insertRef(selected.id, 'expr', ds2, sig)}
allSignals={allSignalOptions()} onOpenSignals={openAllSignals}
hint="e.g. ({stub:a} - {stub:b}) / {sys:dt} for a rate — or a constant like 42. {sys:time}=epoch seconds, {sys:dt}=seconds since the trigger last fired." />
</Fragment>
)}
{selected.kind === 'action.delay' && (
<div class="wizard-field">
@@ -636,6 +911,114 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
</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' && (
<Fragment>
<div class="wizard-field">
@@ -647,7 +1030,7 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
<ExprField label="Value (expression)" value={selected.params.expr ?? ''}
onChange={(v) => patchParams(selected.id, { expr: v })}
onInsert={(ds2, sig) => insertRef(selected.id, 'expr', ds2, sig)}
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals}
allSignals={allSignalOptions()} onOpenSignals={openAllSignals}
hint="Appends this value (with a timestamp) to the array. e.g. {stub:level}, or {sys:time} for the clock." />
</Fragment>
)}
@@ -683,7 +1066,7 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
<ExprField label="Value (expression)" value={selected.params.expr ?? ''}
onChange={(v) => patchParams(selected.id, { expr: v })}
onInsert={(ds2, sig) => insertRef(selected.id, 'expr', ds2, sig)}
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals}
allSignals={allSignalOptions()} onOpenSignals={openAllSignals}
hint="Logs this value to the browser console — handy for debugging a flow." />
</Fragment>
)}
@@ -747,7 +1130,7 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
<DialogFieldsEditor allowInput={false}
fields={parseDialogFields(selected)}
onChange={(f) => patchParams(selected.id, { fields: JSON.stringify(f) })}
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals} />
allSignals={allSignalOptions()} onOpenSignals={openAllSignals} />
</Fragment>
)}
@@ -768,7 +1151,7 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
<DialogFieldsEditor allowInput={true}
fields={parseDialogFields(selected)}
onChange={(f) => patchParams(selected.id, { fields: JSON.stringify(f) })}
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals} />
allSignals={allSignalOptions()} onOpenSignals={openAllSignals} />
<p class="hint">Each <b>input</b> prompts the operator and writes the entered number to its
target on OK; each <b>display</b> shows a live value. Cancel aborts the flow.</p>
</Fragment>
@@ -796,17 +1179,15 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
}
// Expression input with a signal-insert helper and live validation.
function ExprField({ label, value, onChange, onInsert, dsOptions, signalOptions, loadSignals, hint }: {
function ExprField({ label, value, onChange, onInsert, allSignals, onOpenSignals, hint }: {
label: string;
value: string;
onChange: (v: string) => void;
onInsert: (ds: string, sig: string) => void;
dsOptions: string[];
signalOptions: (ds: string) => string[];
loadSignals: (ds: string) => void;
allSignals: SignalOption[];
onOpenSignals: () => void;
hint: string;
}) {
const [insDs, setInsDs] = useState('');
const err = checkExpr(value);
return (
<div class="wizard-field">
@@ -816,11 +1197,9 @@ function ExprField({ label, value, onChange, onInsert, dsOptions, signalOptions,
onInput={(e) => onChange((e.target as HTMLInputElement).value)} />
{err && <p class="wizard-error">{err}</p>}
<div class="flow-expr-insert">
<SearchableSelect value={insDs} options={dsOptions}
onSelect={(ds) => { setInsDs(ds); loadSignals(ds); }} placeholder="ds…" />
<SearchableSelect value="" options={signalOptions(insDs)}
onSelect={(sig) => onInsert(insDs, sig)}
placeholder={insDs ? '+ insert signal' : 'pick ds first'} />
<SignalPicker value="" options={allSignals} onOpen={onOpenSignals}
placeholder="+ insert signal"
onChange={(ref) => { const s = splitRef(ref); onInsert(s.ds, s.name); }} />
</div>
<p class="hint">{hint}</p>
</div>
@@ -934,13 +1313,12 @@ function parseDialogFields(n: LogicNode): DialogFieldSpec[] {
return [];
}
function DialogFieldsEditor({ allowInput, fields, onChange, dsOptions, signalOptions, loadSignals }: {
function DialogFieldsEditor({ allowInput, fields, onChange, allSignals, onOpenSignals }: {
allowInput: boolean;
fields: DialogFieldSpec[];
onChange: (fields: DialogFieldSpec[]) => void;
dsOptions: string[];
signalOptions: (ds: string) => string[];
loadSignals: (ds: string) => void;
allSignals: SignalOption[];
onOpenSignals: () => void;
}) {
function patch(i: number, patch: Partial<DialogFieldSpec>) {
onChange(fields.map((f, j) => (j === i ? { ...f, ...patch } : f)));
@@ -953,7 +1331,6 @@ function DialogFieldsEditor({ allowInput, fields, onChange, dsOptions, signalOpt
<label>{allowInput ? 'Fields' : 'Displayed values'}</label>
{fields.length === 0 && <p class="hint">None yet.</p>}
{fields.map((f, i) => {
const { ds, name } = splitRef(f.target ?? '');
return (
<div key={i} class="flow-field-edit">
<div class="flow-field-head">
@@ -972,11 +1349,9 @@ function DialogFieldsEditor({ allowInput, fields, onChange, dsOptions, signalOpt
{f.type === 'input' ? (
<Fragment>
<div class="flow-row-edit">
<SearchableSelect value={ds} options={dsOptions}
onSelect={(newDs) => { loadSignals(newDs); patch(i, { target: `${newDs}:` }); }} placeholder="ds…" />
<SearchableSelect value={name} options={signalOptions(ds)}
onSelect={(sig) => patch(i, { target: `${ds}:${sig}` })}
placeholder={ds ? 'signal…' : 'pick ds'} />
<SignalPicker value={f.target ?? ''} options={allSignals}
onOpen={onOpenSignals} allowFreeform placeholder="target signal…"
onChange={(ref) => patch(i, { target: ref })} />
</div>
<input class={`prop-input${checkExpr(f.default ?? '') ? ' prop-input-error' : ''}`}
value={f.default ?? ''} placeholder="default value (expression, optional)"
+231 -4
View File
@@ -1,12 +1,14 @@
import { h } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import type { Widget, Interface } from './lib/types';
import { containingTabPane, tabLabels } from './lib/containers';
interface Props {
selected: Widget | null;
multiCount: number;
iface: Interface;
onChange: (updated: Widget) => void;
onRenameId: (oldId: string, newId: string) => void;
onIfaceChange: (updated: Interface) => void;
width?: number;
}
@@ -44,17 +46,59 @@ function TextInput({ value, onCommit }: { value: string; onCommit: (v: string) =
);
}
// Like TextInput, but for the widget id: rejects invalid (empty/duplicate)
// commits and reverts the field to the current id when `onRename` returns false.
function IdInput({ value, onRename }: { value: string; onRename: (v: string) => boolean }) {
const [local, setLocal] = useState(value || '');
useEffect(() => {
setLocal(value || '');
}, [value]);
function commit() {
const v = local.trim();
if (v === value) { setLocal(value); return; }
if (!onRename(v)) setLocal(value); // rejected — revert to the current id
}
return (
<input
class="prop-input"
value={local}
onInput={(e) => setLocal((e.target as HTMLInputElement).value)}
onChange={commit}
onKeyDown={(e) => {
if (e.key === 'Enter') {
commit();
(e.target as HTMLInputElement).blur();
}
}}
/>
);
}
// Widgets that show units from signal metadata (can be overridden)
const UNIT_WIDGETS = new Set(['textview', 'gauge', 'barh', 'barv', 'setvalue']);
// Widgets where the user can set a numeric format string
const FORMAT_WIDGETS = new Set(['textview', 'gauge', 'barh', 'barv']);
// Widgets with a signal-based label (can be overridden)
const LABEL_WIDGETS = new Set(['textview', 'gauge', 'barh', 'barv', 'setvalue', 'led', 'multiled']);
const LABEL_WIDGETS = new Set(['textview', 'gauge', 'barh', 'barv', 'setvalue', 'led', 'multiled', 'toggle']);
// Widgets with multiple signals
const MULTI_SIGNAL_WIDGETS = new Set(['plot', 'multiled']);
const MULTI_SIGNAL_WIDGETS = new Set(['plot', 'multiled', 'table']);
export default function PropertiesPane({ selected, multiCount, iface, onChange, onIfaceChange, width }: Props) {
export default function PropertiesPane({ selected, multiCount, iface, onChange, onRenameId, onIfaceChange, width }: Props) {
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) {
if (!selected) return;
@@ -66,6 +110,17 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
onChange({ ...selected, [key]: value } as Widget);
}
// Apply a custom widget id. Rejects empty or duplicate ids (returns false so
// the field reverts); on success the rename propagates to logic/plot refs.
function renameId(newId: string): boolean {
if (!selected) return false;
if (!newId) return false;
if (newId === selected.id) return false;
if ((iface.widgets || []).some(x => x.id === newId)) return false;
onRenameId(selected.id, newId);
return true;
}
function removeSignal(idx: number) {
if (!selected) return;
const signals = selected.signals.filter((_, i) => i !== idx);
@@ -124,6 +179,11 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
<div class="props-section" key={w.id}>
<div class="props-section-title">{w.type}</div>
{/* Widget id — editable so logic/plot refs can use a friendly name */}
<Field label="Widget ID">
<IdInput value={w.id} onRename={renameId} />
</Field>
{/* Position / size */}
<Field label="X">
<input class="prop-input prop-input-num" type="number" value={w.x}
@@ -169,7 +229,7 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
{/* Label: button and textlabel use it as display text;
signal widgets use it as header label */}
{w.type !== 'image' && w.type !== 'link' && (
{w.type !== 'image' && w.type !== 'link' && w.type !== 'container' && w.type !== 'table' && (
<Field label={LABEL_WIDGETS.has(w.type) ? 'Label override' : 'Label'}>
{LABEL_WIDGETS.has(w.type) ? (
<div class="prop-field-col">
@@ -236,6 +296,30 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
</div>
)}
{w.type === 'toggle' && (
<div>
<Field label="On value">
<TextInput value={w.options['onValue'] ?? '1'} onCommit={(v) => setOpt('onValue', v)} />
</Field>
<Field label="Off value">
<TextInput value={w.options['offValue'] ?? '0'} onCommit={(v) => setOpt('offValue', v)} />
</Field>
<Field label="On label">
<TextInput value={w.options['onLabel'] ?? ''} onCommit={(v) => setOpt('onLabel', v)} />
</Field>
<Field label="Off label">
<TextInput value={w.options['offLabel'] ?? ''} onCommit={(v) => setOpt('offLabel', v)} />
</Field>
<Field label="Confirm dialog">
<select class="prop-select" value={w.options['confirm'] ?? 'false'}
onChange={(e) => setOpt('confirm', (e.target as HTMLSelectElement).value)}>
<option value="false">No</option>
<option value="true">Yes</option>
</select>
</Field>
</div>
)}
{/* Button options (issue #2) */}
{w.type === 'button' && (
<div>
@@ -302,6 +386,7 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
<option value="fft">FFT</option>
<option value="waterfall">Waterfall</option>
<option value="logic">Logic analyser</option>
<option value="waveform">Waveform (array)</option>
</select>
</Field>
{(w.options['plotType'] ?? 'timeseries') === 'timeseries' && (
@@ -359,6 +444,148 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
</Field>
</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>
)}
{w.type === 'container' && (
<div>
<Field label="Variant">
<select class="prop-select" value={w.options['variant'] ?? 'pane'}
onChange={(e) => setOpt('variant', (e.target as HTMLSelectElement).value)}>
<option value="pane">Pane</option>
<option value="tabs">Tabs</option>
</select>
</Field>
<Field label="Accent">
<TextInput value={w.options['accent'] ?? '#3d4f6e'} onCommit={(v) => setOpt('accent', v)} />
</Field>
<Field label="Background">
<select class="prop-select" value={w.options['bg'] ?? 'true'}
onChange={(e) => setOpt('bg', (e.target as HTMLSelectElement).value)}>
<option value="true">Filled</option>
<option value="false">None</option>
</select>
</Field>
{(w.options['variant'] ?? 'pane') === 'tabs' ? (
<Field label="Tabs">
<div class="prop-field-col">
<TextInput value={w.options['tabs'] ?? 'Tab 1,Tab 2'} onCommit={(v) => setOpt('tabs', v)} />
<span class="prop-hint">Comma-separated tab names. Assign each widget to a tab via its "Tab" field.</span>
</div>
</Field>
) : (
<div>
<Field label="Title">
<TextInput value={w.options['title'] ?? ''} onCommit={(v) => setOpt('title', v)} />
</Field>
<Field label="Collapsible">
<select class="prop-select" value={w.options['collapsible'] ?? 'false'}
onChange={(e) => setOpt('collapsible', (e.target as HTMLSelectElement).value)}>
<option value="false">No</option>
<option value="true">Yes</option>
</select>
</Field>
</div>
)}
</div>
)}
{w.type === 'table' && (() => {
const allCols = ['name', 'value', 'unit', 'quality', 'time'];
const colLabels: Record<string, string> = {
name: 'Name', value: 'Value', unit: 'Unit', quality: 'Status', time: 'Time',
};
const cur = (w.options['columns'] ?? 'name,value,unit')
.split(',').map(s => s.trim()).filter(Boolean);
const toggleCol = (c: string) => {
const next = cur.includes(c)
? cur.filter(x => x !== c)
: allCols.filter(x => cur.includes(x) || x === c);
setOpt('columns', next.join(','));
};
return (
<div>
<Field label="Title">
<TextInput value={w.options['title'] ?? ''} onCommit={(v) => setOpt('title', v)} />
</Field>
<Field label="Columns">
<div class="prop-field-col">
{allCols.map(c => (
<label key={c} class="prop-check">
<input type="checkbox" checked={cur.includes(c)} onChange={() => toggleCol(c)} />
{colLabels[c]}
</label>
))}
</div>
</Field>
<Field label="Header row">
<select class="prop-select" value={w.options['header'] ?? 'true'}
onChange={(e) => setOpt('header', (e.target as HTMLSelectElement).value)}>
<option value="true">Show</option>
<option value="false">Hide</option>
</select>
</Field>
<Field label="Value format">
<div class="prop-field-col">
<TextInput value={w.options['format'] ?? ''} onCommit={(v) => setOpt('format', v)} />
<span class="prop-hint">3f · 2e · 4g · empty=auto</span>
</div>
</Field>
<Field label="Row labels">
<div class="prop-field-col">
<TextInput value={w.options['labels'] ?? ''} onCommit={(v) => setOpt('labels', v)} />
<span class="prop-hint">Comma-separated, one per signal. Empty = signal name.</span>
</div>
</Field>
</div>
);
})()}
{/* Tab assignment for a widget sitting inside a tabbed container. */}
{w.type !== 'container' && (() => {
const host = containingTabPane(w, iface.widgets);
if (!host) return null;
const tabs = tabLabels(host);
return (
<Field label="Tab">
<select class="prop-select" value={w.options['tab'] ?? '0'}
onChange={(e) => setOpt('tab', (e.target as HTMLSelectElement).value)}>
{tabs.map((t, i) => <option key={i} value={String(i)}>{t}</option>)}
</select>
</Field>
);
})()}
</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 { useState, useEffect, useRef, useMemo } from 'preact/hooks';
import type { SignalRef, StateVar } from './lib/types';
import SyntheticWizard from './SyntheticWizard';
import SyntheticGraphEditor from './SyntheticGraphEditor';
import GroupsTree from './GroupsTree';
import ChannelFinderModal from './ChannelFinderModal';
import SignalPicker from './SignalPicker';
type TreeTab = 'sources' | 'groups';
type GroupBy = 'datasource' | 'area' | 'system' | 'ioc';
@@ -491,10 +491,11 @@ export default function SignalTree({ onDragStart, width, panelId, statevars, onS
)}
{showWizard && (
<SyntheticWizard
currentIfaceId={panelId}
<SyntheticGraphEditor
create
panelId={panelId}
onClose={() => setShowWizard(false)}
onCreated={loadAllSignals}
onSaved={loadAllSignals}
/>
)}
@@ -523,24 +524,18 @@ export default function SignalTree({ onDragStart, width, panelId, statevars, onS
</div>
<div class="wizard-body">
<div class="wizard-field">
<label>Data Source</label>
<select class="prop-select" value={manualDs} onChange={e => setManualDs((e.target as HTMLSelectElement).value)}>
{Array.from(new Set(allSignals.map(s => s.ds))).map(ds => (
<option key={ds} value={ds}>{ds}</option>
))}
{!allSignals.some(s => s.ds === 'epics') && <option value="epics">epics</option>}
</select>
</div>
<div class="wizard-field">
<label>Signal / PV Name</label>
<input
class="prop-input"
autoFocus
placeholder="e.g. MY:PV:NAME"
value={manualName}
onInput={e => setManualName((e.target as HTMLInputElement).value)}
onKeyDown={e => e.key === 'Enter' && handleManualAdd()}
/>
<label>Signal</label>
<SignalPicker
value={manualName ? `${manualDs}:${manualName}` : ''}
options={allSignals}
allowFreeform
placeholder="Search or type ds:NAME…"
onChange={(ref) => {
const i = ref.indexOf(':');
if (i < 0) { setManualDs(ref); setManualName(''); }
else { setManualDs(ref.slice(0, i)); setManualName(ref.slice(i + 1)); }
}} />
<p class="hint">Pick an existing signal, or type <code>epics:MY:PV:NAME</code> to add a new one.</p>
</div>
</div>
<div class="wizard-footer">
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>
);
}
+73 -19
View File
@@ -7,10 +7,12 @@ import { parseInterface } from './lib/xml';
import { newPlotPanel } from './lib/templates';
import { useAuth, canWrite } from './lib/auth';
import type { Interface } from './lib/types';
import ContextualHelp from './ContextualHelp';
import HelpModal from './HelpModal';
import ZoomControl from './ZoomControl';
import ControlLogicEditor from './ControlLogicEditor';
import AuditViewer from './AuditViewer';
import ConfigManager from './ConfigManager';
import AdminPane from './AdminPane';
interface Props {
onEdit?: (iface?: Interface) => void;
@@ -33,10 +35,16 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) {
const [helpSection, setHelpSection] = useState('start');
const [showTimeNav, setShowTimeNav] = useState(false);
const [showControlLogic, setShowControlLogic] = useState(false);
const [showAudit, setShowAudit] = useState(false);
const [showConfig, setShowConfig] = useState(false);
const [showAdmin, setShowAdmin] = useState(false);
const [toolsOpen, setToolsOpen] = useState(false);
const [leftW, setLeftW] = useState(220);
const [listCollapsed, setListCollapsed] = useState(false);
const me = useAuth();
const writable = canWrite(me.level);
// Advanced tools collapsed into the Tools dropdown (shown only if ≥1 available).
const hasTools = (writable && me.canEditLogic) || me.canViewAudit || writable || me.canAdmin;
function startResize(e: MouseEvent) {
e.preventDefault();
@@ -66,6 +74,15 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) {
return unsub;
}, []);
// Close the Tools dropdown on any outside click (the dropdown itself stops
// propagation so its own button/items don't self-close).
useEffect(() => {
if (!toolsOpen) return;
const close = () => setToolsOpen(false);
window.addEventListener('mousedown', close);
return () => window.removeEventListener('mousedown', close);
}, [toolsOpen]);
// When returning from edit mode, show the edited interface
useEffect(() => {
if (initialInterface) setCurrentInterface(initialInterface);
@@ -137,10 +154,6 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) {
)}
</div>
<div class="toolbar-right">
<div class={`status-chip ${wsStatus}`}>
<span class="status-dot"></span>
{wsStatus === 'connected' && me.user ? `connected as ${me.user}` : wsStatus}
</div>
<button
class={`toolbar-btn${showTimeNav ? ' toolbar-btn-active' : ''}`}
title="Toggle historical time navigation"
@@ -148,7 +161,6 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) {
>
History
</button>
<ContextualHelp mode="view" onOpenManual={openHelp} />
<button
class="icon-btn help-manual-btn"
onClick={() => openHelp('start')}
@@ -157,14 +169,32 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) {
📖
</button>
<ZoomControl />
{writable && me.canEditLogic && (
<button
class="toolbar-btn"
onClick={() => setShowControlLogic(true)}
title="Open server-side control logic"
>
Control logic
</button>
{hasTools && (
<div class="toolbar-dropdown" onMouseDown={(e) => e.stopPropagation()}>
<button
class={`toolbar-btn${toolsOpen ? ' toolbar-btn-active' : ''}`}
title="Advanced tools"
onClick={() => setToolsOpen(o => !o)}
>
Tools
</button>
{toolsOpen && (
<div class="toolbar-dropdown-menu toolbar-dropdown-menu-right" onClick={() => setToolsOpen(false)}>
{writable && me.canEditLogic && (
<button class="ctx-item" onClick={() => setShowControlLogic(true)}> Control logic</button>
)}
{me.canViewAudit && (
<button class="ctx-item" onClick={() => setShowAudit(true)}>🛡 Audit log</button>
)}
{writable && (
<button class="ctx-item" onClick={() => setShowConfig(true)}>🗂 Config manager</button>
)}
{me.canAdmin && (
<button class="ctx-item" onClick={() => setShowAdmin(true)}>{'\u{1F464}\u{FE0E}'} Admin</button>
)}
</div>
)}
</div>
)}
{writable && (
<button
@@ -175,11 +205,6 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) {
Edit
</button>
)}
{me.user && (
<span class="user-chip" title={`Signed in as ${me.user}${writable ? '' : ' (read-only)'}`}>
{me.user}{!writable && ' (read-only)'}
</span>
)}
</div>
</header>
@@ -250,6 +275,25 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) {
</div>
</div>
<footer class="statusbar">
<div class="statusbar-left">
{currentInterface
? <span class="statusbar-iface" title={currentInterface.name}>{currentInterface.name}</span>
: <span class="statusbar-muted">No panel loaded</span>}
{!isLive && <span class="statusbar-hist" title="Viewing historical data"> history</span>}
</div>
<div class="statusbar-right">
<div
class={`status-chip ${wsStatus}`}
title={me.user ? `Signed in as ${me.user}${writable ? '' : ' (read-only)'}` : undefined}
>
<span class="status-dot"></span>
{wsStatus === 'connected' && me.user ? `connected as ${me.user}` : wsStatus}
{me.user && !writable && ' (read-only)'}
</div>
</div>
</footer>
{showHelp && (
<HelpModal initialSection={helpSection} onClose={() => setShowHelp(false)} />
)}
@@ -257,6 +301,16 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) {
{showControlLogic && (
<ControlLogicEditor onClose={() => setShowControlLogic(false)} />
)}
{showAudit && (
<AuditViewer onClose={() => setShowAudit(false)} />
)}
{showConfig && (
<ConfigManager onClose={() => setShowConfig(false)} />
)}
{showAdmin && (
<AdminPane onClose={() => setShowAdmin(false)} />
)}
</div>
);
}
+2
View File
@@ -15,8 +15,10 @@ const WIDGET_TYPES = [
{ type: 'led', label: 'LED', desc: 'State indicator' },
{ type: 'multiled', label: 'Multi-LED', desc: 'Bitset indicator' },
{ type: 'setvalue', label: 'Set Value', desc: 'Input + write control' },
{ type: 'toggle', label: 'Toggle Switch', desc: 'On/off switch control' },
{ type: 'button', label: 'Button', desc: 'Send command on click' },
{ type: 'plot', label: 'Plot', desc: 'Time-series / charts' },
{ type: 'table', label: 'Table', desc: 'Multi-signal value table' },
{ type: 'textlabel', label: 'Label', desc: 'Static text label' },
{ type: 'image', label: 'Image', desc: 'Image from URL' },
{ type: 'link', label: 'Link', desc: 'Navigate to interface' },
+3 -1
View File
@@ -4,7 +4,7 @@ import type { Me, AccessLevel } from './types';
// Default identity used before /api/v1/me resolves (or if it fails): assume a
// trusted LAN user with full access, matching the backend default.
export const DEFAULT_ME: Me = { user: '', level: 'write', groups: [], canEditLogic: true };
export const DEFAULT_ME: Me = { user: '', level: 'write', groups: [], canEditLogic: true, canViewAudit: true, canAdmin: true };
// AuthContext carries the resolved identity + global access level for the
// current user throughout the app.
@@ -37,6 +37,8 @@ export async function fetchMe(): Promise<Me> {
level: (data.level as AccessLevel) ?? 'write',
groups: Array.isArray(data.groups) ? data.groups : [],
canEditLogic: data.canEditLogic !== false,
canViewAudit: data.canViewAudit !== false,
canAdmin: data.canAdmin !== false,
};
} catch {
return DEFAULT_ME;
+56
View File
@@ -0,0 +1,56 @@
import type { Widget } from './types';
// A widget is "inside" a container when its centre falls within the container's
// bounds. Containers are decorative frames that do not re-parent widgets, so
// membership is purely geometric.
export function centreInside(w: Widget, c: Widget): boolean {
const cx = w.x + w.w / 2, cy = w.y + w.h / 2;
return cx >= c.x && cx <= c.x + c.w && cy >= c.y && cy <= c.y + c.h;
}
// Expand a set of widget ids to also include every widget geometrically inside
// any container among them — transitively, so a moved container carries nested
// containers and their contents too. Used when starting a move (drag or arrow
// nudge) so dragging a container drags the widgets sitting on it. Membership is
// snapshotted by the caller at move start; widgets are never re-parented.
export function withContainedWidgets(ids: string[], widgets: Widget[]): string[] {
const byId = new Map(widgets.map(w => [w.id, w]));
const result = new Set(ids);
let changed = true;
while (changed) {
changed = false;
for (const id of [...result]) {
const c = byId.get(id);
if (!c || c.type !== 'container') continue;
for (const w of widgets) {
if (w.id !== c.id && !result.has(w.id) && centreInside(w, c)) {
result.add(w.id);
changed = true;
}
}
}
}
return [...result];
}
// The tab index a widget is assigned to (option 'tab', default 0).
export function widgetTab(w: Widget): number {
return parseInt(w.options['tab'] ?? '0', 10) || 0;
}
// Container panes operating in tabbed mode.
export function tabPanes(widgets: Widget[]): Widget[] {
return widgets.filter(w => w.type === 'container' && w.options['variant'] === 'tabs');
}
// The tabbed container that geometrically contains this widget, if any.
export function containingTabPane(w: Widget, widgets: Widget[]): Widget | null {
return tabPanes(widgets).find(c => c.id !== w.id && centreInside(w, c)) ?? null;
}
// Parse the comma-separated tab labels of a tabbed container (never empty).
export function tabLabels(c: Widget): string[] {
const t = (c.options['tabs'] ?? 'Tab 1,Tab 2')
.split(',').map(s => s.trim()).filter(s => s !== '');
return t.length ? t : ['Tab 1'];
}
+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));
}
+91
View File
@@ -0,0 +1,91 @@
// Shared live/debug helpers for the three visual node-graph editors (LogicEditor,
// ControlLogicEditor, SyntheticGraphEditor). When an editor's "Debug" toggle is on,
// the graph is evaluated online and each node shows a small value badge plus an
// "active" highlight, refreshed at human speed (~0.5 s). The per-node values come
// from different backends per editor (synthetic = server trace endpoint, panel =
// a client-side dry-run engine, control = a server WS push), but they all funnel
// into the same `DebugSnapshot` shape and the same rendering helpers below.
import { useState, useEffect, useRef } from 'preact/hooks';
/** Live state for a single node while debug mode is on. */
export interface NodeDebugState {
value?: any; // last computed value (scalar number, array, bool, string)
type?: 'scalar' | 'array';
approx?: boolean; // value is a rough single-shot estimate (stateful DSP nodes)
error?: string; // evaluation error for this node
active?: boolean; // node fired / produced a value this tick
}
/** Per-node debug state, keyed by node id. */
export type DebugSnapshot = Map<string, NodeDebugState>;
/** Round-trips a value into a compact label for the on-node badge. */
export function formatBadge(s: NodeDebugState | undefined): string {
if (!s) return '';
if (s.error) return '!';
const v = s.value;
if (v == null) return '—';
if (Array.isArray(v)) return `[${v.length}]`;
if (typeof v === 'number') {
if (!isFinite(v)) return String(v);
if (Number.isInteger(v)) return String(v);
return v.toPrecision(4).replace(/\.?0+$/, '');
}
if (typeof v === 'boolean') return v ? 'true' : 'false';
const str = String(v);
return str.length > 10 ? str.slice(0, 9) + '…' : str;
}
/** Full, untruncated value for the badge's `title` tooltip. */
export function badgeTitle(s: NodeDebugState | undefined): string {
if (!s) return '';
if (s.error) return s.error;
const v = s.value;
if (Array.isArray(v)) return `[${v.map(n => (typeof n === 'number' ? n.toPrecision(6) : String(n))).join(', ')}]`;
return v == null ? '' : String(v);
}
/** Extra class(es) for a node element given its current debug state. */
export function nodeDebugClass(s: NodeDebugState | undefined): string {
if (!s) return '';
if (s.error) return 'flow-node-debug-error';
if (s.active) return 'flow-node-active';
return '';
}
/**
* Polls `fetcher` every `intervalMs` while `enabled`, returning the latest
* snapshot (empty map when disabled). Used by editors whose backend is a
* request/response trace (synthetic) or any pull-based source. Overlapping
* polls are prevented; a poll that resolves after the hook is disabled/unmounted
* is discarded.
*/
export function useFlowDebug(
enabled: boolean,
fetcher: () => Promise<DebugSnapshot | null>,
intervalMs = 500,
): DebugSnapshot {
const [snap, setSnap] = useState<DebugSnapshot>(() => new Map());
const fetcherRef = useRef(fetcher);
fetcherRef.current = fetcher;
useEffect(() => {
if (!enabled) { setSnap(new Map()); return; }
let alive = true;
let inFlight = false;
const tick = async () => {
if (inFlight) return;
inFlight = true;
try {
const next = await fetcherRef.current();
if (alive && next) setSnap(next);
} catch { /* transient; next tick retries */ }
finally { inFlight = false; }
};
tick();
const h = setInterval(tick, intervalMs);
return () => { alive = false; clearInterval(h); };
}, [enabled, intervalMs]);
return snap;
}
+96
View File
@@ -0,0 +1,96 @@
// Shared pan/zoom helpers for the three visual node-graph editors (LogicEditor,
// ControlLogicEditor, SyntheticGraphEditor). Zoom is implemented as a CSS
// `transform: scale()` on an inner "zoom layer"; the outer `.flow-canvas-inner`
// is sized to the scaled dimensions so the scroll container reports the right
// scrollable area. Node coordinates stay in unscaled ("logical") space — the
// editors' `toCanvas` divides pointer offsets by the current zoom.
import { useState, useRef, useEffect, useCallback } from 'preact/hooks';
export const ZOOM_MIN = 0.3;
export const ZOOM_MAX = 2.5;
export const ZOOM_FACTOR = 1.25; // multiplier per zoom-in / -out step
export const clampZoom = (z: number) => Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, z));
export interface Box { x: number; y: number; w: number; h: number; }
/** Axis-aligned bounding box over a set of rectangles (null when empty). */
export function contentBounds(rects: Box[]): Box | null {
if (rects.length === 0) return null;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const r of rects) {
minX = Math.min(minX, r.x); minY = Math.min(minY, r.y);
maxX = Math.max(maxX, r.x + r.w); maxY = Math.max(maxY, r.y + r.h);
}
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
}
interface ZoomScroll { zoom: number; scrollX: number; scrollY: number; }
/** New zoom + scroll that keeps the viewport centre fixed while scaling. */
export function zoomAnchored(z0: number, factor: number, scrollX: number, scrollY: number, vw: number, vh: number): ZoomScroll {
const z1 = clampZoom(z0 * factor);
const cx = (scrollX + vw / 2) / z0;
const cy = (scrollY + vh / 2) / z0;
return { zoom: z1, scrollX: Math.max(0, cx * z1 - vw / 2), scrollY: Math.max(0, cy * z1 - vh / 2) };
}
/** Zoom + scroll so `content` is centred and fits within the viewport. */
export function zoomToFit(content: Box, vw: number, vh: number, pad = 48): ZoomScroll {
const z = clampZoom(Math.min(vw / (content.w + 2 * pad), vh / (content.h + 2 * pad)));
return {
zoom: z,
scrollX: Math.max(0, (content.x + content.w / 2) * z - vw / 2),
scrollY: Math.max(0, (content.y + content.h / 2) * z - vh / 2),
};
}
/**
* Zoom controller shared by all three editors. `zoomRef` is kept in sync with
* the live zoom so each editor's `toCanvas` can read it without stale closures.
* `getRects` returns the current node rectangles (logical coords) for fit.
*/
export function useFlowZoom(
canvasRef: { current: HTMLDivElement | null },
zoomRef: { current: number },
baseW: number,
baseH: number,
getRects: () => Box[],
) {
const [zoom, setZoom] = useState(1);
zoomRef.current = zoom;
// Scroll target applied after the zoomed layout commits.
const pending = useRef<{ x: number; y: number } | null>(null);
useEffect(() => {
const el = canvasRef.current;
if (el && pending.current) {
el.scrollLeft = pending.current.x;
el.scrollTop = pending.current.y;
pending.current = null;
}
}, [zoom]);
const apply = (r: ZoomScroll) => { pending.current = { x: r.scrollX, y: r.scrollY }; setZoom(r.zoom); };
const zoomIn = useCallback(() => {
const el = canvasRef.current; if (!el) return;
apply(zoomAnchored(zoomRef.current, ZOOM_FACTOR, el.scrollLeft, el.scrollTop, el.clientWidth, el.clientHeight));
}, []);
const zoomOut = useCallback(() => {
const el = canvasRef.current; if (!el) return;
apply(zoomAnchored(zoomRef.current, 1 / ZOOM_FACTOR, el.scrollLeft, el.scrollTop, el.clientWidth, el.clientHeight));
}, []);
const zoomHome = useCallback(() => { apply({ zoom: 1, scrollX: 0, scrollY: 0 }); }, []);
const zoomFit = useCallback(() => {
const el = canvasRef.current; if (!el) return;
const b = contentBounds(getRects());
if (!b) { apply({ zoom: 1, scrollX: 0, scrollY: 0 }); return; }
apply(zoomToFit(b, el.clientWidth, el.clientHeight));
}, [getRects]);
// Outer sizer reserves the scaled scroll area; inner layer carries the scale.
const innerStyle = `width:${baseW * zoom}px; height:${baseH * zoom}px;`;
const zoomStyle = `width:${baseW}px; height:${baseH}px; transform: scale(${zoom}); transform-origin: 0 0;`;
return { zoom, zoomIn, zoomOut, zoomHome, zoomFit, innerStyle, zoomStyle };
}
+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;
}
+230 -10
View File
@@ -75,6 +75,88 @@ function sleep(ms: number): Promise<void> {
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 {
if (typeof v === 'number') return v;
if (typeof v === 'boolean') return v ? 1 : 0;
@@ -122,7 +204,7 @@ interface WireOut { to: string; port: string }
// expression resolver that exposes the system signals {sys:time}/{sys:dt}.
interface RunCtx { firedTrigger: string; steps: { n: number }; dt: number; resolve: Resolver }
class LogicEngine {
export class LogicEngine {
private graph: LogicGraph = { nodes: [], wires: [] };
private byId = new Map<string, LogicNode>();
// Outgoing wires grouped by source node id.
@@ -145,6 +227,37 @@ class LogicEngine {
private lastFire = new Map<string, number>();
private cleanups: Array<() => void> = [];
// Dry-run / debug mode: when true the engine performs no external side effects
// (signal writes, config mutations, CSV exports, widget commands, dialogs) and
// instead records each node's last value/firing time so an editor overlay can
// visualise the flow without touching production. Used by a throwaway engine
// instance spun up by the Logic editor; the view-mode singleton leaves it off.
private dryRun = false;
private debugStates = new Map<string, { value: any; ts: number }>();
/** Enable/disable dry-run mode. Must be set before load(). */
setDryRun(on: boolean): void { this.dryRun = on; }
// Record that a node fired this tick, optionally capturing its computed value.
private markActive(id: string, value?: any): void {
if (!this.dryRun) return;
const prev = this.debugStates.get(id);
this.debugStates.set(id, { value: value !== undefined ? value : prev?.value, ts: Date.now() });
}
/** Per-node debug snapshot: a node is "active" if it fired within ~0.8 s. */
getDebug(): Map<string, { value: any; active: boolean }> {
const now = Date.now();
const out = new Map<string, { value: any; active: boolean }>();
for (const [id, s] of this.debugStates) out.set(id, { value: s.value, active: now - s.ts < 800 });
return out;
}
// Suppress signal writes in dry-run; otherwise forward to the live client.
private dryWrite(ref: SignalRef, val: any): void {
if (!this.dryRun) wsClient.write(ref, val);
}
// Resolve an expression reference. Two built-in system signals are served
// synthetically (never subscribed): {sys:time} = current epoch seconds,
// {sys:dt} = seconds since the firing trigger last fired. Everything else
@@ -158,6 +271,20 @@ class LogicEngine {
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. */
load(graph: LogicGraph | undefined): void {
this.clear();
@@ -224,9 +351,11 @@ class LogicEngine {
this.watchers = new Map();
this.arrays = new Map();
this.lastFire = new Map();
this.debugStates = new Map();
// Restore every widget to its default state so reopening a panel (whose
// widget ids are stable) does not inherit disabled/hidden/paused flags.
resetWidgetCmds();
// widget ids are stable) does not inherit disabled/hidden/paused flags. The
// dry-run editor engine must not touch the real widget command stores.
if (!this.dryRun) resetWidgetCmds();
}
/** Fire every button-trigger node whose `name` param matches. */
@@ -266,6 +395,18 @@ class LogicEngine {
case 'action.write':
case 'action.accumulate':
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.error':
case 'action.dialog.setpoint': {
@@ -327,12 +468,22 @@ class LogicEngine {
// ── Execution ───────────────────────────────────────────────────────────────
/** Manually fire a trigger node (debug mode: double-click to force a flow
* run). No-op if the id isn't a trigger node. */
fireTrigger(nodeId: string): boolean {
const node = this.byId.get(nodeId);
if (!node || !node.kind.startsWith('trigger.')) return false;
this.activate(nodeId);
return true;
}
// A trigger activated: walk its outgoing 'out' wires.
private activate(triggerId: string): void {
const now = Date.now();
const last = this.lastFire.get(triggerId);
const dt = last === undefined ? 0 : (now - last) / 1000;
this.lastFire.set(triggerId, now);
this.markActive(triggerId);
const ctx: RunCtx = {
firedTrigger: triggerId,
steps: { n: 0 },
@@ -354,15 +505,20 @@ class LogicEngine {
if (ctx.steps.n++ > MAX_STEPS) return;
const node = this.byId.get(nodeId);
if (!node) return;
this.markActive(node.id);
switch (node.kind) {
case 'gate.and':
if (this.gateSatisfied(node.id, ctx.firedTrigger)) await this.follow(node.id, 'out', ctx);
case 'gate.and': {
const ok = this.gateSatisfied(node.id, ctx.firedTrigger);
this.markActive(node.id, ok);
if (ok) await this.follow(node.id, 'out', ctx);
return;
}
case 'flow.if': {
const branch = evalBool(node.params.cond ?? '', ctx.resolve) ? 'then' : 'else';
await this.follow(node.id, branch, ctx);
const pass = evalBool(node.params.cond ?? '', ctx.resolve);
this.markActive(node.id, pass);
await this.follow(node.id, pass ? 'then' : 'else', ctx);
return;
}
@@ -385,7 +541,67 @@ class LogicEngine {
case 'action.write': {
const ref = parseRef(node.params.target ?? '');
const val = evalExpr(node.params.expr ?? '', ctx.resolve);
if (ref && !isNaN(val)) wsClient.write(ref, val);
this.markActive(node.id, val);
if (ref && !isNaN(val)) this.dryWrite(ref, val);
await this.follow(node.id, 'out', ctx);
return;
}
case 'action.config.apply': {
const id = this.resolveInstanceId(node);
if (id && !this.dryRun) {
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 && !this.dryRun) {
const val = await readConfigParam(id, key);
if (val !== null && !isNaN(val)) this.dryWrite(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) && !this.dryRun) {
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 && !this.dryRun) {
const newId = await createConfigInstance(setId, name, fromId);
if (newId && target) this.dryWrite(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 && !this.dryRun) {
const newId = await snapshotConfigSet(setId, name);
if (newId && target) this.dryWrite(target, newId);
}
await this.follow(node.id, 'out', ctx);
return;
}
@@ -398,6 +614,7 @@ class LogicEngine {
case 'action.accumulate': {
const name = (node.params.array ?? '').trim();
const val = evalExpr(node.params.expr ?? '', ctx.resolve);
this.markActive(node.id, val);
if (name && !isNaN(val)) {
const arr = this.arrays.get(name) ?? this.arrays.set(name, []).get(name)!;
arr.push({ t: Date.now(), v: val });
@@ -407,7 +624,7 @@ class LogicEngine {
}
case 'action.export': {
this.exportArrays(this.exportColumns(node), node.params.align ?? 'common', node.params.filename ?? '');
if (!this.dryRun) this.exportArrays(this.exportColumns(node), node.params.align ?? 'common', node.params.filename ?? '');
await this.follow(node.id, 'out', ctx);
return;
}
@@ -422,6 +639,7 @@ class LogicEngine {
case 'action.log': {
const val = evalExpr(node.params.expr ?? '', ctx.resolve);
const label = (node.params.label ?? '').trim();
this.markActive(node.id, val);
console.log(`[logic]${label ? ' ' + label + ':' : ''}`, val);
await this.follow(node.id, 'out', ctx);
return;
@@ -429,7 +647,7 @@ class LogicEngine {
case 'action.widget': {
const wid = (node.params.widget ?? '').trim();
if (wid) {
if (wid && !this.dryRun) {
switch (node.params.op ?? '') {
case 'enable': setWidgetDisabled(wid, false); break;
case 'disable': setWidgetDisabled(wid, true); break;
@@ -446,6 +664,7 @@ class LogicEngine {
case 'action.dialog.info':
case 'action.dialog.error': {
if (this.dryRun) { await this.follow(node.id, 'out', ctx); return; }
const specs = this.dialogFieldSpecs(node);
await this.showDialog({
kind: node.kind === 'action.dialog.error' ? 'error' : 'info',
@@ -458,6 +677,7 @@ class LogicEngine {
}
case 'action.dialog.setpoint': {
if (this.dryRun) { await this.follow(node.id, 'out', ctx); return; }
const specs = this.dialogFieldSpecs(node);
const result = await this.showDialog({
kind: 'setpoint',
+71
View File
@@ -0,0 +1,71 @@
// Node grouping & collapsing — shared model + geometry used by all three visual
// node editors (LogicEditor, ControlLogicEditor, SyntheticGraphEditor).
//
// A group is purely a cosmetic, editor-side annotation: it references member
// node ids, carries an optional label, and can be collapsed to hide its members
// behind a compact box. Groups never change how a graph evaluates — the backends
// store and round-trip them but ignore them when compiling/running the graph.
export interface NodeGroup {
id: string;
label: string;
members: string[]; // ids of member nodes
collapsed: boolean;
}
export interface Rect { x: number; y: number; w: number; h: number; }
export function genGroupId(): string {
return 'grp_' + Math.random().toString(36).slice(2, 9);
}
// The group (if any) that a node belongs to. A node lives in at most one group.
export function groupContaining(
groups: NodeGroup[] | undefined,
nodeId: string,
): NodeGroup | undefined {
return (groups ?? []).find(g => g.members.includes(nodeId));
}
// Drop ids that no longer exist (deleted nodes) and discard groups left empty.
export function pruneGroups(
groups: NodeGroup[] | undefined,
existingIds: Set<string>,
): NodeGroup[] {
return (groups ?? [])
.map(g => ({ ...g, members: g.members.filter(id => existingIds.has(id)) }))
.filter(g => g.members.length > 0);
}
// Axis-aligned bounding box around the member node rects, expanded by `pad` on
// every side and by `header` extra pixels on top (room for the title bar).
// Returns null when no member resolves to a rect.
export function groupBounds(
members: string[],
rectOf: (id: string) => Rect | null,
pad: number,
header: number,
): Rect | null {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const id of members) {
const r = rectOf(id);
if (!r) continue;
if (r.x < minX) minX = r.x;
if (r.y < minY) minY = r.y;
if (r.x + r.w > maxX) maxX = r.x + r.w;
if (r.y + r.h > maxY) maxY = r.y + r.h;
}
if (!isFinite(minX)) return null;
return {
x: minX - pad,
y: minY - pad - header,
w: (maxX - minX) + pad * 2,
h: (maxY - minY) + pad * 2 + header,
};
}
// The compact box shown in place of a collapsed group, anchored at the group's
// top-left corner.
export function collapsedRect(bounds: Rect, width: number, height: number): Rect {
return { x: bounds.x, y: bounds.y, w: width, h: height };
}
+37
View File
@@ -0,0 +1,37 @@
// Cross-plot synchronisation for time-series plot widgets.
//
// Two things are shared across all *linked* timeseries plots:
// 1. The uPlot cursor crosshair, via uPlot's native cursor.sync keyed on
// PLOT_SYNC_KEY (hovering one plot draws the matching-time crosshair on the
// others). This works in both live and historical mode.
// 2. The visible x (time) range, for zoom/pan sync in historical mode — when a
// user zooms one plot, the same [min,max] is applied to the others. Live
// mode keeps its own rolling window (auto-scroll), so range sync is only
// brokered here for historical views.
//
// A plot can opt out of both via its toolbar "link" toggle.
export const PLOT_SYNC_KEY = 'uopi-plots';
export interface XRange {
min: number;
max: number;
}
type RangeListener = (r: XRange) => void;
const rangeListeners = new Map<string, RangeListener>();
export function subscribeXRange(id: string, fn: RangeListener): () => void {
rangeListeners.set(id, fn);
return () => {
rangeListeners.delete(id);
};
}
// Broadcast a new visible x-range to every linked plot except the originator.
export function broadcastXRange(origin: string, r: XRange) {
rangeListeners.forEach((fn, id) => {
if (id !== origin) fn(r);
});
}
+105
View File
@@ -0,0 +1,105 @@
// 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' };
}
// portAccept reports the data type an op's input ports accept:
// 'array' — reductions & array producers require an array input
// 'scalar' — stateful filters & lua require a scalar input
// 'any' — elementwise stateless ops accept either type
// Used to colour input ports and to reject definitely-incompatible wirings.
export function portAccept(op: string): SynthType | 'any' {
if (REDUCTION_OPS.has(op) || ARRAY_PRODUCER_OPS.has(op)) return 'array';
if (SCALAR_ONLY_OPS.has(op)) return 'scalar';
return 'any';
}
// typesCompatible reports whether a wire carrying `from` may terminate on a port
// accepting `accept`. 'unknown' is always allowed (runtime typing is final), as
// is an 'any' port — only a definite scalar↔array mismatch is rejected.
export function typesCompatible(from: SynthType, accept: SynthType | 'any'): boolean {
if (accept === 'any' || from === 'unknown') return true;
return from === accept;
}
// 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 };
}

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