Compare commits
11 Commits
main
...
ac24011487
| Author | SHA1 | Date | |
|---|---|---|---|
| ac24011487 | |||
| 73fcbe7b28 | |||
| 113e5a0fe8 | |||
| 04d31a15c4 | |||
| b0ac044035 | |||
| 3fc7c1b546 | |||
| 206d5b541d | |||
| f7f297c3df | |||
| 446de7f1ee | |||
| 901b87d407 | |||
| 8f6dbcba49 |
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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` (1–8) | Cascaded IIR Butterworth-style low-pass filter |
|
||||
| `formula` | `expr` | Inline math expression (variables: `a`, `b`, …) |
|
||||
| `lua` | `script` | Arbitrary Lua 5.1 code with persistent state |
|
||||
| Node type | Parameters | In→Out | Description |
|
||||
| ---------------- | --------------------------- | -------------- | ----------------------------------------------- |
|
||||
| `source` | `ds`, `name` | — | Reads a signal from any data source |
|
||||
| `gain` | `gain` | elementwise | Multiplies by a constant |
|
||||
| `offset` | `offset` | elementwise | Adds a constant |
|
||||
| `add`/`subtract` | — | elementwise | Sum of inputs / `a − b` |
|
||||
| `multiply`/`divide` | — | elementwise | Product of inputs / `a ÷ b` |
|
||||
| `clamp` | `min`, `max` | elementwise | Constrains to a range |
|
||||
| `threshold` | `threshold`, `high`, `low` | elementwise | Comparator output |
|
||||
| `moving_average` | `window` (samples) | scalar-only | Rolling mean |
|
||||
| `rms` | `window` (samples) | scalar-only | Rolling RMS |
|
||||
| `derivative` | — | scalar-only | Time derivative (per-sample `dt`) |
|
||||
| `integrate` | — | scalar-only | Trapezoidal integral |
|
||||
| `lowpass` | `freq` (Hz), `order` (1–8) | scalar-only | Cascaded IIR Butterworth-style low-pass filter |
|
||||
| `expr` | `expr`, `vars` | elementwise | Inline math expression (named inputs) |
|
||||
| `lua` | `script`, `vars` | scalar-only | Arbitrary Lua 5.1 code with persistent state |
|
||||
| `index` | `i` | array→scalar | Element `i` of a waveform (bounds-checked) |
|
||||
| `slice` | `start`, `end` | array→array | Sub-range of a waveform (clamped) |
|
||||
| `sum`/`mean` | — | array→scalar | Σ / average of a waveform |
|
||||
| `min`/`max` | — | array→scalar | Reduction of a waveform |
|
||||
| `length` | — | array→scalar | Element count of a waveform |
|
||||
| `fft` | — | array→array | Magnitude spectrum (zero-padded to next pow-2) |
|
||||
|
||||
**Scalar vs waveform values:** A value flowing through the graph is a `dsp.Sample` — either a scalar `float64` or a `[]float64` waveform (the array-aware counterpart of EPICS `TypeFloat64Array`). *Elementwise* ops broadcast over arrays (scalar inputs act as constants; array inputs must share a length). *Reduction*/*producer* ops (`index`/`slice`/`sum`/…/`fft`) operate natively on waveforms. *Scalar-only* ops (stateful filters + `lua`) reject array inputs, since their per-evaluation state cannot be split across array lanes. `OpOutputType` (`internal/dsp/types.go`) propagates types statically at compile time to reject invalid wirings and to report the synthetic's metadata type; the editor mirrors these rules in `web/src/lib/synthTypes.ts` to colour wires by data type (scalar vs array) and flag type-incompatible links. Runtime `Sample` typing is authoritative.
|
||||
|
||||
**Low-pass filter implementation:** Cascaded first-order IIR sections. Each stage computes `y = y_prev + α·(x − y_prev)` where `α = dt / (RC + dt)` and `RC = 1/(2π·fc)`. `dt` is computed per sample from source timestamps so the filter is correct for event-driven (non-uniform) data.
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
+604
-107
@@ -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,79 +120,217 @@ 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
|
||||
}
|
||||
|
||||
// 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 global access configuration. It is immutable after
|
||||
// construction and safe for concurrent use.
|
||||
// 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),
|
||||
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
|
||||
}
|
||||
p.userGroups[m] = append(p.userGroups[m], g)
|
||||
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
|
||||
}
|
||||
}
|
||||
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).
|
||||
@@ -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{}
|
||||
|
||||
|
||||
+252
-21
@@ -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 p.Level(u) != LevelWrite {
|
||||
t.Errorf("unconfigured: Level(%q) = %v, want write", u, p.Level(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")
|
||||
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(""))
|
||||
}
|
||||
cases := map[string]bool{
|
||||
"": true, // anonymous / trusted LAN
|
||||
"alice": true, // listed user
|
||||
"carol": true, // member of listed group "ops"
|
||||
"bob": false, // not listed
|
||||
if p.Level("nobody") != LevelRead {
|
||||
t.Errorf("unlisted Level = %v, want read", p.Level("nobody"))
|
||||
}
|
||||
for u, want := range cases {
|
||||
if got := p.CanEditLogic(u); got != want {
|
||||
t.Errorf("CanEditLogic(%q) = %v, want %v", u, got, want)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
+565
-9
@@ -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
|
||||
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
@@ -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
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestConfigSetCRUD exercises create → get → version → apply over HTTP, using
|
||||
// the stub data source's writable "setpoint" signal as the apply target.
|
||||
func TestConfigManagerEndToEnd(t *testing.T) {
|
||||
srv, teardown := setup(t)
|
||||
defer teardown()
|
||||
|
||||
// Create a config set targeting the stub's writable setpoint.
|
||||
setBody := map[string]any{
|
||||
"name": "Stub PSU",
|
||||
"parameters": []map[string]any{
|
||||
{"key": "sp", "ds": "stub", "signal": "setpoint", "type": "float64", "default": 42.0, "mandatory": true, "min": 0.0, "max": 100.0},
|
||||
},
|
||||
}
|
||||
resp := postJSON(t, srv, "/api/v1/config/sets", setBody)
|
||||
assertStatus(t, resp, http.StatusCreated)
|
||||
var set struct {
|
||||
ID string `json:"id"`
|
||||
Version int `json:"version"`
|
||||
}
|
||||
readJSON(t, resp, &set)
|
||||
if set.ID == "" || set.Version != 1 {
|
||||
t.Fatalf("unexpected created set: %+v", set)
|
||||
}
|
||||
|
||||
// List sets includes it.
|
||||
resp = get(t, srv, "/api/v1/config/sets")
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
var sets []map[string]any
|
||||
readJSON(t, resp, &sets)
|
||||
if len(sets) != 1 {
|
||||
t.Fatalf("want 1 set, got %d", len(sets))
|
||||
}
|
||||
|
||||
// Create an instance assigning a concrete value.
|
||||
instBody := map[string]any{
|
||||
"name": "nominal",
|
||||
"setId": set.ID,
|
||||
"values": map[string]any{"sp": 73.0},
|
||||
}
|
||||
resp = postJSON(t, srv, "/api/v1/config/instances", instBody)
|
||||
assertStatus(t, resp, http.StatusCreated)
|
||||
var inst struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
readJSON(t, resp, &inst)
|
||||
if inst.ID == "" {
|
||||
t.Fatal("instance missing id")
|
||||
}
|
||||
|
||||
// Apply writes the value to the target signal.
|
||||
resp = postJSON(t, srv, "/api/v1/config/instances/"+inst.ID+"/apply", nil)
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
var res struct {
|
||||
Applied int `json:"applied"`
|
||||
Failed int `json:"failed"`
|
||||
Entries []struct {
|
||||
Signal string `json:"signal"`
|
||||
OK bool `json:"ok"`
|
||||
} `json:"entries"`
|
||||
}
|
||||
readJSON(t, resp, &res)
|
||||
if res.Applied != 1 || res.Failed != 0 {
|
||||
t.Fatalf("apply summary: applied=%d failed=%d", res.Applied, res.Failed)
|
||||
}
|
||||
if len(res.Entries) != 1 || res.Entries[0].Signal != "setpoint" || !res.Entries[0].OK {
|
||||
t.Errorf("unexpected apply entries: %+v", res.Entries)
|
||||
}
|
||||
|
||||
// Delete the set soft-deletes it.
|
||||
resp = deleteReq(t, srv, "/api/v1/config/sets/"+set.ID)
|
||||
assertStatus(t, resp, http.StatusNoContent)
|
||||
resp = get(t, srv, "/api/v1/config/sets/"+set.ID)
|
||||
assertStatus(t, resp, http.StatusNotFound)
|
||||
}
|
||||
|
||||
// TestConfigInstanceRejectsInvalidValue verifies server-side validation against
|
||||
// the bound set (value above the parameter's max).
|
||||
func TestConfigInstanceRejectsInvalidValue(t *testing.T) {
|
||||
srv, teardown := setup(t)
|
||||
defer teardown()
|
||||
|
||||
resp := postJSON(t, srv, "/api/v1/config/sets", map[string]any{
|
||||
"name": "S",
|
||||
"parameters": []map[string]any{
|
||||
{"key": "sp", "ds": "stub", "signal": "setpoint", "type": "float64", "max": 10.0},
|
||||
},
|
||||
})
|
||||
assertStatus(t, resp, http.StatusCreated)
|
||||
var set struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
readJSON(t, resp, &set)
|
||||
|
||||
resp = postJSON(t, srv, "/api/v1/config/instances", map[string]any{
|
||||
"name": "bad",
|
||||
"setId": set.ID,
|
||||
"values": map[string]any{"sp": 999.0},
|
||||
})
|
||||
assertStatus(t, resp, http.StatusBadRequest)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// Package audit records system-affecting actions (signal writes by users or the
|
||||
// control-logic engine, interface and control-logic mutations) to an append-only
|
||||
// SQLite log that audit staff can query later. It is enabled via [audit] in the
|
||||
// config; when disabled a no-op Recorder is used so call sites need no guards.
|
||||
package audit
|
||||
|
||||
import "time"
|
||||
|
||||
// Actor types distinguish human-initiated actions from automated ones.
|
||||
const (
|
||||
ActorUser = "user" // action attributed to an authenticated end-user
|
||||
ActorSystem = "system" // action performed by the control-logic engine
|
||||
)
|
||||
|
||||
// Outcomes record whether the action succeeded.
|
||||
const (
|
||||
OutcomeOK = "ok"
|
||||
OutcomeError = "error"
|
||||
)
|
||||
|
||||
// Event is a single audit record. Optional fields are omitted from the database
|
||||
// when empty. Time defaults to the current time when zero.
|
||||
type Event struct {
|
||||
Time time.Time `json:"time"`
|
||||
Actor string `json:"actor"` // username, or graph name for system actions
|
||||
ActorType string `json:"actorType"` // ActorUser | ActorSystem
|
||||
Action string `json:"action"` // e.g. "signal.write", "interface.update"
|
||||
DS string `json:"ds,omitempty"` // data source (signal writes)
|
||||
Signal string `json:"signal,omitempty"` // signal / target name
|
||||
Value string `json:"value,omitempty"` // serialised written value
|
||||
Detail string `json:"detail,omitempty"` // free-form context (id, graph, trigger)
|
||||
IP string `json:"ip,omitempty"` // client address (user actions)
|
||||
Outcome string `json:"outcome"` // OutcomeOK | OutcomeError
|
||||
Error string `json:"error,omitempty"` // message when Outcome==OutcomeError
|
||||
}
|
||||
|
||||
// Filter selects a subset of events for Query. Zero-valued fields are ignored.
|
||||
type Filter struct {
|
||||
Start time.Time
|
||||
End time.Time
|
||||
Actor string
|
||||
Action string
|
||||
DS string
|
||||
Signal string
|
||||
Limit int // <=0 means a default cap is applied
|
||||
}
|
||||
|
||||
// Recorder appends events and answers queries. Implementations must be safe for
|
||||
// concurrent use and Record must never block the caller for long.
|
||||
type Recorder interface {
|
||||
Record(Event)
|
||||
Query(Filter) ([]Event, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
// nopRecorder is used when auditing is disabled. It discards every event.
|
||||
type nopRecorder struct{}
|
||||
|
||||
func (nopRecorder) Record(Event) {}
|
||||
func (nopRecorder) Query(Filter) ([]Event, error) { return []Event{}, nil }
|
||||
func (nopRecorder) Close() error { return nil }
|
||||
|
||||
// Nop returns a Recorder that discards everything. Call sites can hold a Recorder
|
||||
// unconditionally and call Record without checking whether auditing is enabled.
|
||||
func Nop() Recorder { return nopRecorder{} }
|
||||
@@ -0,0 +1,183 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite" // pure-Go SQLite driver (no CGo, keeps the single static binary)
|
||||
)
|
||||
|
||||
// defaultQueryLimit caps how many rows Query returns when the filter does not
|
||||
// specify a smaller limit, protecting the API and UI from unbounded result sets.
|
||||
const defaultQueryLimit = 1000
|
||||
|
||||
// writeBuffer is the depth of the async insert queue. When full, Record falls
|
||||
// back to a synchronous insert so events are never silently dropped.
|
||||
const writeBuffer = 4096
|
||||
|
||||
// sqliteRecorder appends events to a SQLite database. Inserts are normally
|
||||
// handled by a background goroutine so Record does not block the calling write
|
||||
// path; the queue has a synchronous fallback to guarantee durability under load.
|
||||
type sqliteRecorder struct {
|
||||
db *sql.DB
|
||||
log *slog.Logger
|
||||
|
||||
ch chan Event
|
||||
wg sync.WaitGroup
|
||||
closed chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
// NewSQLite opens (creating if needed) the audit database at path and starts the
|
||||
// background writer. The returned Recorder must be Closed on shutdown.
|
||||
func NewSQLite(path string, log *slog.Logger) (Recorder, error) {
|
||||
// WAL + a busy timeout let the async writer and synchronous query/fallback
|
||||
// paths share the file without "database is locked" errors.
|
||||
dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)", path)
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open audit db: %w", err)
|
||||
}
|
||||
if _, err := db.Exec(schema); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("init audit schema: %w", err)
|
||||
}
|
||||
r := &sqliteRecorder{
|
||||
db: db,
|
||||
log: log,
|
||||
ch: make(chan Event, writeBuffer),
|
||||
closed: make(chan struct{}),
|
||||
}
|
||||
r.wg.Add(1)
|
||||
go r.writeLoop()
|
||||
return r, nil
|
||||
}
|
||||
|
||||
const schema = `
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts_ns INTEGER NOT NULL,
|
||||
actor TEXT NOT NULL,
|
||||
actor_type TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
ds TEXT NOT NULL DEFAULT '',
|
||||
signal TEXT NOT NULL DEFAULT '',
|
||||
value TEXT NOT NULL DEFAULT '',
|
||||
detail TEXT NOT NULL DEFAULT '',
|
||||
ip TEXT NOT NULL DEFAULT '',
|
||||
outcome TEXT NOT NULL DEFAULT 'ok',
|
||||
error TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit_log(ts_ns);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_actor ON audit_log(actor);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log(action);
|
||||
`
|
||||
|
||||
func (r *sqliteRecorder) Record(e Event) {
|
||||
if e.Time.IsZero() {
|
||||
e.Time = time.Now()
|
||||
}
|
||||
if e.Outcome == "" {
|
||||
e.Outcome = OutcomeOK
|
||||
}
|
||||
select {
|
||||
case <-r.closed:
|
||||
// Recorder is shutting down; best-effort synchronous insert.
|
||||
r.insert(e)
|
||||
case r.ch <- e:
|
||||
default:
|
||||
// Queue full: write synchronously rather than drop an audit record.
|
||||
r.insert(e)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *sqliteRecorder) writeLoop() {
|
||||
defer r.wg.Done()
|
||||
for e := range r.ch {
|
||||
r.insert(e)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *sqliteRecorder) insert(e Event) {
|
||||
_, err := r.db.Exec(
|
||||
`INSERT INTO audit_log (ts_ns, actor, actor_type, action, ds, signal, value, detail, ip, outcome, error)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
e.Time.UnixNano(), e.Actor, e.ActorType, e.Action,
|
||||
e.DS, e.Signal, e.Value, e.Detail, e.IP, e.Outcome, e.Error,
|
||||
)
|
||||
if err != nil {
|
||||
r.log.Error("audit: insert failed", "action", e.Action, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *sqliteRecorder) Query(f Filter) ([]Event, error) {
|
||||
var where []string
|
||||
var args []any
|
||||
if !f.Start.IsZero() {
|
||||
where = append(where, "ts_ns >= ?")
|
||||
args = append(args, f.Start.UnixNano())
|
||||
}
|
||||
if !f.End.IsZero() {
|
||||
where = append(where, "ts_ns <= ?")
|
||||
args = append(args, f.End.UnixNano())
|
||||
}
|
||||
if f.Actor != "" {
|
||||
where = append(where, "actor = ?")
|
||||
args = append(args, f.Actor)
|
||||
}
|
||||
if f.Action != "" {
|
||||
where = append(where, "action = ?")
|
||||
args = append(args, f.Action)
|
||||
}
|
||||
if f.DS != "" {
|
||||
where = append(where, "ds = ?")
|
||||
args = append(args, f.DS)
|
||||
}
|
||||
if f.Signal != "" {
|
||||
where = append(where, "signal LIKE ?")
|
||||
args = append(args, "%"+f.Signal+"%")
|
||||
}
|
||||
limit := f.Limit
|
||||
if limit <= 0 || limit > defaultQueryLimit {
|
||||
limit = defaultQueryLimit
|
||||
}
|
||||
|
||||
q := "SELECT ts_ns, actor, actor_type, action, ds, signal, value, detail, ip, outcome, error FROM audit_log"
|
||||
if len(where) > 0 {
|
||||
q += " WHERE " + strings.Join(where, " AND ")
|
||||
}
|
||||
q += " ORDER BY ts_ns DESC LIMIT ?"
|
||||
args = append(args, limit)
|
||||
|
||||
rows, err := r.db.Query(q, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query audit log: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []Event{}
|
||||
for rows.Next() {
|
||||
var e Event
|
||||
var tsNs int64
|
||||
if err := rows.Scan(&tsNs, &e.Actor, &e.ActorType, &e.Action,
|
||||
&e.DS, &e.Signal, &e.Value, &e.Detail, &e.IP, &e.Outcome, &e.Error); err != nil {
|
||||
return nil, fmt.Errorf("scan audit row: %w", err)
|
||||
}
|
||||
e.Time = time.Unix(0, tsNs)
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *sqliteRecorder) Close() error {
|
||||
r.once.Do(func() {
|
||||
close(r.closed)
|
||||
close(r.ch)
|
||||
})
|
||||
r.wg.Wait()
|
||||
return r.db.Close()
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSQLiteRoundTrip(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "audit.db")
|
||||
rec, err := NewSQLite(path, slog.Default())
|
||||
if err != nil {
|
||||
t.Fatal("NewSQLite:", err)
|
||||
}
|
||||
defer rec.Close()
|
||||
|
||||
base := time.Now()
|
||||
rec.Record(Event{Time: base, Actor: "alice", ActorType: ActorUser, Action: "signal.write", DS: "epics", Signal: "PV:A", Value: "1.5"})
|
||||
rec.Record(Event{Time: base.Add(time.Second), Actor: "flow1", ActorType: ActorSystem, Action: "signal.write", DS: "epics", Signal: "PV:B", Value: "0"})
|
||||
rec.Record(Event{Time: base.Add(2 * time.Second), Actor: "bob", ActorType: ActorUser, Action: "interface.update", Detail: "panel-1", Outcome: OutcomeError, Error: "denied"})
|
||||
|
||||
// Flush the async writer.
|
||||
if err := rec.Close(); err != nil {
|
||||
t.Fatal("Close:", err)
|
||||
}
|
||||
rec, err = NewSQLite(path, slog.Default())
|
||||
if err != nil {
|
||||
t.Fatal("reopen:", err)
|
||||
}
|
||||
defer rec.Close()
|
||||
|
||||
all, err := rec.Query(Filter{})
|
||||
if err != nil {
|
||||
t.Fatal("Query:", err)
|
||||
}
|
||||
if len(all) != 3 {
|
||||
t.Fatalf("got %d events, want 3", len(all))
|
||||
}
|
||||
// Newest first.
|
||||
if all[0].Actor != "bob" {
|
||||
t.Errorf("first actor = %q, want bob", all[0].Actor)
|
||||
}
|
||||
|
||||
byActor, err := rec.Query(Filter{Actor: "alice"})
|
||||
if err != nil {
|
||||
t.Fatal("Query actor:", err)
|
||||
}
|
||||
if len(byActor) != 1 || byActor[0].Signal != "PV:A" {
|
||||
t.Errorf("actor filter = %+v, want one PV:A event", byActor)
|
||||
}
|
||||
|
||||
byAction, err := rec.Query(Filter{Action: "signal.write"})
|
||||
if err != nil {
|
||||
t.Fatal("Query action:", err)
|
||||
}
|
||||
if len(byAction) != 2 {
|
||||
t.Errorf("action filter returned %d, want 2", len(byAction))
|
||||
}
|
||||
|
||||
since, err := rec.Query(Filter{Start: base.Add(1500 * time.Millisecond)})
|
||||
if err != nil {
|
||||
t.Fatal("Query start:", err)
|
||||
}
|
||||
if len(since) != 1 || since[0].Actor != "bob" {
|
||||
t.Errorf("time filter = %+v, want one bob event", since)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
+34
-23
@@ -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 {
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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...)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package confmgr
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// ChangeStatus classifies a single diff entry.
|
||||
type ChangeStatus string
|
||||
|
||||
const (
|
||||
StatusAdded ChangeStatus = "added"
|
||||
StatusRemoved ChangeStatus = "removed"
|
||||
StatusChanged ChangeStatus = "changed"
|
||||
StatusUnchanged ChangeStatus = "unchanged"
|
||||
)
|
||||
|
||||
// SetDiffEntry is one parameter-level difference between two config sets.
|
||||
type SetDiffEntry struct {
|
||||
Key string `json:"key"`
|
||||
Status ChangeStatus `json:"status"`
|
||||
Left *Parameter `json:"left,omitempty"`
|
||||
Right *Parameter `json:"right,omitempty"`
|
||||
}
|
||||
|
||||
// InstanceDiffEntry is one value-level difference between two config instances.
|
||||
type InstanceDiffEntry struct {
|
||||
Key string `json:"key"`
|
||||
Status ChangeStatus `json:"status"`
|
||||
Left any `json:"left,omitempty"`
|
||||
Right any `json:"right,omitempty"`
|
||||
}
|
||||
|
||||
// DiffSets compares two config sets parameter-by-parameter (keyed by Key),
|
||||
// returning entries sorted by key. Both sides are included so the frontend can
|
||||
// render either unified or side-by-side.
|
||||
func DiffSets(left, right ConfigSet) []SetDiffEntry {
|
||||
keys := map[string]bool{}
|
||||
li := map[string]Parameter{}
|
||||
ri := map[string]Parameter{}
|
||||
for _, p := range left.Parameters {
|
||||
li[p.Key] = p
|
||||
keys[p.Key] = true
|
||||
}
|
||||
for _, p := range right.Parameters {
|
||||
ri[p.Key] = p
|
||||
keys[p.Key] = true
|
||||
}
|
||||
out := make([]SetDiffEntry, 0, len(keys))
|
||||
for k := range keys {
|
||||
l, lok := li[k]
|
||||
r, rok := ri[k]
|
||||
e := SetDiffEntry{Key: k}
|
||||
switch {
|
||||
case lok && !rok:
|
||||
e.Status = StatusRemoved
|
||||
lp := l
|
||||
e.Left = &lp
|
||||
case !lok && rok:
|
||||
e.Status = StatusAdded
|
||||
rp := r
|
||||
e.Right = &rp
|
||||
default:
|
||||
lp, rp := l, r
|
||||
e.Left, e.Right = &lp, &rp
|
||||
if paramEqual(l, r) {
|
||||
e.Status = StatusUnchanged
|
||||
} else {
|
||||
e.Status = StatusChanged
|
||||
}
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Key < out[j].Key })
|
||||
return out
|
||||
}
|
||||
|
||||
// DiffInstances compares two config instances value-by-value (keyed by
|
||||
// parameter key), returning entries sorted by key.
|
||||
func DiffInstances(left, right ConfigInstance) []InstanceDiffEntry {
|
||||
keys := map[string]bool{}
|
||||
for k := range left.Values {
|
||||
keys[k] = true
|
||||
}
|
||||
for k := range right.Values {
|
||||
keys[k] = true
|
||||
}
|
||||
out := make([]InstanceDiffEntry, 0, len(keys))
|
||||
for k := range keys {
|
||||
lv, lok := left.Values[k]
|
||||
rv, rok := right.Values[k]
|
||||
e := InstanceDiffEntry{Key: k, Left: lv, Right: rv}
|
||||
switch {
|
||||
case lok && !rok:
|
||||
e.Status = StatusRemoved
|
||||
case !lok && rok:
|
||||
e.Status = StatusAdded
|
||||
case valueEqual(lv, rv):
|
||||
e.Status = StatusUnchanged
|
||||
default:
|
||||
e.Status = StatusChanged
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Key < out[j].Key })
|
||||
return out
|
||||
}
|
||||
|
||||
func paramEqual(a, b Parameter) bool {
|
||||
if a.Label != b.Label || a.Group != b.Group || a.Subgroup != b.Subgroup ||
|
||||
a.DS != b.DS || a.Signal != b.Signal || a.Type != b.Type ||
|
||||
a.Mandatory != b.Mandatory || a.Unit != b.Unit || a.Description != b.Description {
|
||||
return false
|
||||
}
|
||||
if !valueEqual(a.Default, b.Default) || !floatPtrEqual(a.Min, b.Min) || !floatPtrEqual(a.Max, b.Max) {
|
||||
return false
|
||||
}
|
||||
if len(a.EnumValues) != len(b.EnumValues) {
|
||||
return false
|
||||
}
|
||||
for i := range a.EnumValues {
|
||||
if a.EnumValues[i] != b.EnumValues[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func floatPtrEqual(a, b *float64) bool {
|
||||
if a == nil || b == nil {
|
||||
return a == b
|
||||
}
|
||||
return *a == *b
|
||||
}
|
||||
|
||||
// valueEqual compares two JSON-decoded scalar values for diff purposes by
|
||||
// rendering them to a canonical string.
|
||||
func valueEqual(a, b any) bool {
|
||||
return fmt.Sprintf("%v", a) == fmt.Sprintf("%v", b)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package confmgr
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDiffSets(t *testing.T) {
|
||||
left := ConfigSet{Parameters: []Parameter{
|
||||
{Key: "a", DS: "d", Signal: "A", Type: TypeFloat, Default: 1.0},
|
||||
{Key: "b", DS: "d", Signal: "B", Type: TypeFloat},
|
||||
}}
|
||||
right := ConfigSet{Parameters: []Parameter{
|
||||
{Key: "a", DS: "d", Signal: "A", Type: TypeFloat, Default: 2.0}, // changed default
|
||||
{Key: "c", DS: "d", Signal: "C", Type: TypeFloat}, // added
|
||||
}}
|
||||
diff := DiffSets(left, right)
|
||||
got := map[string]ChangeStatus{}
|
||||
for _, e := range diff {
|
||||
got[e.Key] = e.Status
|
||||
}
|
||||
if got["a"] != StatusChanged {
|
||||
t.Errorf("a: want changed, got %s", got["a"])
|
||||
}
|
||||
if got["b"] != StatusRemoved {
|
||||
t.Errorf("b: want removed, got %s", got["b"])
|
||||
}
|
||||
if got["c"] != StatusAdded {
|
||||
t.Errorf("c: want added, got %s", got["c"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffSetsUnchanged(t *testing.T) {
|
||||
p := Parameter{Key: "a", DS: "d", Signal: "A", Type: TypeFloat, Default: 1.0}
|
||||
diff := DiffSets(ConfigSet{Parameters: []Parameter{p}}, ConfigSet{Parameters: []Parameter{p}})
|
||||
if len(diff) != 1 || diff[0].Status != StatusUnchanged {
|
||||
t.Errorf("want single unchanged entry, got %+v", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffInstances(t *testing.T) {
|
||||
left := ConfigInstance{Values: map[string]any{"a": 1.0, "b": "x"}}
|
||||
right := ConfigInstance{Values: map[string]any{"a": 2.0, "c": true}}
|
||||
diff := DiffInstances(left, right)
|
||||
got := map[string]ChangeStatus{}
|
||||
for _, e := range diff {
|
||||
got[e.Key] = e.Status
|
||||
}
|
||||
if got["a"] != StatusChanged {
|
||||
t.Errorf("a: want changed, got %s", got["a"])
|
||||
}
|
||||
if got["b"] != StatusRemoved {
|
||||
t.Errorf("b: want removed, got %s", got["b"])
|
||||
}
|
||||
if got["c"] != StatusAdded {
|
||||
t.Errorf("c: want added, got %s", got["c"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,6 +46,8 @@ const (
|
||||
type Engine struct {
|
||||
broker *broker.Broker
|
||||
store *Store
|
||||
cfg *confmgr.Store
|
||||
audit audit.Recorder
|
||||
log *slog.Logger
|
||||
root context.Context
|
||||
|
||||
@@ -32,19 +55,57 @@ type Engine struct {
|
||||
cancel context.CancelFunc // cancels the current generation
|
||||
wg *sync.WaitGroup // tracks the current generation's goroutines
|
||||
|
||||
// notifier delivers action.dialog requests to connected clients. Stored in
|
||||
// an atomic so flow goroutines can read it without taking e.mu (which Reload
|
||||
// holds while it waits for those same goroutines to drain).
|
||||
notifier atomic.Value // notifierBox
|
||||
|
||||
// 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,
|
||||
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,6 +580,14 @@ type compiledGraph struct {
|
||||
genCtx context.Context
|
||||
wg *sync.WaitGroup
|
||||
|
||||
// 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
|
||||
@@ -254,6 +597,11 @@ type compiledGraph struct {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
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 {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package controllogic
|
||||
|
||||
import "strings"
|
||||
|
||||
// Dialog is a user-facing notification or input request emitted by an
|
||||
// action.dialog node. It is delivered to connected clients whose identity
|
||||
// matches Users/Groups (both empty = everyone). For an "input" dialog the
|
||||
// client's response is written back to Target (a "ds:name" reference, e.g.
|
||||
// "srv:approved") so control logic can read it on a later activation.
|
||||
type Dialog struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"` // "info" | "error" | "input"
|
||||
Title string `json:"title"`
|
||||
Message string `json:"message"`
|
||||
Target string `json:"target,omitempty"`
|
||||
Users []string `json:"users,omitempty"`
|
||||
Groups []string `json:"groups,omitempty"`
|
||||
}
|
||||
|
||||
// Notifier delivers control-logic dialogs to connected clients. The server
|
||||
// implements it; the engine calls Notify when an action.dialog node runs.
|
||||
// Notify must not block (the hub fans out without waiting on slow clients).
|
||||
type Notifier interface {
|
||||
Notify(Dialog)
|
||||
}
|
||||
|
||||
// splitCSV parses a comma-separated user/group filter into trimmed,
|
||||
// non-empty tokens. An empty string yields a nil slice (no filter).
|
||||
func splitCSV(s string) []string {
|
||||
var out []string
|
||||
for _, p := range strings.Split(s, ",") {
|
||||
if t := strings.TrimSpace(p); t != "" {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const definitionsFile = "controllogic.json"
|
||||
@@ -16,9 +17,15 @@ var ErrNotFound = errors.New("control logic graph not found")
|
||||
|
||||
// Store persists control-logic graphs as a single JSON file in the storage dir.
|
||||
// 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
|
||||
trashDir string
|
||||
versionsDir string
|
||||
items map[string]Graph
|
||||
}
|
||||
|
||||
@@ -26,6 +33,8 @@ type Store struct {
|
||||
func NewStore(storageDir string) (*Store, error) {
|
||||
s := &Store{
|
||||
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 {
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -112,6 +112,8 @@ func fetchArchiveHistory(ctx context.Context, archiveURL, signal string, start,
|
||||
Timestamp: ts,
|
||||
Data: data,
|
||||
Quality: q,
|
||||
Severity: p.Severity,
|
||||
Status: p.Status,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ import (
|
||||
//
|
||||
//export goCAMonitorCallback
|
||||
func goCAMonitorCallback(handle C.uintptr_t, dbrType C.int, count C.long,
|
||||
dbr unsafe.Pointer, severity C.int, epicsTimeSecs C.double) {
|
||||
dbr unsafe.Pointer, severity C.int, status C.int, epicsTimeSecs C.double) {
|
||||
|
||||
h := uintptr(handle)
|
||||
|
||||
@@ -79,7 +79,13 @@ func goCAMonitorCallback(handle C.uintptr_t, dbrType C.int, count C.long,
|
||||
data = float64(0)
|
||||
}
|
||||
|
||||
v := datasource.Value{Timestamp: ts, Data: data, Quality: q}
|
||||
v := datasource.Value{
|
||||
Timestamp: ts,
|
||||
Data: data,
|
||||
Quality: q,
|
||||
Severity: int(severity),
|
||||
Status: int(status),
|
||||
}
|
||||
|
||||
// Look up the subscription channel under the global handle table lock and
|
||||
// perform a non-blocking send so we never stall the CA callback thread.
|
||||
@@ -129,6 +135,7 @@ func goCAConnectionCallback(handle C.uintptr_t, connected C.int) {
|
||||
Timestamp: time.Now(),
|
||||
Data: float64(0),
|
||||
Quality: datasource.QualityBad,
|
||||
Severity: 3, // INVALID
|
||||
}
|
||||
select {
|
||||
case ch <- v:
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* goCAConnectionCallback is called when a channel connects or disconnects.
|
||||
*/
|
||||
extern void goCAMonitorCallback(uintptr_t handle, int dbrType, long count,
|
||||
void *dbr, int severity,
|
||||
void *dbr, int severity, int status,
|
||||
double epicsTimeSecs);
|
||||
extern void goCAConnectionCallback(uintptr_t handle, int connected);
|
||||
|
||||
@@ -30,9 +30,10 @@ static void caMonitorCallbackShim(struct event_handler_args args) {
|
||||
|
||||
double timeSecs = 0.0;
|
||||
int severity = 0;
|
||||
int status = 0;
|
||||
|
||||
/*
|
||||
* Determine the timestamp and alarm severity from the DBR type.
|
||||
* Determine the timestamp and alarm severity/status from the DBR type.
|
||||
* We request DBR_TIME_* types so the timestamp is embedded in the value
|
||||
* buffer right after the alarm fields (struct dbr_time_double et al.).
|
||||
*/
|
||||
@@ -41,36 +42,42 @@ static void caMonitorCallbackShim(struct event_handler_args args) {
|
||||
const struct dbr_time_double *p = (const struct dbr_time_double *)args.dbr;
|
||||
timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9;
|
||||
severity = (int)p->severity;
|
||||
status = (int)p->status;
|
||||
break;
|
||||
}
|
||||
case DBR_TIME_FLOAT: {
|
||||
const struct dbr_time_float *p = (const struct dbr_time_float *)args.dbr;
|
||||
timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9;
|
||||
severity = (int)p->severity;
|
||||
status = (int)p->status;
|
||||
break;
|
||||
}
|
||||
case DBR_TIME_LONG: {
|
||||
const struct dbr_time_long *p = (const struct dbr_time_long *)args.dbr;
|
||||
timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9;
|
||||
severity = (int)p->severity;
|
||||
status = (int)p->status;
|
||||
break;
|
||||
}
|
||||
case DBR_TIME_SHORT: {
|
||||
const struct dbr_time_short *p = (const struct dbr_time_short *)args.dbr;
|
||||
timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9;
|
||||
severity = (int)p->severity;
|
||||
status = (int)p->status;
|
||||
break;
|
||||
}
|
||||
case DBR_TIME_STRING: {
|
||||
const struct dbr_time_string *p = (const struct dbr_time_string *)args.dbr;
|
||||
timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9;
|
||||
severity = (int)p->severity;
|
||||
status = (int)p->status;
|
||||
break;
|
||||
}
|
||||
case DBR_TIME_ENUM: {
|
||||
const struct dbr_time_enum *p = (const struct dbr_time_enum *)args.dbr;
|
||||
timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9;
|
||||
severity = (int)p->severity;
|
||||
status = (int)p->status;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
@@ -78,7 +85,7 @@ static void caMonitorCallbackShim(struct event_handler_args args) {
|
||||
}
|
||||
|
||||
goCAMonitorCallback((uintptr_t)args.usr, (int)args.type, (long)args.count,
|
||||
(void *)args.dbr, severity, timeSecs);
|
||||
(void *)args.dbr, severity, status, timeSecs);
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
@@ -275,6 +275,8 @@ func (e *EPICS) timeValueToDS(signal string, tv proto.TimeValue) datasource.Valu
|
||||
Timestamp: tv.Timestamp,
|
||||
Data: data,
|
||||
Quality: quality,
|
||||
Severity: int(tv.Severity),
|
||||
Status: int(tv.Status),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,8 @@ type Value struct {
|
||||
Timestamp time.Time
|
||||
Data any // float64 | []float64 | string | int64 | bool
|
||||
Quality Quality
|
||||
Severity int // raw EPICS alarm severity (0=NO_ALARM,1=MINOR,2=MAJOR,3=INVALID); 0 for sources without alarm info
|
||||
Status int // raw EPICS alarm status (.STAT); 0 for sources without alarm info
|
||||
MetaUpdate bool // if true, metadata was refreshed — dispatcher should re-send meta
|
||||
}
|
||||
|
||||
|
||||
@@ -180,6 +180,7 @@ func structToValue(sv pvdata.StructValue) datasource.Value {
|
||||
val := fieldByName(sv, "value")
|
||||
ts := extractTimestamp(sv)
|
||||
quality := extractQuality(sv)
|
||||
severity, status := extractSeverityStatus(sv)
|
||||
|
||||
var data any
|
||||
if val != nil {
|
||||
@@ -190,6 +191,8 @@ func structToValue(sv pvdata.StructValue) datasource.Value {
|
||||
Timestamp: ts,
|
||||
Data: data,
|
||||
Quality: quality,
|
||||
Severity: severity,
|
||||
Status: status,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -329,6 +332,26 @@ func extractQuality(sv pvdata.StructValue) datasource.Quality {
|
||||
}
|
||||
}
|
||||
|
||||
// extractSeverityStatus pulls the raw EPICS alarm severity and status from the
|
||||
// NTScalar "alarm" struct. Returns (0, 0) when no alarm struct is present.
|
||||
func extractSeverityStatus(sv pvdata.StructValue) (severity, status int) {
|
||||
alarm := structByName(sv, "alarm")
|
||||
if alarm == nil {
|
||||
return 0, 0
|
||||
}
|
||||
if v := fieldByName(*alarm, "severity"); v != nil {
|
||||
if s, ok := v.(int32); ok {
|
||||
severity = int(s)
|
||||
}
|
||||
}
|
||||
if v := fieldByName(*alarm, "status"); v != nil {
|
||||
if s, ok := v.(int32); ok {
|
||||
status = int(s)
|
||||
}
|
||||
}
|
||||
return severity, status
|
||||
}
|
||||
|
||||
func toFloat64(v any) (float64, bool) {
|
||||
switch x := v.(type) {
|
||||
case float64:
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
// Package servervar provides a small persistent key/value data source for
|
||||
// "server variables": named scalar values that the server-side control-logic
|
||||
// engine writes (e.g. the state of a sequence) and that interface panels can
|
||||
// read live. Panels may read any variable; writes from panels are gated to
|
||||
// control-logic editors in the WebSocket write handler.
|
||||
package servervar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/uopi/uopi/internal/datasource"
|
||||
)
|
||||
|
||||
const fileName = "servervars.json"
|
||||
|
||||
type variable struct {
|
||||
value float64
|
||||
ts time.Time
|
||||
}
|
||||
|
||||
// Source is the "srv" data source. Variables are created on first write and
|
||||
// persisted so their last value survives a restart.
|
||||
type Source struct {
|
||||
path string
|
||||
|
||||
mu sync.RWMutex
|
||||
vars map[string]*variable
|
||||
subs map[string]map[int]chan<- datasource.Value
|
||||
nextID int
|
||||
}
|
||||
|
||||
// New opens (or initialises) the server-variable store under storageDir.
|
||||
func New(storageDir string) (*Source, error) {
|
||||
s := &Source{
|
||||
path: filepath.Join(storageDir, fileName),
|
||||
vars: map[string]*variable{},
|
||||
subs: map[string]map[int]chan<- datasource.Value{},
|
||||
}
|
||||
if err := s.load(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Source) load() error {
|
||||
data, err := os.ReadFile(s.path)
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var raw map[string]float64
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now()
|
||||
for name, v := range raw {
|
||||
s.vars[name] = &variable{value: v, ts: now}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// saveLocked persists the current values atomically. Caller holds s.mu.
|
||||
func (s *Source) saveLocked() {
|
||||
raw := make(map[string]float64, len(s.vars))
|
||||
for name, v := range s.vars {
|
||||
raw[name] = v.value
|
||||
}
|
||||
data, err := json.MarshalIndent(raw, "", " ")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
tmp := s.path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0o644); err != nil {
|
||||
return
|
||||
}
|
||||
_ = os.Rename(tmp, s.path)
|
||||
}
|
||||
|
||||
func meta(name string) datasource.Metadata {
|
||||
return datasource.Metadata{
|
||||
Name: name,
|
||||
Type: datasource.TypeFloat64,
|
||||
Description: "Server variable",
|
||||
Writable: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Name implements datasource.DataSource.
|
||||
func (s *Source) Name() string { return "srv" }
|
||||
|
||||
// Connect is a no-op — the store is opened in New.
|
||||
func (s *Source) Connect(_ context.Context) error { return nil }
|
||||
|
||||
// ListSignals returns metadata for every defined server variable.
|
||||
func (s *Source) ListSignals(_ context.Context) ([]datasource.Metadata, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
names := make([]string, 0, len(s.vars))
|
||||
for name := range s.vars {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
out := make([]datasource.Metadata, 0, len(names))
|
||||
for _, name := range names {
|
||||
out = append(out, meta(name))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetMetadata returns metadata for a single variable. Unknown names still report
|
||||
// writable metadata so that control logic / authorised panels may create them.
|
||||
func (s *Source) GetMetadata(_ context.Context, signal string) (datasource.Metadata, error) {
|
||||
return meta(signal), nil
|
||||
}
|
||||
|
||||
// Subscribe registers ch for updates and immediately delivers the current value.
|
||||
func (s *Source) Subscribe(ctx context.Context, signal string, ch chan<- datasource.Value) (datasource.CancelFunc, error) {
|
||||
s.mu.Lock()
|
||||
if s.subs[signal] == nil {
|
||||
s.subs[signal] = map[int]chan<- datasource.Value{}
|
||||
}
|
||||
id := s.nextID
|
||||
s.nextID++
|
||||
s.subs[signal][id] = ch
|
||||
cur, ok := s.vars[signal]
|
||||
var first datasource.Value
|
||||
if ok {
|
||||
first = datasource.Value{Timestamp: cur.ts, Data: cur.value, Quality: datasource.QualityGood}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
if ok {
|
||||
go func() {
|
||||
select {
|
||||
case ch <- first:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
return func() {
|
||||
s.mu.Lock()
|
||||
if m := s.subs[signal]; m != nil {
|
||||
delete(m, id)
|
||||
if len(m) == 0 {
|
||||
delete(s.subs, signal)
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}, nil
|
||||
}
|
||||
|
||||
func toFloat64(v any) float64 {
|
||||
switch x := v.(type) {
|
||||
case float64:
|
||||
return x
|
||||
case float32:
|
||||
return float64(x)
|
||||
case int:
|
||||
return float64(x)
|
||||
case int64:
|
||||
return float64(x)
|
||||
case bool:
|
||||
if x {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
case json.Number:
|
||||
f, _ := x.Float64()
|
||||
return f
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// Write sets a variable (creating it if needed), persists, and fans the new value
|
||||
// out to all subscribers.
|
||||
func (s *Source) Write(_ context.Context, signal string, value any) error {
|
||||
v := toFloat64(value)
|
||||
|
||||
s.mu.Lock()
|
||||
now := time.Now()
|
||||
s.vars[signal] = &variable{value: v, ts: now}
|
||||
s.saveLocked()
|
||||
subs := make([]chan<- datasource.Value, 0, len(s.subs[signal]))
|
||||
for _, ch := range s.subs[signal] {
|
||||
subs = append(subs, ch)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
upd := datasource.Value{Timestamp: now, Data: v, Quality: datasource.QualityGood}
|
||||
for _, ch := range subs {
|
||||
select {
|
||||
case ch <- upd:
|
||||
default:
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// History is not supported.
|
||||
func (s *Source) History(_ context.Context, _ string, _, _ time.Time, _ int) ([]datasource.Value, error) {
|
||||
return nil, datasource.ErrHistoryUnavailable
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package servervar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/uopi/uopi/internal/datasource"
|
||||
)
|
||||
|
||||
func TestWriteSubscribePersist(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s, err := New(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
if err := s.Write(ctx, "seq_state", 3.0); err != nil {
|
||||
t.Fatalf("Write: %v", err)
|
||||
}
|
||||
|
||||
// Subscribe should deliver the current value immediately.
|
||||
ch := make(chan datasource.Value, 4)
|
||||
cancel, err := s.Subscribe(ctx, "seq_state", ch)
|
||||
if err != nil {
|
||||
t.Fatalf("Subscribe: %v", err)
|
||||
}
|
||||
defer cancel()
|
||||
select {
|
||||
case v := <-ch:
|
||||
if got := v.Data.(float64); got != 3.0 {
|
||||
t.Fatalf("initial value = %v, want 3", got)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("no initial value delivered")
|
||||
}
|
||||
|
||||
// A later write fans out to the subscriber.
|
||||
if err := s.Write(ctx, "seq_state", 7.0); err != nil {
|
||||
t.Fatalf("Write 2: %v", err)
|
||||
}
|
||||
select {
|
||||
case v := <-ch:
|
||||
if got := v.Data.(float64); got != 7.0 {
|
||||
t.Fatalf("updated value = %v, want 7", got)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("no update delivered")
|
||||
}
|
||||
|
||||
// ListSignals reports the variable.
|
||||
sigs, err := s.ListSignals(ctx)
|
||||
if err != nil || len(sigs) != 1 || sigs[0].Name != "seq_state" {
|
||||
t.Fatalf("ListSignals = %+v, err %v", sigs, err)
|
||||
}
|
||||
|
||||
// A fresh store over the same dir recovers the last value.
|
||||
s2, err := New(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("reopen: %v", err)
|
||||
}
|
||||
ch2 := make(chan datasource.Value, 1)
|
||||
cancel2, _ := s2.Subscribe(ctx, "seq_state", ch2)
|
||||
defer cancel2()
|
||||
select {
|
||||
case v := <-ch2:
|
||||
if got := v.Data.(float64); got != 7.0 {
|
||||
t.Fatalf("persisted value = %v, want 7", got)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("persisted value not delivered")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package synthetic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"math"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/uopi/uopi/internal/broker"
|
||||
"github.com/uopi/uopi/internal/datasource"
|
||||
"github.com/uopi/uopi/internal/dsp"
|
||||
)
|
||||
|
||||
// evalSampleDef compiles a SignalDef and evaluates it against per-source
|
||||
// Samples keyed by source node id, returning the output Sample.
|
||||
func evalSampleDef(t *testing.T, def SignalDef, srcVals map[string]dsp.Sample) dsp.Sample {
|
||||
t.Helper()
|
||||
rg, err := compileGraph(def)
|
||||
if err != nil {
|
||||
t.Fatalf("compileGraph: %v", err)
|
||||
}
|
||||
out, err := rg.evalSample(srcVals)
|
||||
if err != nil {
|
||||
t.Fatalf("evalSample: %v", err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestArrayElementwiseChain runs an array source through an elementwise op
|
||||
// (gain) and asserts the output stays an array, broadcast per element.
|
||||
func TestArrayElementwiseChain(t *testing.T) {
|
||||
def := SignalDef{
|
||||
Name: "scaled",
|
||||
Graph: &Graph{
|
||||
Output: "out",
|
||||
Nodes: []GraphNode{
|
||||
{ID: "a", Kind: "source", DS: "x", Signal: "wave"},
|
||||
{ID: "g", Kind: "op", Op: "gain", Inputs: []string{"a"}, Params: map[string]any{"gain": 2.0}},
|
||||
{ID: "out", Kind: "output", Inputs: []string{"g"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
out := evalSampleDef(t, def, map[string]dsp.Sample{"a": dsp.Array([]float64{1, 2, 3})})
|
||||
if !out.IsArray {
|
||||
t.Fatalf("want array output, got %v", out)
|
||||
}
|
||||
want := []float64{2, 4, 6}
|
||||
for i, v := range want {
|
||||
if out.Arr[i] != v {
|
||||
t.Errorf("scaled[%d]: want %v, got %v", i, v, out.Arr[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestArrayReductionToScalar runs an array source into mean (array→scalar) and
|
||||
// asserts a scalar output and an array-output compile type for the producer.
|
||||
func TestArrayReductionToScalar(t *testing.T) {
|
||||
def := SignalDef{
|
||||
Name: "avg",
|
||||
Graph: &Graph{
|
||||
Output: "out",
|
||||
Nodes: []GraphNode{
|
||||
{ID: "a", Kind: "source", DS: "x", Signal: "wave"},
|
||||
{ID: "m", Kind: "op", Op: "mean", Inputs: []string{"a"}},
|
||||
{ID: "out", Kind: "output", Inputs: []string{"m"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
out := evalSampleDef(t, def, map[string]dsp.Sample{"a": dsp.Array([]float64{2, 4, 6, 8})})
|
||||
if out.IsArray {
|
||||
t.Fatalf("want scalar output, got array %v", out.Arr)
|
||||
}
|
||||
if math.Abs(out.F-5) > 1e-9 {
|
||||
t.Errorf("mean: want 5, got %v", out.F)
|
||||
}
|
||||
}
|
||||
|
||||
// TestArrayOutTypeMetadata verifies compileGraph reports an array output type
|
||||
// for a pure-elementwise array graph and scalar for a reduction graph.
|
||||
func TestArrayOutTypeMetadata(t *testing.T) {
|
||||
arrayGraph := SignalDef{
|
||||
Name: "fftout",
|
||||
Graph: &Graph{
|
||||
Output: "out",
|
||||
Nodes: []GraphNode{
|
||||
{ID: "a", Kind: "source", DS: "x", Signal: "wave"},
|
||||
{ID: "f", Kind: "op", Op: "fft", Inputs: []string{"a"}},
|
||||
{ID: "out", Kind: "output", Inputs: []string{"f"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
rg, err := compileGraph(arrayGraph)
|
||||
if err != nil {
|
||||
t.Fatalf("compileGraph: %v", err)
|
||||
}
|
||||
if rg.outType != dsp.ValArray {
|
||||
t.Errorf("fft graph outType: want ValArray, got %v", rg.outType)
|
||||
}
|
||||
|
||||
reduction := SignalDef{
|
||||
Name: "sumout",
|
||||
Graph: &Graph{
|
||||
Output: "out",
|
||||
Nodes: []GraphNode{
|
||||
{ID: "a", Kind: "source", DS: "x", Signal: "wave"},
|
||||
{ID: "s", Kind: "op", Op: "sum", Inputs: []string{"a"}},
|
||||
{ID: "out", Kind: "output", Inputs: []string{"s"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
rg2, err := compileGraph(reduction)
|
||||
if err != nil {
|
||||
t.Fatalf("compileGraph: %v", err)
|
||||
}
|
||||
if rg2.outType != dsp.ValScalar {
|
||||
t.Errorf("sum graph outType: want ValScalar, got %v", rg2.outType)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStatefulRejectsArray verifies a stateful op (moving_average) errors when
|
||||
// fed an array input at runtime.
|
||||
func TestStatefulRejectsArray(t *testing.T) {
|
||||
def := SignalDef{
|
||||
Name: "ma",
|
||||
Graph: &Graph{
|
||||
Output: "out",
|
||||
Nodes: []GraphNode{
|
||||
{ID: "a", Kind: "source", DS: "x", Signal: "wave"},
|
||||
{ID: "m", Kind: "op", Op: "moving_average", Inputs: []string{"a"}, Params: map[string]any{"window": 3.0}},
|
||||
{ID: "out", Kind: "output", Inputs: []string{"m"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
rg, err := compileGraph(def)
|
||||
if err != nil {
|
||||
t.Fatalf("compileGraph: %v", err)
|
||||
}
|
||||
if _, err := rg.evalSample(map[string]dsp.Sample{"a": dsp.Array([]float64{1, 2, 3})}); err == nil {
|
||||
t.Error("expected moving_average to reject an array input")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSubscribeArrayPassthrough is an end-to-end check that a synthetic with an
|
||||
// array-valued source and an elementwise op emits a []float64 over the broker,
|
||||
// and that GetMetadata reports the waveform type.
|
||||
func TestSubscribeArrayPassthrough(t *testing.T) {
|
||||
log := slog.New(slog.NewTextHandler(os.Stderr, nil))
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
base := time.Date(2026, 6, 19, 10, 0, 0, 0, time.UTC)
|
||||
src := &seqSource{name: "src", seq: []datasource.Value{
|
||||
{Timestamp: base.Add(1 * time.Second), Data: []float64{1, 2, 3}, Quality: datasource.QualityGood},
|
||||
{Timestamp: base.Add(2 * time.Second), Data: []float64{4, 5, 6}, Quality: datasource.QualityGood},
|
||||
}}
|
||||
|
||||
brk := broker.New(ctx, log)
|
||||
brk.Register(src)
|
||||
syn := New(t.TempDir(), brk, log)
|
||||
if err := syn.Connect(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// gain x2 elementwise keeps the value an array.
|
||||
if err := syn.AddSignal(SignalDef{
|
||||
Name: "scaled",
|
||||
Graph: &Graph{Output: "out", Nodes: []GraphNode{
|
||||
{ID: "a", Kind: "source", DS: "src", Signal: "x"},
|
||||
{ID: "g", Kind: "op", Op: "gain", Inputs: []string{"a"}, Params: map[string]any{"gain": 2.0}},
|
||||
{ID: "out", Kind: "output", Inputs: []string{"g"}},
|
||||
}},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// An fft-based signal has a statically-known array output type (the source's
|
||||
// runtime type need not be known), so its metadata reports the waveform type.
|
||||
if err := syn.AddSignal(SignalDef{
|
||||
Name: "spectrum",
|
||||
Graph: &Graph{Output: "out", Nodes: []GraphNode{
|
||||
{ID: "a", Kind: "source", DS: "src", Signal: "x"},
|
||||
{ID: "f", Kind: "op", Op: "fft", Inputs: []string{"a"}},
|
||||
{ID: "out", Kind: "output", Inputs: []string{"f"}},
|
||||
}},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
meta, err := syn.GetMetadata(ctx, "spectrum")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if meta.Type != datasource.TypeFloat64Array {
|
||||
t.Errorf("metadata type: want TypeFloat64Array, got %v", meta.Type)
|
||||
}
|
||||
|
||||
ch := make(chan datasource.Value, 8)
|
||||
if _, err := syn.Subscribe(ctx, "scaled", ch); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
want := [][]float64{{2, 4, 6}, {8, 10, 12}}
|
||||
for i, w := range want {
|
||||
select {
|
||||
case v := <-ch:
|
||||
arr, ok := v.Data.([]float64)
|
||||
if !ok {
|
||||
t.Fatalf("emit #%d: want []float64, got %T", i, v.Data)
|
||||
}
|
||||
if len(arr) != len(w) {
|
||||
t.Fatalf("emit #%d: want len %d, got %d", i, len(w), len(arr))
|
||||
}
|
||||
for k, val := range w {
|
||||
if arr[k] != val {
|
||||
t.Errorf("emit #%d [%d]: want %v, got %v", i, k, val, arr[k])
|
||||
}
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatalf("timeout waiting for emit #%d", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ type SignalDef struct {
|
||||
Signal string `json:"signal"` // upstream signal name (or "" for constant)
|
||||
Inputs []InputRef `json:"inputs"` // alternative multi-input format
|
||||
Pipeline []NodeDef `json:"pipeline"` // ordered list of DSP nodes
|
||||
Graph *Graph `json:"graph,omitempty"` // DAG form (preferred when present)
|
||||
Meta MetaOverride `json:"meta"` // optional metadata overrides
|
||||
|
||||
// Visibility controls who sees this signal in the signal tree:
|
||||
@@ -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. a−b, a÷b).
|
||||
// kind=="output": Inputs has a single upstream node whose value is the result.
|
||||
//
|
||||
// X/Y are the editor layout coordinates, persisted so a reloaded graph keeps its
|
||||
// shape; they have no effect on evaluation.
|
||||
type GraphNode struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
Op string `json:"op,omitempty"`
|
||||
Params map[string]any `json:"params,omitempty"`
|
||||
DS string `json:"ds,omitempty"`
|
||||
Signal string `json:"signal,omitempty"`
|
||||
Inputs []string `json:"inputs,omitempty"`
|
||||
X float64 `json:"x,omitempty"`
|
||||
Y float64 `json:"y,omitempty"`
|
||||
}
|
||||
|
||||
// MetaOverride allows the synthetic signal to override display metadata.
|
||||
type MetaOverride struct {
|
||||
Unit string `json:"unit,omitempty"`
|
||||
|
||||
@@ -38,21 +38,32 @@ func stringParam(params map[string]any, key string) string {
|
||||
return s
|
||||
}
|
||||
|
||||
// BuildPipeline converts a []NodeDef (from JSON) into a []dsp.Node ready for
|
||||
// execution. JSON numbers are float64, so all numeric params are handled as
|
||||
// float64 regardless of the final type needed.
|
||||
func BuildPipeline(defs []NodeDef) ([]dsp.Node, error) {
|
||||
nodes := make([]dsp.Node, 0, len(defs))
|
||||
for i, d := range defs {
|
||||
n, err := buildNode(d)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pipeline node %d (%q): %w", i, d.Type, err)
|
||||
// stringSliceParam extracts a []string from params; JSON arrays decode to []any,
|
||||
// so each element is coerced via its string value. Returns nil if missing.
|
||||
func stringSliceParam(params map[string]any, key string) []string {
|
||||
if params == nil {
|
||||
return nil
|
||||
}
|
||||
nodes = append(nodes, n)
|
||||
v, ok := params[key]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return nodes, nil
|
||||
arr, ok := v.([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(arr))
|
||||
for _, e := range arr {
|
||||
if s, ok := e.(string); ok && s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// buildNode converts a single NodeDef (from JSON) into a dsp.Node ready for
|
||||
// execution. JSON numbers are float64, so all numeric params are handled as
|
||||
// float64 regardless of the final type needed.
|
||||
func buildNode(d NodeDef) (dsp.Node, error) {
|
||||
p := d.Params
|
||||
switch d.Type {
|
||||
@@ -100,7 +111,7 @@ func buildNode(d NodeDef) (dsp.Node, error) {
|
||||
}, nil
|
||||
|
||||
case "expr":
|
||||
return &dsp.ExprNode{Expr: stringParam(p, "expr")}, nil
|
||||
return &dsp.ExprNode{Expr: stringParam(p, "expr"), Vars: stringSliceParam(p, "vars")}, nil
|
||||
|
||||
case "lowpass":
|
||||
order := int(floatParam(p, "order"))
|
||||
@@ -113,7 +124,31 @@ func buildNode(d NodeDef) (dsp.Node, error) {
|
||||
}, nil
|
||||
|
||||
case "lua":
|
||||
return &dsp.LuaNode{Script: stringParam(p, "script")}, nil
|
||||
return &dsp.LuaNode{Script: stringParam(p, "script"), Vars: stringSliceParam(p, "vars")}, nil
|
||||
|
||||
case "index":
|
||||
return &dsp.IndexNode{I: int(floatParam(p, "i"))}, nil
|
||||
|
||||
case "slice":
|
||||
return &dsp.SliceNode{Start: int(floatParam(p, "start")), End: int(floatParam(p, "end"))}, nil
|
||||
|
||||
case "sum":
|
||||
return &dsp.SumNode{}, nil
|
||||
|
||||
case "mean":
|
||||
return &dsp.MeanNode{}, nil
|
||||
|
||||
case "min":
|
||||
return &dsp.MinNode{}, nil
|
||||
|
||||
case "max":
|
||||
return &dsp.MaxNode{}, nil
|
||||
|
||||
case "length":
|
||||
return &dsp.LengthNode{}, nil
|
||||
|
||||
case "fft":
|
||||
return &dsp.FFTNode{}, nil
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown node type %q", d.Type)
|
||||
|
||||
@@ -0,0 +1,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"}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package synthetic
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// evalDef compiles a SignalDef and evaluates it against per-source values keyed
|
||||
// by source node id.
|
||||
func evalDef(t *testing.T, def SignalDef, srcVals map[string]float64) float64 {
|
||||
t.Helper()
|
||||
rg, err := compileGraph(def)
|
||||
if err != nil {
|
||||
t.Fatalf("compileGraph: %v", err)
|
||||
}
|
||||
out, err := rg.eval(srcVals)
|
||||
if err != nil {
|
||||
t.Fatalf("eval: %v", err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestGraphMultiInputDAG verifies that an intermediate op can take two
|
||||
// independently-wired sources — the capability the old linear pipeline lacked.
|
||||
func TestGraphMultiInputDAG(t *testing.T) {
|
||||
def := SignalDef{
|
||||
Name: "diff",
|
||||
Graph: &Graph{
|
||||
Output: "out",
|
||||
Nodes: []GraphNode{
|
||||
{ID: "a", Kind: "source", DS: "x", Signal: "left"},
|
||||
{ID: "b", Kind: "source", DS: "x", Signal: "right"},
|
||||
{ID: "sub", Kind: "op", Op: "subtract", Inputs: []string{"a", "b"}},
|
||||
{ID: "out", Kind: "output", Inputs: []string{"sub"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
got := evalDef(t, def, map[string]float64{"a": 10, "b": 3})
|
||||
if got != 7 {
|
||||
t.Errorf("subtract DAG: want 7, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGraphExprNamedInputs verifies expr nodes bind arbitrary named inputs in
|
||||
// wired order.
|
||||
func TestGraphExprNamedInputs(t *testing.T) {
|
||||
def := SignalDef{
|
||||
Name: "formula",
|
||||
Graph: &Graph{
|
||||
Output: "out",
|
||||
Nodes: []GraphNode{
|
||||
{ID: "a", Kind: "source", DS: "x", Signal: "p"},
|
||||
{ID: "b", Kind: "source", DS: "x", Signal: "q"},
|
||||
{ID: "e", Kind: "op", Op: "expr", Inputs: []string{"a", "b"},
|
||||
Params: map[string]any{"expr": "price * qty", "vars": []any{"price", "qty"}}},
|
||||
{ID: "out", Kind: "output", Inputs: []string{"e"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
got := evalDef(t, def, map[string]float64{"a": 4, "b": 2.5})
|
||||
if math.Abs(got-10) > 1e-9 {
|
||||
t.Errorf("expr named inputs: want 10, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGraphFanInToExpr exercises a non-trivial DAG: two ops feeding one expr.
|
||||
func TestGraphFanInToExpr(t *testing.T) {
|
||||
def := SignalDef{
|
||||
Name: "combo",
|
||||
Graph: &Graph{
|
||||
Output: "out",
|
||||
Nodes: []GraphNode{
|
||||
{ID: "a", Kind: "source", DS: "x", Signal: "p"},
|
||||
{ID: "b", Kind: "source", DS: "x", Signal: "q"},
|
||||
{ID: "g", Kind: "op", Op: "gain", Inputs: []string{"a"}, Params: map[string]any{"gain": 2.0}},
|
||||
{ID: "o", Kind: "op", Op: "offset", Inputs: []string{"b"}, Params: map[string]any{"offset": 1.0}},
|
||||
{ID: "e", Kind: "op", Op: "expr", Inputs: []string{"g", "o"}, Params: map[string]any{"expr": "a + b"}},
|
||||
{ID: "out", Kind: "output", Inputs: []string{"e"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
// g = 5*2 = 10 ; o = 4+1 = 5 ; a+b = 15
|
||||
got := evalDef(t, def, map[string]float64{"a": 5, "b": 4})
|
||||
if math.Abs(got-15) > 1e-9 {
|
||||
t.Errorf("fan-in DAG: want 15, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGraphLegacyConversion verifies the linear Inputs+Pipeline form still
|
||||
// evaluates correctly via the graph runtime.
|
||||
func TestGraphLegacyConversion(t *testing.T) {
|
||||
def := SignalDef{
|
||||
Name: "legacy",
|
||||
DS: "x",
|
||||
Signal: "p",
|
||||
Pipeline: []NodeDef{{Type: "gain", Params: map[string]any{"gain": 3.0}}},
|
||||
}
|
||||
rg, err := compileGraph(def)
|
||||
if err != nil {
|
||||
t.Fatalf("compileGraph: %v", err)
|
||||
}
|
||||
if len(rg.sources) != 1 {
|
||||
t.Fatalf("want 1 source, got %d", len(rg.sources))
|
||||
}
|
||||
got, err := rg.eval(map[string]float64{rg.sources[0].id: 4})
|
||||
if err != nil {
|
||||
t.Fatalf("eval: %v", err)
|
||||
}
|
||||
if got != 12 {
|
||||
t.Errorf("legacy gain: want 12, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGraphCycleRejected ensures a cyclic graph is refused at compile time.
|
||||
func TestGraphCycleRejected(t *testing.T) {
|
||||
def := SignalDef{
|
||||
Name: "cyclic",
|
||||
Graph: &Graph{
|
||||
Output: "out",
|
||||
Nodes: []GraphNode{
|
||||
{ID: "a", Kind: "op", Op: "gain", Inputs: []string{"b"}, Params: map[string]any{"gain": 1.0}},
|
||||
{ID: "b", Kind: "op", Op: "gain", Inputs: []string{"a"}, Params: map[string]any{"gain": 1.0}},
|
||||
{ID: "out", Kind: "output", Inputs: []string{"a"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
if _, err := compileGraph(def); err == nil {
|
||||
t.Error("expected cycle to be rejected")
|
||||
}
|
||||
}
|
||||
@@ -21,8 +21,7 @@ const definitionsFile = "synthetic.json"
|
||||
// signalState holds everything needed to run one synthetic signal.
|
||||
type signalState struct {
|
||||
def SignalDef
|
||||
nodes []dsp.Node
|
||||
states []map[string]any // one map per node, persistent across calls
|
||||
rg *runtimeGraph // compiled DAG; op-node state persists across evaluations
|
||||
|
||||
// cancel stops the goroutine driving this signal.
|
||||
cancel context.CancelFunc
|
||||
@@ -80,7 +79,7 @@ func (s *Synthetic) ListSignals(_ context.Context) ([]datasource.Metadata, error
|
||||
|
||||
out := make([]datasource.Metadata, 0, len(s.signals))
|
||||
for _, st := range s.signals {
|
||||
out = append(out, defToMetadata(st.def))
|
||||
out = append(out, defToMetadata(st.def, outTypeOf(st)))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -95,7 +94,7 @@ func (s *Synthetic) FilteredMetadata(keep func(SignalDef) bool) []datasource.Met
|
||||
out := make([]datasource.Metadata, 0, len(s.signals))
|
||||
for _, st := range s.signals {
|
||||
if keep(st.def) {
|
||||
out = append(out, defToMetadata(st.def))
|
||||
out = append(out, defToMetadata(st.def, outTypeOf(st)))
|
||||
}
|
||||
}
|
||||
return out
|
||||
@@ -110,7 +109,7 @@ func (s *Synthetic) GetMetadata(_ context.Context, signal string) (datasource.Me
|
||||
if !ok {
|
||||
return datasource.Metadata{}, datasource.ErrNotFound
|
||||
}
|
||||
return defToMetadata(st.def), nil
|
||||
return defToMetadata(st.def, outTypeOf(st)), nil
|
||||
}
|
||||
|
||||
// Subscribe registers ch to receive computed values for the named signal.
|
||||
@@ -123,19 +122,25 @@ func (s *Synthetic) Subscribe(ctx context.Context, signal string, ch chan<- data
|
||||
return nil, datasource.ErrNotFound
|
||||
}
|
||||
|
||||
// Collect the upstream references for this signal.
|
||||
refs := upstreamRefs(st.def)
|
||||
// Collect the source node references for this signal's DAG.
|
||||
refs := st.rg.sourceRefs()
|
||||
if len(refs) == 0 {
|
||||
return nil, fmt.Errorf("synthetic: signal %q has no upstream inputs", signal)
|
||||
}
|
||||
// Source node ids, index-aligned with refs, so updates map to graph inputs.
|
||||
srcIDs := make([]string, len(st.rg.sources))
|
||||
for i, s := range st.rg.sources {
|
||||
srcIDs[i] = s.id
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
|
||||
go func() {
|
||||
defer cancel()
|
||||
|
||||
// Latest value per upstream input index.
|
||||
latest := make([]float64, len(refs))
|
||||
// Latest value and timestamp per source node id.
|
||||
latest := make(map[string]dsp.Sample, len(refs))
|
||||
latestTs := make([]time.Time, len(refs))
|
||||
ready := make([]bool, len(refs))
|
||||
|
||||
// Subscribe to every upstream ref via the broker.
|
||||
@@ -159,7 +164,7 @@ func (s *Synthetic) Subscribe(ctx context.Context, signal string, ch chan<- data
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
val := toFloat64(u.Value.Data)
|
||||
val := toSample(u.Value.Data)
|
||||
select {
|
||||
case updateCh <- indexedUpdate{idx: idx, val: val, ts: u.Value.Timestamp}:
|
||||
default:
|
||||
@@ -184,7 +189,8 @@ func (s *Synthetic) Subscribe(ctx context.Context, signal string, ch chan<- data
|
||||
return
|
||||
|
||||
case upd := <-updateCh:
|
||||
latest[upd.idx] = upd.val
|
||||
latest[srcIDs[upd.idx]] = upd.val
|
||||
latestTs[upd.idx] = upd.ts
|
||||
ready[upd.idx] = true
|
||||
|
||||
// Only compute once we have at least one value for every input.
|
||||
@@ -199,7 +205,19 @@ func (s *Synthetic) Subscribe(ctx context.Context, signal string, ch chan<- data
|
||||
continue
|
||||
}
|
||||
|
||||
// Run the pipeline.
|
||||
// The output is computed from the latest value of every input, so
|
||||
// its timestamp is the most recent contributing sample time. Using
|
||||
// the triggering update's timestamp instead would drag the output
|
||||
// back in time whenever a slow/stale input fired, producing
|
||||
// non-monotonic or duplicated timestamps on plots.
|
||||
outTs := latestTs[0]
|
||||
for _, ts := range latestTs[1:] {
|
||||
if ts.After(outTs) {
|
||||
outTs = ts
|
||||
}
|
||||
}
|
||||
|
||||
// Evaluate the DAG.
|
||||
s.mu.RLock()
|
||||
cur, stillExists := s.signals[signal]
|
||||
s.mu.RUnlock()
|
||||
@@ -207,15 +225,15 @@ func (s *Synthetic) Subscribe(ctx context.Context, signal string, ch chan<- data
|
||||
return
|
||||
}
|
||||
|
||||
result, err := runPipeline(cur.nodes, cur.states, latest)
|
||||
result, err := cur.rg.evalSample(latest)
|
||||
if err != nil {
|
||||
s.log.Warn("synthetic: pipeline error", "signal", signal, "err", err)
|
||||
continue
|
||||
}
|
||||
|
||||
v := datasource.Value{
|
||||
Timestamp: upd.ts,
|
||||
Data: result,
|
||||
Timestamp: outTs,
|
||||
Data: result.AsAny(),
|
||||
Quality: datasource.QualityGood,
|
||||
}
|
||||
select {
|
||||
@@ -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
|
||||
// defToMetadata converts a SignalDef into a datasource.Metadata. outType is the
|
||||
// compiled graph's best-effort output type; an array output is reported as a
|
||||
// waveform (TypeFloat64Array) so widgets can pick a compatible view.
|
||||
func defToMetadata(def SignalDef, outType dsp.ValType) datasource.Metadata {
|
||||
dt := datasource.TypeFloat64
|
||||
if outType == dsp.ValArray {
|
||||
dt = datasource.TypeFloat64Array
|
||||
}
|
||||
return inputs[0], nil
|
||||
}
|
||||
|
||||
// First node receives all upstream inputs.
|
||||
cur := inputs
|
||||
var result float64
|
||||
var err error
|
||||
|
||||
for i, node := range nodes {
|
||||
result, err = node.Process(cur, states[i])
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("node %d (%s): %w", i, node.Type(), err)
|
||||
}
|
||||
// Subsequent nodes receive only the single output of the previous node.
|
||||
cur = []float64{result}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// upstreamRefs returns the broker.SignalRef list for a SignalDef.
|
||||
// If Inputs is set, those take precedence; otherwise DS+Signal is used.
|
||||
func upstreamRefs(def SignalDef) []broker.SignalRef {
|
||||
if len(def.Inputs) > 0 {
|
||||
refs := make([]broker.SignalRef, len(def.Inputs))
|
||||
for i, inp := range def.Inputs {
|
||||
refs[i] = broker.SignalRef{DS: inp.DS, Name: inp.Signal}
|
||||
}
|
||||
return refs
|
||||
}
|
||||
if def.DS != "" && def.Signal != "" {
|
||||
return []broker.SignalRef{{DS: def.DS, Name: def.Signal}}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// defToMetadata converts a SignalDef into a datasource.Metadata.
|
||||
func defToMetadata(def SignalDef) datasource.Metadata {
|
||||
return datasource.Metadata{
|
||||
Name: def.Name,
|
||||
Type: datasource.TypeFloat64,
|
||||
Type: dt,
|
||||
Unit: def.Meta.Unit,
|
||||
Description: def.Meta.Description,
|
||||
DisplayLow: def.Meta.DisplayLow,
|
||||
@@ -482,7 +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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package dsp
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
// This file holds ArrayNode ops: those that operate natively on waveform
|
||||
// (float64 array) Samples — reductions (array→scalar), producers (array→array),
|
||||
// and element access. Each also implements the legacy scalar Node interface
|
||||
// (treating a scalar as a single-element array) so it remains usable from the
|
||||
// scalar eval path.
|
||||
|
||||
// reductionProcess adapts a scalar Process call to a reduction ArrayNode.
|
||||
func reductionProcess(n ArrayNode, in []float64, st map[string]any) (float64, error) {
|
||||
s, err := n.ProcessSample(scalarInputs(in), st)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return s.F, nil
|
||||
}
|
||||
|
||||
// ── IndexNode ───────────────────────────────────────────────────────────────
|
||||
|
||||
// IndexNode extracts element I of an array input (array→scalar).
|
||||
type IndexNode struct{ I int }
|
||||
|
||||
func (n *IndexNode) Type() string { return "index" }
|
||||
func (n *IndexNode) Process(in []float64, st map[string]any) (float64, error) {
|
||||
return reductionProcess(n, in, st)
|
||||
}
|
||||
func (n *IndexNode) ProcessSample(in []Sample, _ map[string]any) (Sample, error) {
|
||||
if len(in) == 0 {
|
||||
return Sample{}, errors.New("index: no inputs")
|
||||
}
|
||||
arr := in[0].AsArray()
|
||||
if n.I < 0 || n.I >= len(arr) {
|
||||
return Sample{}, fmt.Errorf("index: %d out of range [0,%d)", n.I, len(arr))
|
||||
}
|
||||
return Scalar(arr[n.I]), nil
|
||||
}
|
||||
|
||||
// ── SliceNode ───────────────────────────────────────────────────────────────
|
||||
|
||||
// SliceNode returns a sub-range [Start,End) of an array input (array→array),
|
||||
// clamped to the array bounds. End <= 0 means "to the end".
|
||||
type SliceNode struct{ Start, End int }
|
||||
|
||||
func (n *SliceNode) Type() string { return "slice" }
|
||||
func (n *SliceNode) Process(in []float64, st map[string]any) (float64, error) {
|
||||
s, err := n.ProcessSample(scalarInputs(in), st)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(s.Arr) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
return s.Arr[0], nil
|
||||
}
|
||||
func (n *SliceNode) ProcessSample(in []Sample, _ map[string]any) (Sample, error) {
|
||||
if len(in) == 0 {
|
||||
return Sample{}, errors.New("slice: no inputs")
|
||||
}
|
||||
arr := in[0].AsArray()
|
||||
start := n.Start
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
if start > len(arr) {
|
||||
start = len(arr)
|
||||
}
|
||||
end := n.End
|
||||
if end <= 0 || end > len(arr) {
|
||||
end = len(arr)
|
||||
}
|
||||
if end < start {
|
||||
end = start
|
||||
}
|
||||
out := make([]float64, end-start)
|
||||
copy(out, arr[start:end])
|
||||
return Array(out), nil
|
||||
}
|
||||
|
||||
// ── reductions ────────────────────────────────────────────────────────────────
|
||||
|
||||
// SumNode sums an array input (array→scalar).
|
||||
type SumNode struct{}
|
||||
|
||||
func (n *SumNode) Type() string { return "sum" }
|
||||
func (n *SumNode) Process(in []float64, st map[string]any) (float64, error) {
|
||||
return reductionProcess(n, in, st)
|
||||
}
|
||||
func (n *SumNode) ProcessSample(in []Sample, _ map[string]any) (Sample, error) {
|
||||
if len(in) == 0 {
|
||||
return Sample{}, errors.New("sum: no inputs")
|
||||
}
|
||||
var s float64
|
||||
for _, v := range in[0].AsArray() {
|
||||
s += v
|
||||
}
|
||||
return Scalar(s), nil
|
||||
}
|
||||
|
||||
// MeanNode averages an array input (array→scalar).
|
||||
type MeanNode struct{}
|
||||
|
||||
func (n *MeanNode) Type() string { return "mean" }
|
||||
func (n *MeanNode) Process(in []float64, st map[string]any) (float64, error) {
|
||||
return reductionProcess(n, in, st)
|
||||
}
|
||||
func (n *MeanNode) ProcessSample(in []Sample, _ map[string]any) (Sample, error) {
|
||||
if len(in) == 0 {
|
||||
return Sample{}, errors.New("mean: no inputs")
|
||||
}
|
||||
arr := in[0].AsArray()
|
||||
if len(arr) == 0 {
|
||||
return Scalar(0), nil
|
||||
}
|
||||
var s float64
|
||||
for _, v := range arr {
|
||||
s += v
|
||||
}
|
||||
return Scalar(s / float64(len(arr))), nil
|
||||
}
|
||||
|
||||
// MinNode returns the minimum element of an array input (array→scalar).
|
||||
type MinNode struct{}
|
||||
|
||||
func (n *MinNode) Type() string { return "min" }
|
||||
func (n *MinNode) Process(in []float64, st map[string]any) (float64, error) {
|
||||
return reductionProcess(n, in, st)
|
||||
}
|
||||
func (n *MinNode) ProcessSample(in []Sample, _ map[string]any) (Sample, error) {
|
||||
if len(in) == 0 {
|
||||
return Sample{}, errors.New("min: no inputs")
|
||||
}
|
||||
arr := in[0].AsArray()
|
||||
if len(arr) == 0 {
|
||||
return Scalar(0), nil
|
||||
}
|
||||
m := arr[0]
|
||||
for _, v := range arr[1:] {
|
||||
if v < m {
|
||||
m = v
|
||||
}
|
||||
}
|
||||
return Scalar(m), nil
|
||||
}
|
||||
|
||||
// MaxNode returns the maximum element of an array input (array→scalar).
|
||||
type MaxNode struct{}
|
||||
|
||||
func (n *MaxNode) Type() string { return "max" }
|
||||
func (n *MaxNode) Process(in []float64, st map[string]any) (float64, error) {
|
||||
return reductionProcess(n, in, st)
|
||||
}
|
||||
func (n *MaxNode) ProcessSample(in []Sample, _ map[string]any) (Sample, error) {
|
||||
if len(in) == 0 {
|
||||
return Sample{}, errors.New("max: no inputs")
|
||||
}
|
||||
arr := in[0].AsArray()
|
||||
if len(arr) == 0 {
|
||||
return Scalar(0), nil
|
||||
}
|
||||
m := arr[0]
|
||||
for _, v := range arr[1:] {
|
||||
if v > m {
|
||||
m = v
|
||||
}
|
||||
}
|
||||
return Scalar(m), nil
|
||||
}
|
||||
|
||||
// LengthNode returns the element count of an array input (array→scalar).
|
||||
type LengthNode struct{}
|
||||
|
||||
func (n *LengthNode) Type() string { return "length" }
|
||||
func (n *LengthNode) Process(in []float64, st map[string]any) (float64, error) {
|
||||
return reductionProcess(n, in, st)
|
||||
}
|
||||
func (n *LengthNode) ProcessSample(in []Sample, _ map[string]any) (Sample, error) {
|
||||
if len(in) == 0 {
|
||||
return Sample{}, errors.New("length: no inputs")
|
||||
}
|
||||
return Scalar(float64(len(in[0].AsArray()))), nil
|
||||
}
|
||||
|
||||
// ── FFTNode ────────────────────────────────────────────────────────────────
|
||||
|
||||
// FFTNode computes the magnitude spectrum of an array input (array→array). The
|
||||
// input is zero-padded to the next power of two; the output has that length and
|
||||
// holds |X[k]| for each frequency bin.
|
||||
type FFTNode struct{}
|
||||
|
||||
func (n *FFTNode) Type() string { return "fft" }
|
||||
func (n *FFTNode) Process(in []float64, st map[string]any) (float64, error) {
|
||||
s, err := n.ProcessSample(scalarInputs(in), st)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(s.Arr) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
return s.Arr[0], nil
|
||||
}
|
||||
func (n *FFTNode) ProcessSample(in []Sample, _ map[string]any) (Sample, error) {
|
||||
if len(in) == 0 {
|
||||
return Sample{}, errors.New("fft: no inputs")
|
||||
}
|
||||
return Array(fftMagnitude(in[0].AsArray())), nil
|
||||
}
|
||||
|
||||
// fftMagnitude returns the magnitude spectrum of x, zero-padded to the next
|
||||
// power of two. Returns an empty slice for empty input.
|
||||
func fftMagnitude(x []float64) []float64 {
|
||||
if len(x) == 0 {
|
||||
return nil
|
||||
}
|
||||
n := nextPow2(len(x))
|
||||
re := make([]float64, n)
|
||||
im := make([]float64, n)
|
||||
copy(re, x)
|
||||
fftRadix2(re, im)
|
||||
mag := make([]float64, n)
|
||||
for i := range mag {
|
||||
mag[i] = math.Hypot(re[i], im[i])
|
||||
}
|
||||
return mag
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package dsp
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSampleRoundTrip(t *testing.T) {
|
||||
s := Scalar(3.5)
|
||||
if s.IsArray {
|
||||
t.Error("Scalar should not be an array")
|
||||
}
|
||||
if s.Type() != ValScalar {
|
||||
t.Errorf("Scalar type: want ValScalar, got %v", s.Type())
|
||||
}
|
||||
if s.AsAny() != 3.5 {
|
||||
t.Errorf("Scalar AsAny: want 3.5, got %v", s.AsAny())
|
||||
}
|
||||
if got := s.AsArray(); len(got) != 1 || got[0] != 3.5 {
|
||||
t.Errorf("Scalar AsArray: want [3.5], got %v", got)
|
||||
}
|
||||
|
||||
a := Array([]float64{1, 2, 3})
|
||||
if !a.IsArray {
|
||||
t.Error("Array should be an array")
|
||||
}
|
||||
if a.Type() != ValArray {
|
||||
t.Errorf("Array type: want ValArray, got %v", a.Type())
|
||||
}
|
||||
got, ok := a.AsAny().([]float64)
|
||||
if !ok || len(got) != 3 {
|
||||
t.Errorf("Array AsAny: want []float64 len 3, got %v", a.AsAny())
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyElementwiseAllScalar(t *testing.T) {
|
||||
n := &AddNode{}
|
||||
out, err := ApplyElementwise(n, []Sample{Scalar(2), Scalar(3)}, map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.IsArray || out.F != 5 {
|
||||
t.Errorf("all-scalar add: want scalar 5, got %v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyElementwiseBroadcast(t *testing.T) {
|
||||
// array ⊕ scalar: scalar is a constant broadcast across the array.
|
||||
n := &AddNode{}
|
||||
out, err := ApplyElementwise(n, []Sample{Array([]float64{1, 2, 3}), Scalar(10)}, map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !out.IsArray {
|
||||
t.Fatalf("array+scalar: want array, got %v", out)
|
||||
}
|
||||
want := []float64{11, 12, 13}
|
||||
for i, v := range want {
|
||||
if out.Arr[i] != v {
|
||||
t.Errorf("array+scalar[%d]: want %v, got %v", i, v, out.Arr[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyElementwiseArrayArray(t *testing.T) {
|
||||
n := &MultiplyNode{}
|
||||
out, err := ApplyElementwise(n, []Sample{Array([]float64{1, 2, 3}), Array([]float64{4, 5, 6})}, map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []float64{4, 10, 18}
|
||||
for i, v := range want {
|
||||
if out.Arr[i] != v {
|
||||
t.Errorf("array*array[%d]: want %v, got %v", i, v, out.Arr[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyElementwiseLengthMismatch(t *testing.T) {
|
||||
n := &AddNode{}
|
||||
_, err := ApplyElementwise(n, []Sample{Array([]float64{1, 2}), Array([]float64{1, 2, 3})}, map[string]any{})
|
||||
if err == nil {
|
||||
t.Error("expected length-mismatch error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReductionNodes(t *testing.T) {
|
||||
arr := []Sample{Array([]float64{2, 4, 6, 8})}
|
||||
cases := []struct {
|
||||
name string
|
||||
node ArrayNode
|
||||
want float64
|
||||
}{
|
||||
{"sum", &SumNode{}, 20},
|
||||
{"mean", &MeanNode{}, 5},
|
||||
{"min", &MinNode{}, 2},
|
||||
{"max", &MaxNode{}, 8},
|
||||
{"length", &LengthNode{}, 4},
|
||||
{"index", &IndexNode{I: 2}, 6},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
out, err := tc.node.ProcessSample(arr, map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.IsArray || out.F != tc.want {
|
||||
t.Errorf("%s: want scalar %v, got %v", tc.name, tc.want, out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndexNodeOutOfRange(t *testing.T) {
|
||||
n := &IndexNode{I: 9}
|
||||
_, err := n.ProcessSample([]Sample{Array([]float64{1, 2, 3})}, map[string]any{})
|
||||
if err == nil {
|
||||
t.Error("expected out-of-range error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSliceNode(t *testing.T) {
|
||||
n := &SliceNode{Start: 1, End: 3}
|
||||
out, err := n.ProcessSample([]Sample{Array([]float64{10, 20, 30, 40})}, map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []float64{20, 30}
|
||||
if len(out.Arr) != len(want) {
|
||||
t.Fatalf("slice: want len %d, got %d", len(want), len(out.Arr))
|
||||
}
|
||||
for i, v := range want {
|
||||
if out.Arr[i] != v {
|
||||
t.Errorf("slice[%d]: want %v, got %v", i, v, out.Arr[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFFTMagnitude(t *testing.T) {
|
||||
// A constant signal has all energy in bin 0 (the DC term equals the sum).
|
||||
x := []float64{1, 1, 1, 1}
|
||||
mag := fftMagnitude(x)
|
||||
if len(mag) != 4 {
|
||||
t.Fatalf("fft len: want 4, got %d", len(mag))
|
||||
}
|
||||
if math.Abs(mag[0]-4) > 1e-9 {
|
||||
t.Errorf("fft DC bin: want 4, got %v", mag[0])
|
||||
}
|
||||
for k := 1; k < len(mag); k++ {
|
||||
if math.Abs(mag[k]) > 1e-9 {
|
||||
t.Errorf("fft bin %d: want ~0, got %v", k, mag[k])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFFTSingleTone(t *testing.T) {
|
||||
// One full cycle of a cosine over 8 samples → energy in bins 1 and N-1.
|
||||
n := 8
|
||||
x := make([]float64, n)
|
||||
for i := range x {
|
||||
x[i] = math.Cos(2 * math.Pi * float64(i) / float64(n))
|
||||
}
|
||||
mag := fftMagnitude(x)
|
||||
if math.Abs(mag[1]-float64(n)/2) > 1e-6 {
|
||||
t.Errorf("fft tone bin 1: want %v, got %v", float64(n)/2, mag[1])
|
||||
}
|
||||
if math.Abs(mag[n-1]-float64(n)/2) > 1e-6 {
|
||||
t.Errorf("fft tone bin %d: want %v, got %v", n-1, float64(n)/2, mag[n-1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpOutputType(t *testing.T) {
|
||||
cases := []struct {
|
||||
op string
|
||||
in []ValType
|
||||
want ValType
|
||||
wantErr bool
|
||||
}{
|
||||
// reductions → scalar regardless of input
|
||||
{"sum", []ValType{ValArray}, ValScalar, false},
|
||||
{"mean", []ValType{ValScalar}, ValScalar, false},
|
||||
{"index", []ValType{ValUnknown}, ValScalar, false},
|
||||
// array producers require array, yield array
|
||||
{"fft", []ValType{ValArray}, ValArray, false},
|
||||
{"slice", []ValType{ValUnknown}, ValArray, false},
|
||||
{"fft", []ValType{ValScalar}, ValUnknown, true},
|
||||
// scalar-only reject arrays
|
||||
{"moving_average", []ValType{ValScalar}, ValScalar, false},
|
||||
{"lua", []ValType{ValArray}, ValUnknown, true},
|
||||
{"rms", []ValType{ValUnknown}, ValScalar, false},
|
||||
// elementwise: array if any array, scalar if all scalar, else unknown
|
||||
{"add", []ValType{ValScalar, ValScalar}, ValScalar, false},
|
||||
{"add", []ValType{ValArray, ValScalar}, ValArray, false},
|
||||
{"gain", []ValType{ValUnknown}, ValUnknown, false},
|
||||
{"expr", []ValType{ValArray, ValScalar}, ValArray, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got, err := OpOutputType(tc.op, tc.in)
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("OpOutputType(%q,%v): expected error", tc.op, tc.in)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("OpOutputType(%q,%v): unexpected error %v", tc.op, tc.in, err)
|
||||
continue
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("OpOutputType(%q,%v): want %v, got %v", tc.op, tc.in, tc.want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package dsp
|
||||
|
||||
import "math"
|
||||
|
||||
// nextPow2 returns the smallest power of two >= n (and at least 1).
|
||||
func nextPow2(n int) int {
|
||||
p := 1
|
||||
for p < n {
|
||||
p <<= 1
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// fftRadix2 computes the in-place iterative radix-2 Cooley-Tukey FFT of the
|
||||
// complex signal held in re/im. len(re) == len(im) must be a power of two. The
|
||||
// transform overwrites re/im with the frequency-domain result. This is a small
|
||||
// self-contained implementation (no external dependency) used by the synthetic
|
||||
// fft op.
|
||||
func fftRadix2(re, im []float64) {
|
||||
n := len(re)
|
||||
if n <= 1 {
|
||||
return
|
||||
}
|
||||
|
||||
// Bit-reversal permutation.
|
||||
for i, j := 1, 0; i < n; i++ {
|
||||
bit := n >> 1
|
||||
for ; j&bit != 0; bit >>= 1 {
|
||||
j ^= bit
|
||||
}
|
||||
j ^= bit
|
||||
if i < j {
|
||||
re[i], re[j] = re[j], re[i]
|
||||
im[i], im[j] = im[j], im[i]
|
||||
}
|
||||
}
|
||||
|
||||
// Danielson-Lanczos butterflies.
|
||||
for length := 2; length <= n; length <<= 1 {
|
||||
ang := -2 * math.Pi / float64(length)
|
||||
wReal, wImag := math.Cos(ang), math.Sin(ang)
|
||||
for i := 0; i < n; i += length {
|
||||
curReal, curImag := 1.0, 0.0
|
||||
half := length >> 1
|
||||
for k := 0; k < half; k++ {
|
||||
a := i + k
|
||||
b := i + k + half
|
||||
tReal := curReal*re[b] - curImag*im[b]
|
||||
tImag := curReal*im[b] + curImag*re[b]
|
||||
re[b] = re[a] - tReal
|
||||
im[b] = im[a] - tImag
|
||||
re[a] += tReal
|
||||
im[a] += tImag
|
||||
curReal, curImag = curReal*wReal-curImag*wImag, curReal*wImag+curImag*wReal
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
-10
@@ -277,16 +277,28 @@ func (n *ThresholdNode) Process(inputs []float64, _ map[string]any) (float64, er
|
||||
|
||||
// ── ExprNode ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// ExprNode evaluates a simple arithmetic expression with variables a, b, c, d
|
||||
// bound to inputs[0..3]. It uses a hand-written recursive descent parser.
|
||||
// ExprNode evaluates a simple arithmetic expression. Inputs are bound to named
|
||||
// variables: Vars[i] -> inputs[i]. When Vars is empty it defaults to a, b, c, d
|
||||
// (bound to inputs[0..3]) for backward compatibility. It uses a hand-written
|
||||
// recursive descent parser.
|
||||
type ExprNode struct {
|
||||
Expr string
|
||||
Vars []string
|
||||
}
|
||||
|
||||
// defaultVarNames returns the variable names for an expr/lua node: the explicit
|
||||
// list when set, otherwise the legacy a,b,c,d.
|
||||
func defaultVarNames(vars []string) []string {
|
||||
if len(vars) > 0 {
|
||||
return vars
|
||||
}
|
||||
return []string{"a", "b", "c", "d"}
|
||||
}
|
||||
|
||||
func (n *ExprNode) Type() string { return "expr" }
|
||||
func (n *ExprNode) Process(inputs []float64, _ map[string]any) (float64, error) {
|
||||
vars := map[string]float64{}
|
||||
names := []string{"a", "b", "c", "d"}
|
||||
names := defaultVarNames(n.Vars)
|
||||
for i, name := range names {
|
||||
if i < len(inputs) {
|
||||
vars[name] = inputs[i]
|
||||
@@ -507,13 +519,10 @@ func (p *exprParser) parseCall() (float64, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Not a function call — must be a single-letter variable.
|
||||
if len(name) != 1 {
|
||||
return 0, fmt.Errorf("unknown identifier %q (use a–d for variables, or a known function name)", name)
|
||||
}
|
||||
// Not a function call — must be a declared input variable.
|
||||
val, ok := p.vars[name]
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("unknown variable %q (allowed: a, b, c, d)", name)
|
||||
return 0, fmt.Errorf("unknown variable %q (declare it as a named input)", name)
|
||||
}
|
||||
return val, nil
|
||||
}
|
||||
@@ -628,10 +637,12 @@ func (n *LowPassNode) Process(inputs []float64, state map[string]any) (float64,
|
||||
// ── LuaNode ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// LuaNode runs a Lua script in a sandboxed gopher-lua VM.
|
||||
// Inputs are bound to globals a, b, c, d. The script's return value is the output.
|
||||
// Inputs are bound to globals named by Vars (Vars[i] -> inputs[i]); when Vars is
|
||||
// empty it defaults to a, b, c, d. The script's return value is the output.
|
||||
// The os, io, package, and debug libraries are disabled.
|
||||
type LuaNode struct {
|
||||
Script string
|
||||
Vars []string
|
||||
}
|
||||
|
||||
func (n *LuaNode) Type() string { return "lua" }
|
||||
@@ -682,7 +693,7 @@ func (n *LuaNode) Process(inputs []float64, state map[string]any) (result float6
|
||||
L.SetTop(0)
|
||||
|
||||
// Bind inputs.
|
||||
names := []string{"a", "b", "c", "d"}
|
||||
names := defaultVarNames(n.Vars)
|
||||
for i, name := range names {
|
||||
if i < len(inputs) {
|
||||
L.SetGlobal(name, lua.LNumber(inputs[i]))
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
package dsp
|
||||
|
||||
import "fmt"
|
||||
|
||||
// ValType is the data type of a Sample: a scalar float, a float array
|
||||
// (waveform), or — at graph-compile time, before a source's real type is
|
||||
// known — unknown.
|
||||
type ValType uint8
|
||||
|
||||
const (
|
||||
ValUnknown ValType = iota
|
||||
ValScalar
|
||||
ValArray
|
||||
)
|
||||
|
||||
func (t ValType) String() string {
|
||||
switch t {
|
||||
case ValScalar:
|
||||
return "scalar"
|
||||
case ValArray:
|
||||
return "array"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// Sample is a value flowing through the synthetic DSP graph: either a scalar
|
||||
// float64 or a float64 array (waveform). It is the array-aware counterpart of
|
||||
// the bare float64 the legacy scalar Node interface uses.
|
||||
type Sample struct {
|
||||
F float64
|
||||
Arr []float64
|
||||
IsArray bool
|
||||
}
|
||||
|
||||
// Scalar wraps a float64 as a scalar Sample.
|
||||
func Scalar(f float64) Sample { return Sample{F: f} }
|
||||
|
||||
// Array wraps a []float64 as an array Sample.
|
||||
func Array(a []float64) Sample { return Sample{Arr: a, IsArray: true} }
|
||||
|
||||
// Type reports whether the sample is a scalar or an array.
|
||||
func (s Sample) Type() ValType {
|
||||
if s.IsArray {
|
||||
return ValArray
|
||||
}
|
||||
return ValScalar
|
||||
}
|
||||
|
||||
// AsAny returns the value in the form datasource.Value.Data expects: a
|
||||
// []float64 for arrays, a float64 for scalars.
|
||||
func (s Sample) AsAny() any {
|
||||
if s.IsArray {
|
||||
return s.Arr
|
||||
}
|
||||
return s.F
|
||||
}
|
||||
|
||||
// AsArray returns the sample's data as a slice: the array itself, or a
|
||||
// single-element slice for a scalar. Used by reductions that accept either.
|
||||
func (s Sample) AsArray() []float64 {
|
||||
if s.IsArray {
|
||||
return s.Arr
|
||||
}
|
||||
return []float64{s.F}
|
||||
}
|
||||
|
||||
// ArrayNode is an optional extension of Node implemented by ops that operate
|
||||
// natively on Samples (reductions array→scalar, producers array→array, etc.).
|
||||
// eval prefers ProcessSample when a node implements it.
|
||||
type ArrayNode interface {
|
||||
Node
|
||||
ProcessSample(inputs []Sample, state map[string]any) (Sample, error)
|
||||
}
|
||||
|
||||
// statelessElementwise lists scalar ops that are safe to broadcast element-wise
|
||||
// over array inputs: they hold no per-evaluation state, so running the legacy
|
||||
// Process once per array lane is well-defined. Stateful ops (moving_average,
|
||||
// rms, lowpass, derivative, integrate) and lua are excluded — a single shared
|
||||
// state map cannot be meaningfully split across lanes.
|
||||
var statelessElementwise = map[string]bool{
|
||||
"gain": true, "offset": true, "add": true, "subtract": true,
|
||||
"multiply": true, "divide": true, "clamp": true, "threshold": true,
|
||||
"expr": true,
|
||||
}
|
||||
|
||||
// StatelessElementwise reports whether a scalar op type may be broadcast over
|
||||
// array inputs via ApplyElementwise.
|
||||
func StatelessElementwise(nodeType string) bool { return statelessElementwise[nodeType] }
|
||||
|
||||
// scalarInputs wraps a legacy float64 input slice as scalar Samples.
|
||||
func scalarInputs(in []float64) []Sample {
|
||||
out := make([]Sample, len(in))
|
||||
for i, v := range in {
|
||||
out[i] = Scalar(v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ApplyElementwise runs a stateless scalar Node over Sample inputs. If every
|
||||
// input is scalar it calls Process once and wraps the result. If any input is
|
||||
// an array it broadcasts: scalar inputs act as constants, all array inputs must
|
||||
// share a common length (else an error), and Process is invoked once per index.
|
||||
//
|
||||
// The node MUST be stateless (see StatelessElementwise) — a shared state map
|
||||
// cannot be split across array lanes.
|
||||
func ApplyElementwise(n Node, inputs []Sample, state map[string]any) (Sample, error) {
|
||||
// Determine the array length, if any input is an array.
|
||||
length := -1
|
||||
for _, s := range inputs {
|
||||
if !s.IsArray {
|
||||
continue
|
||||
}
|
||||
if length == -1 {
|
||||
length = len(s.Arr)
|
||||
} else if len(s.Arr) != length {
|
||||
return Sample{}, fmt.Errorf("%s: array length mismatch (%d vs %d)", n.Type(), length, len(s.Arr))
|
||||
}
|
||||
}
|
||||
|
||||
if length == -1 {
|
||||
// All scalar — single legacy call.
|
||||
row := make([]float64, len(inputs))
|
||||
for i, s := range inputs {
|
||||
row[i] = s.F
|
||||
}
|
||||
r, err := n.Process(row, state)
|
||||
if err != nil {
|
||||
return Sample{}, err
|
||||
}
|
||||
return Scalar(r), nil
|
||||
}
|
||||
|
||||
out := make([]float64, length)
|
||||
row := make([]float64, len(inputs))
|
||||
for i := 0; i < length; i++ {
|
||||
for j, s := range inputs {
|
||||
if s.IsArray {
|
||||
row[j] = s.Arr[i]
|
||||
} else {
|
||||
row[j] = s.F
|
||||
}
|
||||
}
|
||||
r, err := n.Process(row, state)
|
||||
if err != nil {
|
||||
return Sample{}, err
|
||||
}
|
||||
out[i] = r
|
||||
}
|
||||
return Array(out), nil
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/uopi/uopi/internal/access"
|
||||
"github.com/uopi/uopi/internal/audit"
|
||||
"github.com/uopi/uopi/internal/broker"
|
||||
"github.com/uopi/uopi/internal/controllogic"
|
||||
"github.com/uopi/uopi/internal/datasource"
|
||||
)
|
||||
|
||||
// DialogHub fans control-logic dialog requests out to connected WebSocket
|
||||
// clients whose identity matches the dialog's user/group filter, and routes
|
||||
// input responses back to the dialog's target server variable.
|
||||
//
|
||||
// It implements controllogic.Notifier; the engine calls Notify when an
|
||||
// action.dialog node runs. Input dialogs are remembered as pending so a later
|
||||
// dialogResponse can be correlated by id and validated against its recipient
|
||||
// filter — this is why panels can write the response target even though direct
|
||||
// srv writes are otherwise gated to control-logic editors.
|
||||
type DialogHub struct {
|
||||
broker *broker.Broker
|
||||
policy *access.Policy
|
||||
audit audit.Recorder
|
||||
log *slog.Logger
|
||||
|
||||
mu sync.Mutex
|
||||
clients map[*wsClient]struct{}
|
||||
pending map[string]controllogic.Dialog // input dialogs awaiting a response
|
||||
}
|
||||
|
||||
// NewDialogHub builds an empty hub. rec is never nil after construction.
|
||||
func NewDialogHub(brk *broker.Broker, policy *access.Policy, rec audit.Recorder, log *slog.Logger) *DialogHub {
|
||||
if rec == nil {
|
||||
rec = audit.Nop()
|
||||
}
|
||||
return &DialogHub{
|
||||
broker: brk,
|
||||
policy: policy,
|
||||
audit: rec,
|
||||
log: log,
|
||||
clients: map[*wsClient]struct{}{},
|
||||
pending: map[string]controllogic.Dialog{},
|
||||
}
|
||||
}
|
||||
|
||||
func (h *DialogHub) add(c *wsClient) {
|
||||
h.mu.Lock()
|
||||
h.clients[c] = struct{}{}
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
func (h *DialogHub) remove(c *wsClient) {
|
||||
h.mu.Lock()
|
||||
delete(h.clients, c)
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
// dialogOut is the client-bound JSON form of a dialog request.
|
||||
type dialogOut struct {
|
||||
Type string `json:"type"` // always "dialog"
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"` // "info" | "error" | "input"
|
||||
Title string `json:"title,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// Notify implements controllogic.Notifier: serialise the dialog and push it to
|
||||
// every connected client matching its user/group filter.
|
||||
func (h *DialogHub) Notify(d controllogic.Dialog) {
|
||||
b, err := json.Marshal(dialogOut{
|
||||
Type: "dialog",
|
||||
ID: d.ID,
|
||||
Kind: d.Kind,
|
||||
Title: d.Title,
|
||||
Message: d.Message,
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
if d.Kind == "input" && strings.TrimSpace(d.Target) != "" {
|
||||
h.pending[d.ID] = d
|
||||
}
|
||||
var targets []*wsClient
|
||||
for c := range h.clients {
|
||||
if h.matches(c.user, d) {
|
||||
targets = append(targets, c)
|
||||
}
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
for _, c := range targets {
|
||||
select {
|
||||
case c.outCh <- b:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// matches reports whether user is a recipient of d. An empty user+group filter
|
||||
// targets everyone; otherwise the user must be named or in a named group.
|
||||
func (h *DialogHub) matches(user string, d controllogic.Dialog) bool {
|
||||
if len(d.Users) == 0 && len(d.Groups) == 0 {
|
||||
return true
|
||||
}
|
||||
if h.policy != nil {
|
||||
user = h.policy.ResolveUser(user)
|
||||
}
|
||||
for _, u := range d.Users {
|
||||
if u == user {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if len(d.Groups) > 0 && h.policy != nil {
|
||||
groups := map[string]bool{}
|
||||
for _, g := range h.policy.GroupsOf(user) {
|
||||
groups[g] = true
|
||||
}
|
||||
for _, g := range d.Groups {
|
||||
if groups[g] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// cancel drops a pending input dialog without writing (user dismissed it).
|
||||
func (h *DialogHub) cancel(id string) {
|
||||
h.mu.Lock()
|
||||
delete(h.pending, id)
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
// respond writes an input dialog's response to its target server variable. The
|
||||
// dialog must be pending and the responding client must have been a recipient;
|
||||
// this gate replaces the usual srv write-permission check for sanctioned
|
||||
// control-logic responses.
|
||||
func (h *DialogHub) respond(ctx context.Context, c *wsClient, id string, value float64) {
|
||||
h.mu.Lock()
|
||||
d, ok := h.pending[id]
|
||||
if ok {
|
||||
delete(h.pending, id)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
if !ok || !h.matches(c.user, d) {
|
||||
return
|
||||
}
|
||||
|
||||
ds, name, ok := parseDialogTarget(d.Target)
|
||||
if !ok || ds == "local" {
|
||||
return
|
||||
}
|
||||
src, ok := h.broker.Source(ds)
|
||||
if !ok {
|
||||
h.log.Warn("dialog response: unknown data source", "ds", ds, "target", d.Target)
|
||||
return
|
||||
}
|
||||
|
||||
ev := audit.Event{
|
||||
Actor: c.user,
|
||||
ActorType: audit.ActorUser,
|
||||
Action: "signal.write",
|
||||
DS: ds,
|
||||
Signal: name,
|
||||
Value: strconv.FormatFloat(value, 'g', -1, 64),
|
||||
Detail: "control logic dialog response",
|
||||
IP: c.ip,
|
||||
Outcome: audit.OutcomeOK,
|
||||
}
|
||||
wctx := datasource.WithUser(ctx, c.user)
|
||||
if err := src.Write(wctx, name, value); err != nil {
|
||||
h.log.Warn("dialog response: write failed", "ds", ds, "signal", name, "err", err)
|
||||
ev.Outcome = audit.OutcomeError
|
||||
ev.Error = err.Error()
|
||||
}
|
||||
h.audit.Record(ev)
|
||||
}
|
||||
|
||||
// parseDialogTarget splits a "ds:name" dialog target on the first ':'. A bare
|
||||
// name (no ':') defaults to the persistent server-variable source "srv".
|
||||
func parseDialogTarget(t string) (ds, name string, ok bool) {
|
||||
t = strings.TrimSpace(t)
|
||||
if t == "" {
|
||||
return "", "", false
|
||||
}
|
||||
if i := strings.IndexByte(t, ':'); i >= 0 {
|
||||
return t[:i], t[i+1:], true
|
||||
}
|
||||
return "srv", t, true
|
||||
}
|
||||
@@ -10,7 +10,9 @@ import (
|
||||
|
||||
"github.com/uopi/uopi/internal/access"
|
||||
"github.com/uopi/uopi/internal/api"
|
||||
"github.com/uopi/uopi/internal/audit"
|
||||
"github.com/uopi/uopi/internal/broker"
|
||||
"github.com/uopi/uopi/internal/confmgr"
|
||||
"github.com/uopi/uopi/internal/controllogic"
|
||||
"github.com/uopi/uopi/internal/datasource/synthetic"
|
||||
"github.com/uopi/uopi/internal/metrics"
|
||||
@@ -27,7 +29,10 @@ type Server struct {
|
||||
|
||||
// New creates the HTTP server, registers all routes, and returns a ready-to-start Server.
|
||||
// synth may be nil if the synthetic data source is not enabled.
|
||||
func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, policy *access.Policy, acl *panelacl.Store, ctrlLogic *controllogic.Store, ctrlEngine *controllogic.Engine, channelFinderURL, archiverURL, trustedUserHeader string, log *slog.Logger) *Server {
|
||||
func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, cfgStore *confmgr.Store, policy *access.Policy, acl *panelacl.Store, ctrlLogic *controllogic.Store, ctrlEngine *controllogic.Engine, dialogs *DialogHub, 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)
|
||||
|
||||
@@ -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"`
|
||||
@@ -53,6 +68,8 @@ type outMsg struct {
|
||||
TS string `json:"ts,omitempty"`
|
||||
Value any `json:"value,omitempty"`
|
||||
Quality string `json:"quality,omitempty"`
|
||||
Severity int `json:"severity,omitempty"` // raw EPICS alarm severity (0=NO_ALARM)
|
||||
Status int `json:"status,omitempty"` // raw EPICS alarm status
|
||||
|
||||
// meta
|
||||
Meta *metaPayload `json:"meta,omitempty"`
|
||||
@@ -94,6 +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)
|
||||
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
|
||||
@@ -196,6 +248,8 @@ func (c *wsClient) dispatchLoop(ctx context.Context) {
|
||||
TS: u.Value.Timestamp.UTC().Format(time.RFC3339Nano),
|
||||
Value: u.Value.Data,
|
||||
Quality: u.Value.Quality.String(),
|
||||
Severity: u.Value.Severity,
|
||||
Status: u.Value.Status,
|
||||
}
|
||||
b, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
@@ -248,6 +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),
|
||||
|
||||
@@ -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,18 +477,45 @@ func setXMLAttribute(data []byte, attr, value string) ([]byte, error) {
|
||||
return []byte(s[:valStart] + value + s[valStart+valEnd:]), nil
|
||||
}
|
||||
|
||||
// Delete removes the interface with the given ID.
|
||||
// Delete moves the interface with the given ID — and all of its version
|
||||
// backups — into a timestamped trash folder so it can be recovered if needed.
|
||||
// Nothing is destroyed; recovery is a matter of moving the files back.
|
||||
func (s *Store) Delete(id string) error {
|
||||
if err := validateID(id); err != nil {
|
||||
return ErrNotFound
|
||||
}
|
||||
err := os.Remove(s.filePath(id))
|
||||
if _, err := os.Stat(s.filePath(id)); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
trashDir := filepath.Join(s.rootDir, "trash", "interfaces", fmt.Sprintf("%s.%d", id, time.Now().UnixMilli()))
|
||||
if err := os.MkdirAll(trashDir, 0o755); err != nil {
|
||||
return fmt.Errorf("create trash dir: %w", err)
|
||||
}
|
||||
|
||||
// Move the current file plus every versioned backup (id.vN.xml), preserving
|
||||
// their original names so a restore is a straight move-back.
|
||||
entries, err := os.ReadDir(s.dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
if name == id+".xml" || (strings.HasPrefix(name, id+".v") && isVersioned(name)) {
|
||||
if err := os.Rename(filepath.Join(s.dir, name), filepath.Join(trashDir, name)); err != nil {
|
||||
return fmt.Errorf("move %s to trash: %w", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Clone duplicates an interface, assigning a new unique ID.
|
||||
func (s *Store) Clone(srcID string) (string, error) {
|
||||
if err := validateID(srcID); err != nil {
|
||||
|
||||
+26
-16
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { wsClient } from './lib/ws';
|
||||
import ViewMode from './ViewMode';
|
||||
import EditMode from './EditMode';
|
||||
import FullscreenMode from './FullscreenMode';
|
||||
import ControlDialogs from './ControlDialogs';
|
||||
import { applyZoom, getStoredZoom } from './ZoomControl';
|
||||
import { AuthContext, DEFAULT_ME, fetchMe, canRead } from './lib/auth';
|
||||
import type { Interface, Me } from './lib/types';
|
||||
@@ -69,6 +70,7 @@ export default function App() {
|
||||
return (
|
||||
<AuthContext.Provider value={me}>
|
||||
<div class="app-root">
|
||||
<ControlDialogs />
|
||||
{wsStatus === 'disconnected' && (
|
||||
<div class="connection-banner">WebSocket disconnected — reconnecting…</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useEffect, useCallback } from 'preact/hooks';
|
||||
|
||||
// AuditEvent mirrors the JSON returned by GET /api/v1/audit (internal/audit.Event).
|
||||
interface AuditEvent {
|
||||
time: string;
|
||||
actor: string;
|
||||
actorType: string; // "user" | "system"
|
||||
action: string;
|
||||
ds?: string;
|
||||
signal?: string;
|
||||
value?: string;
|
||||
detail?: string;
|
||||
ip?: string;
|
||||
outcome: string; // "ok" | "error"
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface Props { onClose: () => void; }
|
||||
|
||||
/** Convert an RFC3339/ISO timestamp to a compact local datetime string. */
|
||||
function fmtTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return iso;
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
/** Convert a datetime-local input value (local time) to an RFC3339 string. */
|
||||
function localToRFC3339(v: string): string {
|
||||
if (!v) return '';
|
||||
const d = new Date(v);
|
||||
return isNaN(d.getTime()) ? '' : d.toISOString();
|
||||
}
|
||||
|
||||
export default function AuditViewer({ onClose }: Props) {
|
||||
const [events, setEvents] = useState<AuditEvent[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Filters
|
||||
const [user, setUser] = useState('');
|
||||
const [action, setAction] = useState('');
|
||||
const [signal, setSignal] = useState('');
|
||||
const [start, setStart] = useState('');
|
||||
const [end, setEnd] = useState('');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const q = new URLSearchParams();
|
||||
if (user.trim()) q.set('user', user.trim());
|
||||
if (action) q.set('action', action);
|
||||
if (signal.trim()) q.set('signal', signal.trim());
|
||||
const s = localToRFC3339(start);
|
||||
const e = localToRFC3339(end);
|
||||
if (s) q.set('start', s);
|
||||
if (e) q.set('end', e);
|
||||
const res = await fetch(`/api/v1/audit?${q.toString()}`);
|
||||
if (res.status === 403) throw new Error('You are not permitted to view the audit log.');
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
setEvents(Array.isArray(data) ? data : []);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
setEvents([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [user, action, signal, start, end]);
|
||||
|
||||
// Initial load.
|
||||
useEffect(() => { load(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
function resetFilters() {
|
||||
setUser(''); setAction(''); setSignal(''); setStart(''); setEnd('');
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="cl-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
||||
<div class="cl-modal audit-modal">
|
||||
<header class="cl-header">
|
||||
<span class="cl-title">Audit log</span>
|
||||
<span class="hint cl-subtitle">Every system-affecting action (signal writes, control-logic and interface changes).</span>
|
||||
<div class="cl-header-actions">
|
||||
<button class="panel-btn" disabled={loading} onClick={load}>{loading ? 'Loading…' : 'Refresh'}</button>
|
||||
<button class="panel-btn" onClick={onClose}>Close</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="audit-filters">
|
||||
<input class="audit-filter" type="text" placeholder="User" value={user}
|
||||
onInput={(e) => setUser((e.target as HTMLInputElement).value)} />
|
||||
<select class="audit-filter" value={action}
|
||||
onChange={(e) => setAction((e.target as HTMLSelectElement).value)}>
|
||||
<option value="">All actions</option>
|
||||
<option value="signal.write">signal.write</option>
|
||||
<option value="interface.create">interface.create</option>
|
||||
<option value="interface.update">interface.update</option>
|
||||
<option value="interface.delete">interface.delete</option>
|
||||
<option value="controllogic.create">controllogic.create</option>
|
||||
<option value="controllogic.update">controllogic.update</option>
|
||||
<option value="controllogic.delete">controllogic.delete</option>
|
||||
</select>
|
||||
<input class="audit-filter" type="text" placeholder="Signal contains…" value={signal}
|
||||
onInput={(e) => setSignal((e.target as HTMLInputElement).value)} />
|
||||
<label class="audit-filter-label">From
|
||||
<input class="audit-filter" type="datetime-local" value={start}
|
||||
onInput={(e) => setStart((e.target as HTMLInputElement).value)} />
|
||||
</label>
|
||||
<label class="audit-filter-label">To
|
||||
<input class="audit-filter" type="datetime-local" value={end}
|
||||
onInput={(e) => setEnd((e.target as HTMLInputElement).value)} />
|
||||
</label>
|
||||
<button class="panel-btn" disabled={loading} onClick={load}>Apply</button>
|
||||
<button class="panel-btn" onClick={resetFilters}>Reset</button>
|
||||
</div>
|
||||
|
||||
{error && <div class="cl-error">{error}</div>}
|
||||
|
||||
<div class="audit-body">
|
||||
{!error && events.length === 0 && !loading && (
|
||||
<div class="hint audit-empty">No matching audit entries.</div>
|
||||
)}
|
||||
{events.length > 0 && (
|
||||
<table class="audit-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Actor</th>
|
||||
<th>Action</th>
|
||||
<th>Source</th>
|
||||
<th>Signal / target</th>
|
||||
<th>Value</th>
|
||||
<th>Detail</th>
|
||||
<th>Client</th>
|
||||
<th>Result</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{events.map((ev, i) => (
|
||||
<tr key={i} class={ev.outcome === 'error' ? 'audit-row-error' : ''}>
|
||||
<td class="audit-time">{fmtTime(ev.time)}</td>
|
||||
<td>
|
||||
<span class={`audit-actor-tag audit-actor-${ev.actorType}`}>{ev.actorType}</span>
|
||||
{' '}{ev.actor || '—'}
|
||||
</td>
|
||||
<td>{ev.action}</td>
|
||||
<td>{ev.ds || '—'}</td>
|
||||
<td class="audit-signal" title={ev.signal}>{ev.signal || '—'}</td>
|
||||
<td class="audit-value" title={ev.value}>{ev.value ?? '—'}</td>
|
||||
<td class="audit-detail" title={ev.detail}>{ev.detail || '—'}</td>
|
||||
<td>{ev.ip || '—'}</td>
|
||||
<td>
|
||||
{ev.outcome === 'error'
|
||||
? <span class="audit-result-err" title={ev.error}>error</span>
|
||||
: <span class="audit-result-ok">ok</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+57
-2
@@ -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
@@ -1,95 +0,0 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useRef, useEffect } from 'preact/hooks';
|
||||
|
||||
interface Props {
|
||||
mode: 'view' | 'edit';
|
||||
onOpenManual: (section?: string) => void;
|
||||
}
|
||||
|
||||
const TIPS: Record<string, Array<{ text: string; section?: string }>> = {
|
||||
view: [
|
||||
{ text: 'Click any interface in the left panel to load it on the canvas.', section: 'view' },
|
||||
{ text: 'Right-click a widget to view signal info or export data as CSV.', section: 'view' },
|
||||
{ text: 'The ● Live button shows live streaming is active. Use the date pickers to load historical data.', section: 'history' },
|
||||
{ text: 'Click Edit (top-right) to open the drag-and-drop panel builder.', section: 'edit' },
|
||||
{ text: 'Use the Share button on a panel to grant access to users or groups.', section: 'sharing' },
|
||||
{ text: 'The ⚙ Control logic button opens server-side automation that runs even with no panel open.', section: 'control' },
|
||||
{ text: 'The dot in the status chip turns green when the WebSocket is connected.', section: 'view' },
|
||||
],
|
||||
edit: [
|
||||
{ text: 'Drag a signal from the left tree onto the canvas to create a widget.', section: 'edit' },
|
||||
{ text: 'Click a widget to select it, then adjust its properties on the right.', section: 'edit' },
|
||||
{ text: 'Ctrl+click to select multiple widgets; then use the align toolbar.', section: 'edit' },
|
||||
{ text: 'Ctrl+Z to undo, Ctrl+Y to redo. Up to 50 steps are remembered.', section: 'shortcuts' },
|
||||
{ text: 'Use "+ Synthetic" in the signal tree to create computed/filtered signals.', section: 'signals' },
|
||||
{ text: 'Switch to the Logic tab to add interactive behaviour with a node-graph flow editor.', section: 'logic' },
|
||||
{ text: 'Add Local variables for set-points and counters that live inside the panel.', section: 'edit' },
|
||||
{ text: 'Arrow keys nudge the selected widget by 1 px (Shift for grid size).', section: 'shortcuts' },
|
||||
],
|
||||
};
|
||||
|
||||
export default function ContextualHelp({ mode, onOpenManual }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [tipIndex, setTipIndex] = useState(0);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const tips = TIPS[mode];
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handler(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [open]);
|
||||
|
||||
// Cycle to a new random tip each time the popover opens
|
||||
function handleOpen() {
|
||||
setTipIndex(t => (t + 1) % tips.length);
|
||||
setOpen(o => !o);
|
||||
}
|
||||
|
||||
const tip = tips[tipIndex];
|
||||
|
||||
return (
|
||||
<div class="ctx-help" ref={ref}>
|
||||
<button
|
||||
class="ctx-help-btn"
|
||||
onClick={handleOpen}
|
||||
title="Contextual help"
|
||||
aria-label="Open contextual help"
|
||||
aria-expanded={open}
|
||||
>
|
||||
?
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div class="ctx-help-popover" role="tooltip">
|
||||
<div class="ctx-help-tip">
|
||||
<span class="ctx-help-icon">💡</span>
|
||||
<span>{tip.text}</span>
|
||||
</div>
|
||||
|
||||
<div class="ctx-help-footer">
|
||||
<button
|
||||
class="ctx-help-nav"
|
||||
onClick={() => setTipIndex(t => (t + 1) % tips.length)}
|
||||
title="Next tip"
|
||||
>
|
||||
Next tip →
|
||||
</button>
|
||||
<button
|
||||
class="ctx-help-link"
|
||||
onClick={() => { setOpen(false); onOpenManual(tip.section); }}
|
||||
>
|
||||
Open manual ↗
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { h, Fragment } from 'preact';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import { wsClient } from './lib/ws';
|
||||
import { controlDialogs, dismissControlDialog, type ControlDialog } from './lib/controldialogs';
|
||||
|
||||
// Renders dialogs pushed by server-side control-logic action.dialog nodes over
|
||||
// the WebSocket. Mounted once globally (App) since these are addressed to the
|
||||
// connected user, independent of which panel is open. info/error dialogs are
|
||||
// acknowledgements; input dialogs send a numeric response back to the server.
|
||||
export default function ControlDialogs() {
|
||||
const [reqs, setReqs] = useState<ControlDialog[]>([]);
|
||||
useEffect(() => controlDialogs.subscribe(setReqs), []);
|
||||
if (reqs.length === 0) return null;
|
||||
return <Fragment>{reqs.map(r => <ControlDialogModal key={r.id} req={r} />)}</Fragment>;
|
||||
}
|
||||
|
||||
function ControlDialogModal({ req }: { req: ControlDialog; key?: string }) {
|
||||
const isInput = req.kind === 'input';
|
||||
const [val, setVal] = useState('');
|
||||
|
||||
function ok() {
|
||||
if (isInput) {
|
||||
const n = parseFloat(val);
|
||||
wsClient.sendDialogResponse(req.id, Number.isFinite(n) ? n : null);
|
||||
}
|
||||
dismissControlDialog(req.id);
|
||||
}
|
||||
function cancel() {
|
||||
if (isInput) wsClient.sendDialogResponse(req.id, null);
|
||||
dismissControlDialog(req.id);
|
||||
}
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') { e.preventDefault(); ok(); }
|
||||
if (e.key === 'Escape') { e.preventDefault(); cancel(); }
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="logic-dialog-backdrop" onKeyDown={onKey}>
|
||||
<div class={`logic-dialog logic-dialog-${req.kind}`} role="dialog">
|
||||
<div class="logic-dialog-header">
|
||||
{req.title || (req.kind === 'error' ? 'Error' : isInput ? 'Input required' : 'Info')}
|
||||
</div>
|
||||
{req.message && <div class="logic-dialog-message">{req.message}</div>}
|
||||
{isInput && (
|
||||
<div class="logic-dialog-field">
|
||||
<input class="prop-input logic-dialog-input" type="number"
|
||||
autoFocus value={val}
|
||||
onInput={(e) => setVal((e.target as HTMLInputElement).value)}
|
||||
onKeyDown={onKey} />
|
||||
</div>
|
||||
)}
|
||||
<div class="logic-dialog-actions">
|
||||
{isInput && <button class="panel-btn" onClick={cancel}>Cancel</button>}
|
||||
<button class="panel-btn panel-btn-primary" onClick={ok}>OK</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+665
-74
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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 (
|
||||
|
||||
+129
-113
@@ -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>
|
||||
{!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} />
|
||||
)}
|
||||
{!historyLoading && versions.map(v => (
|
||||
<div
|
||||
key={v.version}
|
||||
class={`history-item${v.current ? ' history-item-current' : ''}`}
|
||||
>
|
||||
<div class="history-item-main">
|
||||
<span class="history-version">v{v.version}</span>
|
||||
{v.tag && <span class="history-tag">{v.tag}</span>}
|
||||
{v.current && <span class="history-current-badge">current</span>}
|
||||
</div>
|
||||
<div class="history-item-meta">
|
||||
{new Date(v.savedAt).toLocaleString()}
|
||||
</div>
|
||||
<div class="history-item-actions">
|
||||
{!v.current && (
|
||||
<button class="history-action-btn" onClick={() => loadVersion(v.version)} title="Load this version into the editor (does not change history)">
|
||||
Load
|
||||
</button>
|
||||
)}
|
||||
{!v.current && (
|
||||
<button class="history-action-btn" onClick={() => promoteVersion(v.version)} title="Make this version the current one">
|
||||
Set current
|
||||
</button>
|
||||
)}
|
||||
<button class="history-action-btn" onClick={() => forkVersion(v.version)} title="Create a new interface from this version">
|
||||
Fork
|
||||
</button>
|
||||
<button class="history-action-btn" onClick={() => editVersionTag(v.version, v.tag ?? '')} title="Edit this version's label">
|
||||
Label
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{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
@@ -403,6 +403,7 @@ function DiagramSignalTree() {
|
||||
|
||||
const SECTIONS = [
|
||||
{ id: 'start', label: 'Getting Started' },
|
||||
{ id: 'tips', label: 'Quick Tips' },
|
||||
{ id: 'view', label: 'View Mode' },
|
||||
{ id: 'edit', label: 'Edit Mode' },
|
||||
{ id: 'widgets', label: 'Widgets' },
|
||||
@@ -411,10 +412,68 @@ const SECTIONS = [
|
||||
{ id: 'logic', label: 'Panel Logic' },
|
||||
{ id: 'control', label: 'Control Logic' },
|
||||
{ id: 'sharing', label: 'Sharing & Access' },
|
||||
{ id: 'audit', label: 'Audit Log' },
|
||||
{ id: 'history', label: 'Historical Data' },
|
||||
{ id: 'shortcuts', label: 'Keyboard Shortcuts' },
|
||||
];
|
||||
|
||||
// Quick tips, grouped by the mode they apply to. Each links to the manual
|
||||
// section that covers it in depth. (Previously surfaced via a floating
|
||||
// contextual-help popover; now consolidated here in the manual.)
|
||||
const TIPS: Record<'view' | 'edit', Array<{ text: string; section: string }>> = {
|
||||
view: [
|
||||
{ text: 'Click any interface in the left panel to load it on the canvas.', section: 'view' },
|
||||
{ text: 'Right-click a widget to view signal info or export data as CSV.', section: 'view' },
|
||||
{ text: 'The ● Live button shows live streaming is active. Use the date pickers to load historical data.', section: 'history' },
|
||||
{ text: 'Click Edit (top-right) to open the drag-and-drop panel builder.', section: 'edit' },
|
||||
{ text: 'Use the Share button on a panel to grant access to users or groups.', section: 'sharing' },
|
||||
{ text: 'The ⚙ Control logic button opens server-side automation that runs even with no panel open.', section: 'control' },
|
||||
{ text: 'The 🛡 Audit button (for permitted users) opens the log of system-affecting actions.', section: 'audit' },
|
||||
{ text: 'The status chip turns green and shows your username when the WebSocket is connected.', section: 'view' },
|
||||
],
|
||||
edit: [
|
||||
{ text: 'Drag a signal from the left tree onto the canvas to create a widget.', section: 'edit' },
|
||||
{ text: 'Click a widget to select it, then adjust its properties on the right.', section: 'edit' },
|
||||
{ text: 'Ctrl+click to select multiple widgets; then use the align toolbar.', section: 'edit' },
|
||||
{ text: 'Ctrl+Z to undo, Ctrl+Y to redo. Up to 50 steps are remembered.', section: 'shortcuts' },
|
||||
{ text: 'Use "+ Synthetic" in the signal tree to create computed/filtered signals.', section: 'signals' },
|
||||
{ text: 'Switch to the Logic tab to add interactive behaviour with a node-graph flow editor.', section: 'logic' },
|
||||
{ text: 'Add Local variables for set-points and counters that live inside the panel.', section: 'edit' },
|
||||
{ text: 'Arrow keys nudge the selected widget by 1 px (Shift for grid size).', section: 'shortcuts' },
|
||||
],
|
||||
};
|
||||
|
||||
function SectionTips({ onNavigate }: { onNavigate: (section: string) => void }) {
|
||||
const groups: Array<['view' | 'edit', string]> = [
|
||||
['view', 'In View mode'],
|
||||
['edit', 'In Edit mode'],
|
||||
];
|
||||
return (
|
||||
<div>
|
||||
<p class="help-lead">
|
||||
A handful of quick pointers to get the most out of uopi. Click any tip to jump to the
|
||||
section that explains it in detail.
|
||||
</p>
|
||||
{groups.map(([mode, title]) => (
|
||||
<div key={mode}>
|
||||
<h3 class="help-h3">{title}</h3>
|
||||
<ul class="help-tips-list">
|
||||
{TIPS[mode].map(tip => (
|
||||
<li key={tip.text}>
|
||||
<button class="help-tip-item" onClick={() => onNavigate(tip.section)}>
|
||||
<span class="help-tip-icon">💡</span>
|
||||
<span class="help-tip-text">{tip.text}</span>
|
||||
<span class="help-tip-go">↗</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionStart() {
|
||||
return (
|
||||
<div>
|
||||
@@ -481,8 +540,10 @@ function SectionView() {
|
||||
|
||||
<h3 class="help-h3">Connection status</h3>
|
||||
<p>
|
||||
The coloured chip in the top-right shows the WebSocket connection state.
|
||||
A red <em>"disconnected"</em> banner appears at the top while reconnecting — live values will resume automatically.
|
||||
The coloured chip in the top-right shows the WebSocket connection state. When connected it
|
||||
reads <em>"connected as <your username>"</em>, so you can confirm the identity your
|
||||
actions are attributed to in the audit log. A red <em>"disconnected"</em> banner appears at
|
||||
the top while reconnecting — live values will resume automatically.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -677,6 +738,44 @@ function SectionSharing() {
|
||||
);
|
||||
}
|
||||
|
||||
function SectionAudit() {
|
||||
return (
|
||||
<div>
|
||||
<p class="help-lead">
|
||||
When the server is configured with <code>[audit] enabled = true</code>, every
|
||||
<strong> system-affecting action</strong> is appended to an append-only log so it can be
|
||||
reviewed later. Permitted users open it with the <strong>🛡 Audit</strong> button in the
|
||||
view-mode toolbar.
|
||||
</p>
|
||||
|
||||
<h3 class="help-h3">What gets recorded</h3>
|
||||
<ul class="help-list">
|
||||
<li><strong>Signal writes</strong> — both user-initiated (set-value widgets, buttons, panel
|
||||
logic) and automated writes from the control-logic engine.</li>
|
||||
<li><strong>Interface changes</strong> — panels created, updated, or deleted.</li>
|
||||
<li><strong>Control-logic changes</strong> — graphs created, updated, or deleted.</li>
|
||||
</ul>
|
||||
<p>Each entry records the time, the actor (username, or the graph name for automated
|
||||
actions), the action, the affected data source / signal and value, the client address, and
|
||||
whether it succeeded.</p>
|
||||
|
||||
<h3 class="help-h3">Viewing the log</h3>
|
||||
<ul class="help-list">
|
||||
<li>The viewer lists the most recent events first.</li>
|
||||
<li>Filter by user, action, signal, or a start/end time range.</li>
|
||||
<li>System (automated) actions are tagged distinctly from user actions.</li>
|
||||
</ul>
|
||||
|
||||
<p class="help-callout">
|
||||
Access to the audit log is gated by an allowlist (<code>audit.readers</code> in the config),
|
||||
following the same model as logic editing: an empty list means every user may view it,
|
||||
otherwise only the listed users or groups can. The 🛡 Audit button is hidden for users who
|
||||
are not permitted.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionWidgets() {
|
||||
return (
|
||||
<div>
|
||||
@@ -858,8 +957,9 @@ function SectionShortcuts() {
|
||||
);
|
||||
}
|
||||
|
||||
const SECTION_COMPONENTS: Record<string, () => JSX.Element> = {
|
||||
const SECTION_COMPONENTS: Record<string, (props: { onNavigate: (section: string) => void }) => JSX.Element> = {
|
||||
start: SectionStart,
|
||||
tips: SectionTips,
|
||||
view: SectionView,
|
||||
edit: SectionEdit,
|
||||
widgets: SectionWidgets,
|
||||
@@ -868,6 +968,7 @@ const SECTION_COMPONENTS: Record<string, () => JSX.Element> = {
|
||||
logic: SectionLogic,
|
||||
control: SectionControl,
|
||||
sharing: SectionSharing,
|
||||
audit: SectionAudit,
|
||||
history: SectionHistory,
|
||||
shortcuts: SectionShortcuts,
|
||||
};
|
||||
@@ -877,7 +978,8 @@ const SECTION_COMPONENTS: Record<string, () => JSX.Element> = {
|
||||
export default function HelpModal({ initialSection = 'start', onClose }: Props) {
|
||||
const [active, setActive] = useState(initialSection);
|
||||
|
||||
const Content = SECTION_COMPONENTS[active] ?? SectionStart;
|
||||
const Content: (props: { onNavigate: (section: string) => void }) => JSX.Element =
|
||||
SECTION_COMPONENTS[active] ?? SectionStart;
|
||||
|
||||
// Close on backdrop click
|
||||
function handleBackdrop(e: MouseEvent) {
|
||||
@@ -915,7 +1017,7 @@ export default function HelpModal({ initialSection = 'start', onClose }: Props)
|
||||
{/* Content area */}
|
||||
<div class="help-content">
|
||||
<h2 class="help-h2">{SECTIONS.find(s => s.id === active)?.label}</h2>
|
||||
<Content />
|
||||
<Content onNavigate={setActive} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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>}
|
||||
|
||||
+452
-77
@@ -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 (
|
||||
{selected.kind === 'action.write' && (
|
||||
<Fragment>
|
||||
<div class="wizard-field">
|
||||
<label>Target data source</label>
|
||||
<SearchableSelect value={ds} options={dsOptions}
|
||||
onSelect={(newDs) => { loadSignals(newDs); patchParams(selected.id, { target: `${newDs}:` }); }} />
|
||||
</div>
|
||||
<div class="wizard-field">
|
||||
<label>Target signal</label>
|
||||
<SearchableSelect value={name} options={signalOptions(ds)}
|
||||
onSelect={(sig) => patchParams(selected.id, { target: `${ds}:${sig}` })}
|
||||
placeholder={ds ? 'Search signals…' : 'Select data source first'} />
|
||||
<label>Target signal / variable</label>
|
||||
<SignalPicker value={selected.params.target ?? ''} options={allSignalOptions()}
|
||||
onOpen={openAllSignals} allowFreeform
|
||||
onChange={(ref) => patchParams(selected.id, { target: ref })} />
|
||||
<p class="hint">Pick a signal, or type <code>local:name</code> to write a panel-local variable.</p>
|
||||
</div>
|
||||
<ExprField label="Value (expression)" value={selected.params.expr ?? ''}
|
||||
onChange={(v) => patchParams(selected.id, { expr: v })}
|
||||
onInsert={(ds2, sig) => insertRef(selected.id, 'expr', ds2, sig)}
|
||||
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals}
|
||||
allSignals={allSignalOptions()} onOpenSignals={openAllSignals}
|
||||
hint="e.g. ({stub:a} - {stub:b}) / {sys:dt} for a rate — or a constant like 42. {sys:time}=epoch seconds, {sys:dt}=seconds since the trigger last fired." />
|
||||
</Fragment>
|
||||
);
|
||||
})()}
|
||||
)}
|
||||
|
||||
{selected.kind === 'action.delay' && (
|
||||
<div class="wizard-field">
|
||||
@@ -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
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useMemo } from 'preact/hooks';
|
||||
|
||||
// A single fuzzy-finder input for picking a signal as one "ds:name" reference,
|
||||
// replacing the old stacked "Data source" + "Signal" select pair. The caller
|
||||
// supplies a flattened option list (every ds:name it knows about) and, via
|
||||
// onOpen, a hook to lazily load signals for all data sources when the dropdown
|
||||
// is first opened. When allowFreeform is set, typing a value that matches no
|
||||
// option (e.g. a new srv:/local: variable) can still be committed.
|
||||
|
||||
export interface SignalOption { ds: string; name: string; }
|
||||
|
||||
export default function SignalPicker({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
onOpen,
|
||||
placeholder = 'Search signals…',
|
||||
allowFreeform = false,
|
||||
}: {
|
||||
value: string; // current "ds:name" (or "")
|
||||
options: SignalOption[];
|
||||
onChange: (ref: string) => void;
|
||||
onOpen?: () => void;
|
||||
placeholder?: string;
|
||||
allowFreeform?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [filter, setFilter] = useState('');
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const tokens = filter.toLowerCase().split(/\s+/).filter(Boolean);
|
||||
const scored = options
|
||||
.map(o => ({ o, label: `${o.ds}:${o.name}` }))
|
||||
.filter(({ label }) => {
|
||||
const l = label.toLowerCase();
|
||||
return tokens.every(t => l.includes(t));
|
||||
});
|
||||
// Prefer matches where the signal name (not the ds prefix) leads.
|
||||
const f = filter.toLowerCase();
|
||||
scored.sort((a, b) => {
|
||||
const an = a.o.name.toLowerCase().startsWith(f) ? 0 : 1;
|
||||
const bn = b.o.name.toLowerCase().startsWith(f) ? 0 : 1;
|
||||
if (an !== bn) return an - bn;
|
||||
return a.label.localeCompare(b.label);
|
||||
});
|
||||
return scored.slice(0, 200);
|
||||
}, [options, filter]);
|
||||
|
||||
function openDropdown() {
|
||||
setOpen(true);
|
||||
setFilter('');
|
||||
onOpen?.();
|
||||
}
|
||||
|
||||
function commit(ref: string) {
|
||||
onChange(ref);
|
||||
setOpen(false);
|
||||
setFilter('');
|
||||
}
|
||||
|
||||
// A freeform entry is offered when the typed text looks like a usable
|
||||
// reference and is not already an exact option.
|
||||
const freeform = allowFreeform && filter.trim() !== ''
|
||||
&& !filtered.some(({ label }) => label === filter.trim());
|
||||
|
||||
return (
|
||||
<div class="search-select">
|
||||
<div class="search-select-trigger" onClick={() => (open ? setOpen(false) : openDropdown())}>
|
||||
{value || <span class="search-select-placeholder">{placeholder}</span>}
|
||||
<span class="search-select-arrow">{open ? '▴' : '▾'}</span>
|
||||
</div>
|
||||
{open && (
|
||||
<div class="search-select-dropdown">
|
||||
<input
|
||||
class="prop-input"
|
||||
autoFocus
|
||||
placeholder="Type to search… (ds:name)"
|
||||
value={filter}
|
||||
onInput={(e) => setFilter((e.target as HTMLInputElement).value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (filtered.length > 0) commit(`${filtered[0].o.ds}:${filtered[0].o.name}`);
|
||||
else if (freeform) commit(filter.trim());
|
||||
}
|
||||
if (e.key === 'Escape') { e.preventDefault(); setOpen(false); }
|
||||
}}
|
||||
/>
|
||||
<div class="search-select-list">
|
||||
{freeform && (
|
||||
<div class="search-select-item" onClick={() => commit(filter.trim())}>
|
||||
Use “{filter.trim()}”
|
||||
</div>
|
||||
)}
|
||||
{filtered.length === 0 && !freeform && <div class="search-select-item hint">No matches</div>}
|
||||
{filtered.map(({ o, label }) => (
|
||||
<div
|
||||
key={label}
|
||||
class={`search-select-item${label === value ? ' search-select-item-selected' : ''}`}
|
||||
onClick={() => commit(`${o.ds}:${o.name}`)}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{open && <div class="search-select-backdrop" onClick={() => setOpen(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+17
-22
@@ -1,10 +1,10 @@
|
||||
import { h, Fragment } from 'preact';
|
||||
import { useState, useEffect, useRef, useMemo } from 'preact/hooks';
|
||||
import type { SignalRef, StateVar } from './lib/types';
|
||||
import SyntheticWizard from './SyntheticWizard';
|
||||
import SyntheticGraphEditor from './SyntheticGraphEditor';
|
||||
import GroupsTree from './GroupsTree';
|
||||
import ChannelFinderModal from './ChannelFinderModal';
|
||||
import SignalPicker from './SignalPicker';
|
||||
|
||||
type TreeTab = 'sources' | 'groups';
|
||||
type GroupBy = 'datasource' | 'area' | 'system' | 'ioc';
|
||||
@@ -491,10 +491,11 @@ export default function SignalTree({ onDragStart, width, panelId, statevars, onS
|
||||
)}
|
||||
|
||||
{showWizard && (
|
||||
<SyntheticWizard
|
||||
currentIfaceId={panelId}
|
||||
<SyntheticGraphEditor
|
||||
create
|
||||
panelId={panelId}
|
||||
onClose={() => setShowWizard(false)}
|
||||
onCreated={loadAllSignals}
|
||||
onSaved={loadAllSignals}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -523,24 +524,18 @@ export default function SignalTree({ onDragStart, width, panelId, statevars, onS
|
||||
</div>
|
||||
<div class="wizard-body">
|
||||
<div class="wizard-field">
|
||||
<label>Data Source</label>
|
||||
<select class="prop-select" value={manualDs} onChange={e => setManualDs((e.target as HTMLSelectElement).value)}>
|
||||
{Array.from(new Set(allSignals.map(s => s.ds))).map(ds => (
|
||||
<option key={ds} value={ds}>{ds}</option>
|
||||
))}
|
||||
{!allSignals.some(s => s.ds === 'epics') && <option value="epics">epics</option>}
|
||||
</select>
|
||||
</div>
|
||||
<div class="wizard-field">
|
||||
<label>Signal / PV Name</label>
|
||||
<input
|
||||
class="prop-input"
|
||||
autoFocus
|
||||
placeholder="e.g. MY:PV:NAME"
|
||||
value={manualName}
|
||||
onInput={e => setManualName((e.target as HTMLInputElement).value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleManualAdd()}
|
||||
/>
|
||||
<label>Signal</label>
|
||||
<SignalPicker
|
||||
value={manualName ? `${manualDs}:${manualName}` : ''}
|
||||
options={allSignals}
|
||||
allowFreeform
|
||||
placeholder="Search or type ds:NAME…"
|
||||
onChange={(ref) => {
|
||||
const i = ref.indexOf(':');
|
||||
if (i < 0) { setManualDs(ref); setManualName(''); }
|
||||
else { setManualDs(ref.slice(0, i)); setManualName(ref.slice(i + 1)); }
|
||||
}} />
|
||||
<p class="hint">Pick an existing signal, or type <code>epics:MY:PV:NAME</code> to add a new one.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wizard-footer">
|
||||
|
||||
+901
-155
File diff suppressed because it is too large
Load Diff
@@ -1,279 +0,0 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import LuaEditor from './LuaEditor';
|
||||
import SearchableSelect from './SearchableSelect';
|
||||
import type { InputRef, SignalDef } from './lib/types';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
onCreated: () => void;
|
||||
// Interface id the wizard was opened from — used to bind panel-scoped signals.
|
||||
currentIfaceId?: string;
|
||||
}
|
||||
|
||||
interface NodeParam {
|
||||
label: string;
|
||||
key: string;
|
||||
type: 'number' | 'text' | 'lua';
|
||||
default: string;
|
||||
}
|
||||
|
||||
const NODE_TYPES: Array<{ type: string; label: string; params: NodeParam[] }> = [
|
||||
{ type: 'gain', label: 'Gain', params: [{ label: 'Factor', key: 'gain', type: 'number', default: '1' }] },
|
||||
{ type: 'offset', label: 'Offset', params: [{ label: 'Offset', key: 'offset', type: 'number', default: '0' }] },
|
||||
{ type: 'moving_average', label: 'Moving Average', params: [{ label: 'Window (n)', key: 'window', type: 'number', default: '10' }] },
|
||||
{ type: 'rms', label: 'RMS', params: [{ label: 'Window (n)', key: 'window', type: 'number', default: '10' }] },
|
||||
{ type: 'lowpass', label: 'Low-pass Filter', params: [
|
||||
{ label: 'Cutoff freq (Hz)', key: 'freq', type: 'number', default: '1' },
|
||||
{ label: 'Order (1–8)', key: 'order', type: 'number', default: '1' },
|
||||
]},
|
||||
{ type: 'derivative', label: 'Derivative', params: [] },
|
||||
{ type: 'integrate', label: 'Integrate', params: [] },
|
||||
{ type: 'clamp', label: 'Clamp', params: [{ label: 'Min', key: 'min', type: 'number', default: '0' }, { label: 'Max', key: 'max', type: 'number', default: '100' }] },
|
||||
{ type: 'expr', label: 'Formula', params: [{ label: 'Expression (a,b,c,d = inputs)', key: 'expr', type: 'text', default: 'a * 1.0' }] },
|
||||
{ type: 'lua', label: 'Lua Script', params: [{ label: 'Script (a,b,c,d = inputs)', key: 'script', type: 'lua', default: 'return a' }] },
|
||||
];
|
||||
|
||||
interface DataSource {
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface SignalInfo {
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// ── Main Wizard ──────────────────────────────────────────────────────────────
|
||||
|
||||
export default function SyntheticWizard({ onClose, onCreated, currentIfaceId }: Props) {
|
||||
const [name, setName] = useState('');
|
||||
const [inputs, setInputs] = useState<InputRef[]>([{ ds: 'stub', signal: 'sine_1hz' }]);
|
||||
const [nodeType, setNodeType] = useState('gain');
|
||||
const [params, setParams] = useState<Record<string, string>>({});
|
||||
// Visibility scope. Panel scope requires an interface to bind to, so when the
|
||||
// wizard is opened outside a saved panel it falls back to 'user'.
|
||||
const [visibility, setVisibility] = useState<'panel' | 'user' | 'global'>(currentIfaceId ? 'panel' : 'user');
|
||||
const [unit, setUnit] = useState('');
|
||||
const [desc, setDesc] = useState('');
|
||||
const [dispLow, setDispLow] = useState('0');
|
||||
const [dispHigh, setDispHigh] = useState('100');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// Loaded data
|
||||
const [dataSources, setDataSources] = useState<string[]>([]);
|
||||
const [dsSignals, setDsSignals] = useState<Record<string, string[]>>({});
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/v1/datasources')
|
||||
.then(r => r.ok ? r.json() : [])
|
||||
.then((ds: DataSource[]) => setDataSources(ds.map(d => d.name)))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
async function loadSignals(ds: string) {
|
||||
if (dsSignals[ds]) return;
|
||||
try {
|
||||
const res = await fetch(`/api/v1/signals?ds=${encodeURIComponent(ds)}`);
|
||||
if (!res.ok) return;
|
||||
const sigs: SignalInfo[] = await res.json();
|
||||
setDsSignals(prev => ({ ...prev, [ds]: sigs.map(s => s.name) }));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const nodeDef = NODE_TYPES.find(n => n.type === nodeType)!;
|
||||
|
||||
function getParam(key: string, def: string): string {
|
||||
return params[key] ?? def;
|
||||
}
|
||||
|
||||
function setParam(key: string, val: string) {
|
||||
setParams(p => ({ ...p, [key]: val }));
|
||||
}
|
||||
|
||||
function updateInput(idx: number, patch: Partial<InputRef>) {
|
||||
setInputs(prev => prev.map((inp, i) => {
|
||||
if (i !== idx) return inp;
|
||||
const next = { ...inp, ...patch };
|
||||
if (patch.ds) {
|
||||
next.signal = '';
|
||||
loadSignals(patch.ds);
|
||||
}
|
||||
return next;
|
||||
}));
|
||||
}
|
||||
|
||||
function addInput() {
|
||||
setInputs(prev => [...prev, { ds: dataSources[0] || '', signal: '' }]);
|
||||
}
|
||||
|
||||
function removeInput(idx: number) {
|
||||
setInputs(prev => prev.filter((_, i) => i !== idx));
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (!name.trim()) { setError('Name is required'); return; }
|
||||
if (inputs.some(i => !i.ds || !i.signal)) { setError('All inputs must have a data source and signal'); return; }
|
||||
|
||||
const nodeParams: Record<string, any> = {};
|
||||
for (const p of nodeDef.params) {
|
||||
const raw = getParam(p.key, p.default);
|
||||
nodeParams[p.key] = p.type === 'number' ? parseFloat(raw) : raw;
|
||||
}
|
||||
|
||||
const def: SignalDef = {
|
||||
name: name.trim(),
|
||||
inputs,
|
||||
pipeline: [{ type: nodeType, params: nodeParams }],
|
||||
meta: {
|
||||
unit: unit || undefined,
|
||||
description: desc || undefined,
|
||||
displayLow: parseFloat(dispLow) || 0,
|
||||
displayHigh: parseFloat(dispHigh) || 100,
|
||||
},
|
||||
visibility,
|
||||
...(visibility === 'panel' && currentIfaceId ? { panel: currentIfaceId } : {}),
|
||||
};
|
||||
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await fetch('/api/v1/synthetic', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(def),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const j = await res.json().catch(() => ({ error: res.statusText }));
|
||||
throw new Error(j.error ?? res.statusText);
|
||||
}
|
||||
onCreated();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="wizard-backdrop" onClick={onClose}>
|
||||
<div class="wizard" onClick={(e) => e.stopPropagation()}>
|
||||
<div class="wizard-header">
|
||||
<span>New Synthetic Signal</span>
|
||||
<button class="icon-btn" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
<div class="wizard-body">
|
||||
{error && <p class="wizard-error">{error}</p>}
|
||||
|
||||
<div class="wizard-section-title">Signal identity</div>
|
||||
<div class="wizard-field">
|
||||
<label>Name</label>
|
||||
<input class="prop-input" value={name}
|
||||
placeholder="e.g. smoothed_pressure"
|
||||
onInput={(e) => setName((e.target as HTMLInputElement).value)} />
|
||||
</div>
|
||||
|
||||
<div class="wizard-field">
|
||||
<label>Visibility</label>
|
||||
<select class="prop-select" value={visibility}
|
||||
onChange={(e) => setVisibility((e.target as HTMLSelectElement).value as any)}>
|
||||
<option value="panel" disabled={!currentIfaceId}>
|
||||
This panel only{currentIfaceId ? '' : ' (save panel first)'}
|
||||
</option>
|
||||
<option value="user">All my panels</option>
|
||||
<option value="global">All panels (global)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="wizard-section-title">Input signals</div>
|
||||
{inputs.map((inp, idx) => (
|
||||
<div key={idx} class="wizard-field wizard-field-row" style="align-items: flex-end; gap: 0.5rem; margin-bottom: 0.5rem;">
|
||||
<div class="wizard-field" style="flex:1;">
|
||||
<label>Data source</label>
|
||||
<SearchableSelect
|
||||
value={inp.ds}
|
||||
options={dataSources}
|
||||
onSelect={(ds) => updateInput(idx, { ds })}
|
||||
/>
|
||||
</div>
|
||||
<div class="wizard-field" style="flex:2;">
|
||||
<label>Signal</label>
|
||||
<SearchableSelect
|
||||
value={inp.signal}
|
||||
options={dsSignals[inp.ds] || []}
|
||||
onSelect={(signal) => updateInput(idx, { signal })}
|
||||
placeholder={inp.ds ? 'Search signals…' : 'Select data source first'}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
class="icon-btn"
|
||||
style="margin-bottom: 0.25rem;"
|
||||
title="Remove input"
|
||||
onClick={() => removeInput(idx)}
|
||||
disabled={inputs.length === 1}
|
||||
>✕</button>
|
||||
</div>
|
||||
))}
|
||||
<button class="panel-btn" style="margin-top: 0.5rem;" onClick={addInput}>+ Add input</button>
|
||||
|
||||
<div class="wizard-section-title" style="margin-top: 1.5rem;">Processing</div>
|
||||
<div class="wizard-field">
|
||||
<label>Node type</label>
|
||||
<select class="prop-select" value={nodeType}
|
||||
onChange={(e) => { setNodeType((e.target as HTMLSelectElement).value); setParams({}); }}>
|
||||
{NODE_TYPES.map(n => <option key={n.type} value={n.type}>{n.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
{nodeDef.params.map(p => (
|
||||
<div key={p.key} class="wizard-field">
|
||||
<label>{p.label}</label>
|
||||
{p.type === 'lua' ? (
|
||||
<LuaEditor
|
||||
value={getParam(p.key, p.default)}
|
||||
onChange={(v) => setParam(p.key, v)}
|
||||
/>
|
||||
) : (
|
||||
<input class="prop-input" type={p.type === 'number' ? 'number' : 'text'}
|
||||
value={getParam(p.key, p.default)}
|
||||
onInput={(e) => setParam(p.key, (e.target as HTMLInputElement).value)} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div class="wizard-section-title">Metadata (optional)</div>
|
||||
<div class="wizard-field wizard-field-row">
|
||||
<div class="wizard-field">
|
||||
<label>Unit</label>
|
||||
<input class="prop-input" value={unit} placeholder="m/s"
|
||||
onInput={(e) => setUnit((e.target as HTMLInputElement).value)} />
|
||||
</div>
|
||||
<div class="wizard-field">
|
||||
<label>Display low</label>
|
||||
<input class="prop-input" type="number" value={dispLow}
|
||||
onInput={(e) => setDispLow((e.target as HTMLInputElement).value)} />
|
||||
</div>
|
||||
<div class="wizard-field">
|
||||
<label>Display high</label>
|
||||
<input class="prop-input" type="number" value={dispHigh}
|
||||
onInput={(e) => setDispHigh((e.target as HTMLInputElement).value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="wizard-field">
|
||||
<label>Description</label>
|
||||
<input class="prop-input" value={desc} placeholder="Optional description"
|
||||
onInput={(e) => setDesc((e.target as HTMLInputElement).value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wizard-footer">
|
||||
<button class="toolbar-btn" onClick={onClose}>Cancel</button>
|
||||
<button class="toolbar-btn toolbar-btn-primary" onClick={handleCreate} disabled={saving}>
|
||||
{saving ? 'Creating…' : 'Create Signal'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+70
-16
@@ -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 && (
|
||||
{hasTools && (
|
||||
<div class="toolbar-dropdown" onMouseDown={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
class="toolbar-btn"
|
||||
onClick={() => setShowControlLogic(true)}
|
||||
title="Open server-side control logic"
|
||||
class={`toolbar-btn${toolsOpen ? ' toolbar-btn-active' : ''}`}
|
||||
title="Advanced tools"
|
||||
onClick={() => setToolsOpen(o => !o)}
|
||||
>
|
||||
⚙ Control logic
|
||||
⋯ 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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'];
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { writable } from './store';
|
||||
|
||||
// A dialog request pushed by a server-side control-logic action.dialog node and
|
||||
// delivered over the WebSocket. Unlike panel logic dialogs (lib/logic.ts) these
|
||||
// originate on the server and are addressed to the connected user by identity.
|
||||
export interface ControlDialog {
|
||||
id: string;
|
||||
kind: 'info' | 'error' | 'input';
|
||||
title?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
// Active control-logic dialogs awaiting the user's acknowledgement / input.
|
||||
export const controlDialogs = writable<ControlDialog[]>([]);
|
||||
|
||||
// pushControlDialog is invoked by the WS client on a "dialog" message.
|
||||
export function pushControlDialog(d: ControlDialog): void {
|
||||
controlDialogs.update(list => (list.some(x => x.id === d.id) ? list : [...list, d]));
|
||||
}
|
||||
|
||||
// dismissControlDialog removes a dialog once answered or cancelled.
|
||||
export function dismissControlDialog(id: string): void {
|
||||
controlDialogs.update(list => list.filter(d => d.id !== id));
|
||||
}
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user