Compare commits
2 Commits
113e5a0fe8
...
ac24011487
| Author | SHA1 | Date | |
|---|---|---|---|
| ac24011487 | |||
| 73fcbe7b28 |
@@ -40,33 +40,63 @@
|
|||||||
- [x] when validation fails the error is propagated to the user (live `/config/rules/check` panel while editing; save returns the structured violation)
|
- [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`)
|
- [x] all actions tracked via history (versioned rules) + audit (`config.rule.create/update/delete/promote/fork`)
|
||||||
- [ ] Improve UX:
|
- [ ] 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:
|
- [ ] Synthetic editor:
|
||||||
- [ ] color code the node link by type
|
- [x] color code the node link by type
|
||||||
- [ ] edges color depends on type (e.g. float) including arrays
|
- [x] edges color depends on type (e.g. float) including arrays (scalar = slate, array = purple)
|
||||||
- [ ] input / outputs are color coded (if can accept many, gray, otherwise specific color, can be dynamic)
|
- [x] input / outputs are color coded (out port = node's type; in port = accepted type, gray when it accepts either)
|
||||||
- [ ] can not connect input / output that are not compatible
|
- [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] hover on a block in error should show the reason
|
||||||
- [x] add proper array functionality
|
- [x] add proper array functionality
|
||||||
- [x] add shortcut to add Signals (S) and nodes (N) with HUD
|
- [x] add shortcut to add Signals (S) and nodes (N) with HUD
|
||||||
- [ ] Panels:
|
- [ ] Panels:
|
||||||
- [x] in view mode the widgets should have no border/bg but blend with background
|
- [x] in view mode the widgets should have no border/bg but blend with background
|
||||||
- [ ] add widgets such as toggle switch, table and other industrial hmi widgets
|
- [ ] add widgets such as toggle switch, table and other industrial hmi widgets
|
||||||
- [ ] add container widgets: labelled/title pane, tab panes, collapsable panes etc
|
- [x] toggle switch (`web/src/widgets/Toggle.tsx`; read+write bool control, configurable on/off value+label, confirm dialog)
|
||||||
- [ ] plot pane:
|
- [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)
|
||||||
- [ ] add toolbar
|
- [ ] other industrial hmi widgets
|
||||||
- [ ] add time window selector to toolbar
|
- [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)
|
||||||
- [ ] plot x-axis synchronised (option to unlink in toolbar)
|
- [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`)
|
||||||
- [ ] cursors and measuraments
|
- [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)
|
||||||
- [ ] pause/resume
|
- [x] plot pane:
|
||||||
- [ ] save screenshot
|
- [x] add toolbar (hover toolbar in `PlotWidget`, `.plot-toolbar`)
|
||||||
- [ ] clean ui:
|
- [x] add time window selector to toolbar (Auto/15s/30s/1m/5m/15m/1h rolling-window override, live windowed plot types)
|
||||||
- create small statusbar where connection widget and other status related info will be placed
|
- [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`)
|
||||||
- group advanced items (audit/control loops/config manager etc) in a tool menu or something similar to not fill the toolbar
|
- [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 Δ)
|
||||||
- make the toolbar as clean as possible
|
- [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:
|
- [ ] Logic editor:
|
||||||
- add full support to local array values: dynamic, dynamic but capped max, fixed size etc:
|
- [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
|
- array functions should work with new local array
|
||||||
- [ ] Control loop:
|
- [ ] 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
|
- add full support to server side array values
|
||||||
- [x] Implement git style versioning for: synthetic variable, panels, control logic:
|
- [x] Implement git style versioning for: synthetic variable, panels, control logic:
|
||||||
- [x] possibility to fork any version
|
- [x] possibility to fork any version
|
||||||
@@ -78,10 +108,31 @@
|
|||||||
- [x] active (the one currently view/edited) version has circle bigger then rest
|
- [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] 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] unsaved / new version appear with connection line dashed
|
||||||
- [ ] Implement admin pane: create / manage groups, set users permits, manage auditors etc
|
- [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:
|
- [ ] Implement new datasources:
|
||||||
- [ ] Finalize alarm service
|
- [ ] Finalize alarm service
|
||||||
- [ ] modbus tcp
|
- [ ] modbus tcp
|
||||||
- [ ] scpi tcp
|
- [ ] scpi tcp / VXI-11 protocol
|
||||||
- [ ] udp? other?
|
- [ ] 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)
|
- [ ] **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
|
||||||
|
|||||||
+30
-11
@@ -152,18 +152,32 @@ func main() {
|
|||||||
}
|
}
|
||||||
brk.Register(srvVars)
|
brk.Register(srvVars)
|
||||||
|
|
||||||
// Build the global access policy from config: every user is trusted with
|
// Build the role-based access policy from config: each [[groups]] entry lists
|
||||||
// full write access unless downgraded by the blacklist; groups are named
|
// members by role (viewer/operator/logiceditor/auditor/admin) and an optional
|
||||||
// sets of users referenced by per-panel sharing.
|
// parent for nesting. Every user is an implicit viewer of the built-in public
|
||||||
blacklist := make(map[string]string, len(cfg.Server.Blacklist))
|
// group; a config with no roles at all is fully open (everyone admin).
|
||||||
for _, e := range cfg.Server.Blacklist {
|
specs := make([]access.GroupSpec, 0, len(cfg.Groups))
|
||||||
blacklist[e.User] = e.Level
|
|
||||||
}
|
|
||||||
groups := make(map[string][]string, len(cfg.Groups))
|
|
||||||
for _, g := range 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)
|
||||||
}
|
}
|
||||||
policy := access.New(cfg.Server.DefaultUser, blacklist, groups, cfg.Server.LogicEditors, cfg.Audit.Readers)
|
|
||||||
|
|
||||||
// Audit log: when enabled, every system-affecting action (user/automated
|
// Audit log: when enabled, every system-affecting action (user/automated
|
||||||
// signal writes, interface and control-logic mutations) is recorded to SQLite.
|
// signal writes, interface and control-logic mutations) is recorded to SQLite.
|
||||||
@@ -199,9 +213,14 @@ func main() {
|
|||||||
// to server variables. Installed before Reload so running graphs can emit.
|
// to server variables. Installed before Reload so running graphs can emit.
|
||||||
dialogs := server.NewDialogHub(brk, policy, recorder, log)
|
dialogs := server.NewDialogHub(brk, policy, recorder, log)
|
||||||
ctrlEngine.SetNotifier(dialogs)
|
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()
|
ctrlEngine.Reload()
|
||||||
|
|
||||||
srv := server.New(cfg.Server.Listen, webFS, brk, synthDS, store, cfgStore, policy, aclStore, ctrlStore, ctrlEngine, dialogs, recorder, cfg.Datasource.EPICS.ChannelFinderURL, cfg.Datasource.EPICS.ArchiveURL, cfg.Server.TrustedUserHeader, log)
|
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 {
|
if err := srv.Start(ctx); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "server error: %v\n", err)
|
fmt.Fprintf(os.Stderr, "server error: %v\n", err)
|
||||||
|
|||||||
@@ -236,6 +236,9 @@ A signal defined by composing one or more input signals through a chain of proce
|
|||||||
processing node, set parameters, Create.
|
processing node, set parameters, Create.
|
||||||
- **Node-graph editor** — a visual editor that wires one or more inputs through a chain of
|
- **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.
|
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
|
**Visibility scope:** each synthetic signal is scoped as *panel* (visible only to the panel
|
||||||
that created it), *user*, or *global* (shared with everyone).
|
that created it), *user*, or *global* (shared with everyone).
|
||||||
@@ -303,6 +306,9 @@ managed through the REST API.
|
|||||||
config* makes a new instance for a set, optionally seeded from another instance. Both
|
config* makes a new instance for a set, optionally seeded from another instance. Both
|
||||||
mutating nodes are audited.
|
mutating nodes are audited.
|
||||||
- Each graph can be enabled/disabled independently; saving reloads the engine live.
|
- Each graph can be enabled/disabled independently; saving reloads the engine live.
|
||||||
|
- 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).
|
- Editing is gated by the logic-edit restriction (§2).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+89
-22
@@ -164,6 +164,22 @@ Framing: JSON messages over a single persistent WebSocket connection per client.
|
|||||||
|
|
||||||
// Request historical data
|
// Request historical data
|
||||||
{ "type": "history", "signal": "EPICS:PV1", "start": "2026-01-01T00:00:00Z", "end": "2026-01-02T00:00:00Z", "maxPoints": 5000 }
|
{ "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:**
|
**Server → Client messages:**
|
||||||
@@ -178,6 +194,11 @@ Framing: JSON messages over a single persistent WebSocket connection per client.
|
|||||||
// Historical data response
|
// Historical data response
|
||||||
{ "type": "history", "signal": "EPICS:PV1", "points": [ { "ts": "...", "value": 1.2 }, ... ] }
|
{ "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
|
// Error
|
||||||
{ "type": "error", "code": "NOT_FOUND", "message": "Signal not found" }
|
{ "type": "error", "code": "NOT_FOUND", "message": "Signal not found" }
|
||||||
```
|
```
|
||||||
@@ -205,6 +226,7 @@ Base path: `/api/v1`
|
|||||||
| GET, PUT | `/groups` | Read or set group definitions |
|
| GET, PUT | `/groups` | Read or set group definitions |
|
||||||
| GET, POST | `/synthetic` | List or create synthetic signal definitions |
|
| GET, POST | `/synthetic` | List or create synthetic signal definitions |
|
||||||
| GET, PUT, DELETE| `/synthetic/{name}` | Read, update, or delete a synthetic definition |
|
| 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, POST | `/controllogic` | List or create server-side control-logic graphs |
|
||||||
| GET, PUT, DELETE| `/controllogic/{id}` | Read, update, or delete a control-logic graph |
|
| GET, PUT, DELETE| `/controllogic/{id}` | Read, update, or delete a control-logic graph |
|
||||||
| GET, POST | `/config/sets` | List or create configuration sets (schemas) |
|
| GET, POST | `/config/sets` | List or create configuration sets (schemas) |
|
||||||
@@ -360,14 +382,48 @@ instance's `setId` in its `Meta`.
|
|||||||
|
|
||||||
### 3.10 Access Control
|
### 3.10 Access Control
|
||||||
|
|
||||||
Identity and global policy live in `internal/access` (`Policy`, `Level`). The user identity
|
Identity and global policy live in `internal/access` (`Policy`, `Role`, `Level`). The user
|
||||||
is read per request from `server.trusted_user_header` (with a `default_user` fallback) and
|
identity is read per request from `server.trusted_user_header` (with a `default_user`
|
||||||
stored on the request context. `accessMiddleware` gates mutating HTTP methods by the user's
|
fallback) and stored on the request context. Access is **role-based** through group
|
||||||
global level. Per-panel ownership and ACL evaluation (with folder inheritance) live in
|
memberships: each `[[groups]]` block lists members by role along the cumulative ladder
|
||||||
`internal/panelacl`, backed by the `acl.json` sidecar. `Policy.CanEditLogic(user)` enforces
|
`viewer < operator < logiceditor < auditor < admin`, and a user's **effective** global
|
||||||
the optional `server.logic_editors` allowlist over control-logic endpoints and over any
|
capability is the highest role across all their memberships. Roles map to capabilities as:
|
||||||
change to a panel's `<logic>` block; it is surfaced to the frontend through `/api/v1/me`
|
operator+ → write (`Level` is derived, `LevelWrite` for operator+, else `LevelRead`);
|
||||||
(`canEditLogic`) so the UI can hide logic-editing affordances.
|
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
|
### 3.11 Configuration Manager
|
||||||
|
|
||||||
@@ -565,15 +621,25 @@ storage_dir = "./interfaces"
|
|||||||
# Access control (all optional)
|
# Access control (all optional)
|
||||||
trusted_user_header = "" # header carrying the proxy-authenticated user
|
trusted_user_header = "" # header carrying the proxy-authenticated user
|
||||||
default_user = "" # identity when the header is absent (LAN/dev)
|
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"
|
# Role-based access through group memberships. Roles (low→high):
|
||||||
# user = "guest"
|
# viewer < operator < logiceditor < auditor < admin
|
||||||
# level = "readonly"
|
# Effective capability = highest role across all memberships. The built-in
|
||||||
|
# "public" group makes every user an implicit viewer. Groups may nest via
|
||||||
# [[groups]] # named user sets for per-panel sharing
|
# "parent" (members inherit their role on descendants). No roles anywhere = open
|
||||||
# name = "operators"
|
# (everyone admin); once set, unlisted users are read-only viewers.
|
||||||
# members = ["alice", "bob"]
|
# [[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]
|
[datasource.epics]
|
||||||
enabled = true
|
enabled = true
|
||||||
@@ -586,7 +652,7 @@ enabled = true
|
|||||||
```
|
```
|
||||||
|
|
||||||
All settings can also be overridden with `UOPI_*` environment variables (e.g.
|
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`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -611,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.
|
- 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
|
- **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
|
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
|
values — the proxy MUST strip any inbound copy of that header or it can be spoofed.
|
||||||
global blacklist downgrades users (read-only/no-access); per-panel ACLs and the optional
|
Authorisation is role-based through group memberships (viewer/operator/logiceditor/
|
||||||
`logic_editors` allowlist provide finer control. An unidentified caller (no header, no
|
auditor/admin), with the highest role across memberships deciding global capability;
|
||||||
`default_user`) is treated as a trusted-LAN user with full write access, preserving the
|
per-panel ACLs provide finer per-panel control. When **no** roles are assigned anywhere
|
||||||
unproxied/SSH-tunnel deployment model.
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+601
-135
@@ -1,32 +1,114 @@
|
|||||||
// Package access implements uopi's global user-access policy: every user is
|
// Package access implements uopi's role-based access policy. Access is granted
|
||||||
// trusted (full write) by default, while a configured blacklist can downgrade
|
// through group memberships: every user belongs implicitly to the built-in
|
||||||
// specific users to read-only or no access. It also resolves the per-request
|
// "public" group (granting the baseline viewer role), and may additionally be
|
||||||
// user identity and the user→group memberships defined in config.
|
// 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
|
// Groups can be nested: a member of a parent group holds that role on every
|
||||||
// layered on top of this in a later phase.
|
// 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
|
package access
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"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
|
type Level int
|
||||||
|
|
||||||
const (
|
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
|
LevelNone Level = iota
|
||||||
// LevelRead permits reads only (no create/update/delete/share, no signal writes).
|
// LevelRead permits reads only.
|
||||||
LevelRead
|
LevelRead
|
||||||
// LevelWrite permits full access. This is the default for any user not blacklisted.
|
// LevelWrite permits full write access.
|
||||||
LevelWrite
|
LevelWrite
|
||||||
)
|
)
|
||||||
|
|
||||||
// String renders the level using the same tokens accepted by ParseLevel and
|
// String renders the level using the tokens surfaced to the frontend via
|
||||||
// surfaced to the frontend via /api/v1/me.
|
// /api/v1/me.
|
||||||
func (l Level) String() string {
|
func (l Level) String() string {
|
||||||
switch l {
|
switch l {
|
||||||
case LevelNone:
|
case LevelNone:
|
||||||
@@ -38,87 +120,217 @@ func (l Level) String() string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseLevel maps a config string to a Level. Unknown values restrict to
|
func roleToLevel(r Role) Level {
|
||||||
// read-only, since the only reason to list a user is to limit them.
|
if r >= RoleOperator {
|
||||||
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":
|
|
||||||
return LevelWrite
|
return LevelWrite
|
||||||
default:
|
}
|
||||||
return LevelRead
|
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
|
// Policy holds the resolved role-based access configuration. It is safe for
|
||||||
// construction and safe for concurrent use.
|
// concurrent use; reads take a shared lock and admin mutations take an exclusive
|
||||||
|
// lock and persist to disk.
|
||||||
type Policy struct {
|
type Policy struct {
|
||||||
defaultUser string
|
mu sync.RWMutex
|
||||||
blacklist map[string]Level // user → downgraded level
|
path string // access.json path; "" disables persistence
|
||||||
userGroups map[string][]string // user → groups they belong to
|
defaultUser string // immutable after construction/load
|
||||||
groupNames []string // all configured group names (sorted)
|
groups map[string]*group
|
||||||
logicEditors map[string]bool // users + group names allowed to edit logic
|
|
||||||
auditReaders map[string]bool // users + group names allowed to view the audit log
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// New builds a Policy. blacklist maps a username to a config level string;
|
// GroupSpec seeds one group at construction time: its name, optional parent, and
|
||||||
// groups maps a group name to its member usernames. logicEditors optionally
|
// explicit user→role assignments.
|
||||||
// restricts who may edit panel/control logic (usernames or group names); empty
|
type GroupSpec struct {
|
||||||
// means no restriction.
|
Name string
|
||||||
func New(defaultUser string, blacklist map[string]string, groups map[string][]string, logicEditors, auditReaders []string) *Policy {
|
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{
|
p := &Policy{
|
||||||
defaultUser: strings.TrimSpace(defaultUser),
|
defaultUser: strings.TrimSpace(defaultUser),
|
||||||
blacklist: make(map[string]Level),
|
groups: make(map[string]*group),
|
||||||
userGroups: make(map[string][]string),
|
|
||||||
logicEditors: make(map[string]bool),
|
|
||||||
auditReaders: make(map[string]bool),
|
|
||||||
}
|
}
|
||||||
for _, e := range logicEditors {
|
for _, s := range specs {
|
||||||
e = strings.TrimSpace(e)
|
name := strings.TrimSpace(s.Name)
|
||||||
if e != "" {
|
if name == "" {
|
||||||
p.logicEditors[e] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, e := range auditReaders {
|
|
||||||
e = strings.TrimSpace(e)
|
|
||||||
if e != "" {
|
|
||||||
p.auditReaders[e] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for user, lvl := range blacklist {
|
|
||||||
u := strings.TrimSpace(user)
|
|
||||||
if u == "" {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
p.blacklist[u] = ParseLevel(lvl)
|
g := p.ensureGroupLocked(name)
|
||||||
}
|
g.parent = strings.TrimSpace(s.Parent)
|
||||||
for g, members := range groups {
|
for u, r := range s.Members {
|
||||||
g = strings.TrimSpace(g)
|
if u = strings.TrimSpace(u); u != "" {
|
||||||
if g == "" {
|
g.members[u] = r
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sort.Strings(p.groupNames)
|
}
|
||||||
|
p.ensureGroupLocked(PublicGroup).parent = ""
|
||||||
|
p.normalizeParentsLocked()
|
||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
|
|
||||||
// GroupNames returns a copy of every configured user-group name, sorted.
|
// ensureGroupLocked returns the named group, creating an empty one if needed.
|
||||||
func (p *Policy) GroupNames() []string {
|
// The caller must hold p.mu for writing (or be in construction).
|
||||||
out := make([]string, len(p.groupNames))
|
func (p *Policy) ensureGroupLocked(name string) *group {
|
||||||
copy(out, p.groupNames)
|
g, ok := p.groups[name]
|
||||||
return out
|
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
|
// ResolveUser trims the proxy-provided header value and falls back to the
|
||||||
// configured default_user when it is empty (e.g. unproxied/dev deployments).
|
// configured default_user when it is empty (e.g. unproxied/dev deployments).
|
||||||
@@ -130,81 +342,335 @@ func (p *Policy) ResolveUser(headerValue string) string {
|
|||||||
return u
|
return u
|
||||||
}
|
}
|
||||||
|
|
||||||
// Level returns the global access level for a user. Users that are neither
|
// Level returns the coarse global access level for a user.
|
||||||
// blacklisted nor anonymous get full write access.
|
|
||||||
func (p *Policy) Level(user string) Level {
|
func (p *Policy) Level(user string) Level {
|
||||||
user = strings.TrimSpace(user)
|
p.mu.RLock()
|
||||||
if user == "" {
|
defer p.mu.RUnlock()
|
||||||
// No identity at all (no proxy header, no default_user): trusted LAN.
|
return roleToLevel(p.effectiveRoleLocked(user))
|
||||||
return LevelWrite
|
|
||||||
}
|
|
||||||
if lvl, ok := p.blacklist[user]; ok {
|
|
||||||
return lvl
|
|
||||||
}
|
|
||||||
return LevelWrite
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// LogicRestricted reports whether a logic-editor allowlist is configured. When
|
// EffectiveRole returns the highest role a user holds across all groups.
|
||||||
// false, any write-capable user may edit panel/control logic.
|
func (p *Policy) EffectiveRole(user string) Role {
|
||||||
func (p *Policy) LogicRestricted() bool {
|
p.mu.RLock()
|
||||||
return len(p.logicEditors) > 0
|
defer p.mu.RUnlock()
|
||||||
|
return p.effectiveRoleLocked(user)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CanEditLogic reports whether a user may add or edit panel logic and
|
// CanEditLogic reports whether a user may add or edit panel and control logic
|
||||||
// server-side control logic. When no allowlist is configured everyone with
|
// (logic-editor role or higher).
|
||||||
// write access qualifies; otherwise the user (or one of their groups) must be
|
|
||||||
// listed. Anonymous/trusted-LAN callers (user=="") are always permitted.
|
|
||||||
func (p *Policy) CanEditLogic(user string) bool {
|
func (p *Policy) CanEditLogic(user string) bool {
|
||||||
user = strings.TrimSpace(user)
|
p.mu.RLock()
|
||||||
if user == "" {
|
defer p.mu.RUnlock()
|
||||||
return true
|
return p.effectiveRoleLocked(user) >= RoleLogic
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CanViewAudit reports whether a user may view the audit log. When no reader
|
// CanViewAudit reports whether a user may view the audit log (auditor or admin).
|
||||||
// allowlist is configured everyone with read access qualifies; otherwise the
|
|
||||||
// user (or one of their groups) must be listed. Anonymous/trusted-LAN callers
|
|
||||||
// (user=="") are always permitted.
|
|
||||||
func (p *Policy) CanViewAudit(user string) bool {
|
func (p *Policy) CanViewAudit(user string) bool {
|
||||||
user = strings.TrimSpace(user)
|
p.mu.RLock()
|
||||||
if user == "" {
|
defer p.mu.RUnlock()
|
||||||
return true
|
return p.effectiveRoleLocked(user) >= RoleAuditor
|
||||||
}
|
|
||||||
if len(p.auditReaders) == 0 {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if p.auditReaders[user] {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
for _, g := range p.userGroups[user] {
|
|
||||||
if p.auditReaders[g] {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GroupsOf returns a copy of the groups a user belongs to.
|
// CanAdmin reports whether a user may use the admin pane (admin role).
|
||||||
func (p *Policy) GroupsOf(user string) []string {
|
func (p *Policy) CanAdmin(user string) bool {
|
||||||
src := p.userGroups[strings.TrimSpace(user)]
|
p.mu.RLock()
|
||||||
out := make([]string, len(src))
|
defer p.mu.RUnlock()
|
||||||
copy(out, src)
|
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
|
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{}
|
type ctxKey struct{}
|
||||||
|
|
||||||
|
|||||||
+252
-21
@@ -1,35 +1,266 @@
|
|||||||
package access
|
package access
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
func TestCanEditLogic(t *testing.T) {
|
// spec is a small helper to build a GroupSpec.
|
||||||
groups := map[string][]string{"ops": {"carol"}}
|
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.
|
func TestUnconfiguredIsOpen(t *testing.T) {
|
||||||
open := New("", nil, groups, nil, nil)
|
// No roles assigned anywhere → fully open (everyone is admin), matching a
|
||||||
|
// fresh/dev deployment.
|
||||||
|
p := New("", nil)
|
||||||
for _, u := range []string{"", "alice", "carol"} {
|
for _, u := range []string{"", "alice", "carol"} {
|
||||||
if !open.CanEditLogic(u) {
|
if !p.CanAdmin(u) {
|
||||||
t.Errorf("unrestricted: CanEditLogic(%q) = false, want true", 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.
|
func TestRoleLadder(t *testing.T) {
|
||||||
p := New("", nil, groups, []string{"alice", "ops"}, nil)
|
p := New("", []GroupSpec{
|
||||||
if !p.LogicRestricted() {
|
spec("public", "", map[string]Role{"adm": RoleAdmin}),
|
||||||
t.Error("LogicRestricted() = false with allowlist set")
|
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{
|
if p.Level("nobody") != LevelRead {
|
||||||
"": true, // anonymous / trusted LAN
|
t.Errorf("unlisted Level = %v, want read", p.Level("nobody"))
|
||||||
"alice": true, // listed user
|
|
||||||
"carol": true, // member of listed group "ops"
|
|
||||||
"bob": false, // not listed
|
|
||||||
}
|
}
|
||||||
for u, want := range cases {
|
|
||||||
if got := p.CanEditLogic(u); got != want {
|
cases := []struct {
|
||||||
t.Errorf("CanEditLogic(%q) = %v, want %v", u, got, want)
|
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
|
||||||
|
}
|
||||||
|
|||||||
+257
-6
@@ -2,6 +2,7 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -12,6 +13,8 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"runtime"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -23,6 +26,7 @@ import (
|
|||||||
"github.com/uopi/uopi/internal/controllogic"
|
"github.com/uopi/uopi/internal/controllogic"
|
||||||
"github.com/uopi/uopi/internal/datasource"
|
"github.com/uopi/uopi/internal/datasource"
|
||||||
"github.com/uopi/uopi/internal/datasource/synthetic"
|
"github.com/uopi/uopi/internal/datasource/synthetic"
|
||||||
|
"github.com/uopi/uopi/internal/metrics"
|
||||||
"github.com/uopi/uopi/internal/panelacl"
|
"github.com/uopi/uopi/internal/panelacl"
|
||||||
"github.com/uopi/uopi/internal/storage"
|
"github.com/uopi/uopi/internal/storage"
|
||||||
)
|
)
|
||||||
@@ -97,6 +101,14 @@ func (h *Handler) Register(mux *http.ServeMux, prefix string) {
|
|||||||
mux.HandleFunc("DELETE "+prefix+"/folders/{id}", h.deleteFolder)
|
mux.HandleFunc("DELETE "+prefix+"/folders/{id}", h.deleteFolder)
|
||||||
// User groups defined in config (read-only; for the sharing UI)
|
// User groups defined in config (read-only; for the sharing UI)
|
||||||
mux.HandleFunc("GET "+prefix+"/usergroups", h.listUserGroups)
|
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
|
// Signal group tree
|
||||||
mux.HandleFunc("GET "+prefix+"/groups", h.getGroups)
|
mux.HandleFunc("GET "+prefix+"/groups", h.getGroups)
|
||||||
mux.HandleFunc("PUT "+prefix+"/groups", h.putGroups)
|
mux.HandleFunc("PUT "+prefix+"/groups", h.putGroups)
|
||||||
@@ -106,6 +118,8 @@ func (h *Handler) Register(mux *http.ServeMux, prefix string) {
|
|||||||
mux.HandleFunc("GET "+prefix+"/synthetic/{name}", h.getSynthetic)
|
mux.HandleFunc("GET "+prefix+"/synthetic/{name}", h.getSynthetic)
|
||||||
mux.HandleFunc("PUT "+prefix+"/synthetic/{name}", h.updateSynthetic)
|
mux.HandleFunc("PUT "+prefix+"/synthetic/{name}", h.updateSynthetic)
|
||||||
mux.HandleFunc("DELETE "+prefix+"/synthetic/{name}", h.deleteSynthetic)
|
mux.HandleFunc("DELETE "+prefix+"/synthetic/{name}", h.deleteSynthetic)
|
||||||
|
// 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).
|
// 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", h.listSyntheticVersions)
|
||||||
mux.HandleFunc("GET "+prefix+"/synthetic/{name}/versions/{version}", h.getSyntheticVersion)
|
mux.HandleFunc("GET "+prefix+"/synthetic/{name}/versions/{version}", h.getSyntheticVersion)
|
||||||
@@ -156,6 +170,7 @@ func (h *Handler) Register(mux *http.ServeMux, prefix string) {
|
|||||||
mux.HandleFunc("GET "+prefix+"/config/rules", h.listConfigRules)
|
mux.HandleFunc("GET "+prefix+"/config/rules", h.listConfigRules)
|
||||||
mux.HandleFunc("POST "+prefix+"/config/rules", h.createConfigRule)
|
mux.HandleFunc("POST "+prefix+"/config/rules", h.createConfigRule)
|
||||||
mux.HandleFunc("POST "+prefix+"/config/rules/check", h.checkConfigRule)
|
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("GET "+prefix+"/config/rules/{id}", h.getConfigRule)
|
||||||
mux.HandleFunc("PUT "+prefix+"/config/rules/{id}", h.updateConfigRule)
|
mux.HandleFunc("PUT "+prefix+"/config/rules/{id}", h.updateConfigRule)
|
||||||
mux.HandleFunc("DELETE "+prefix+"/config/rules/{id}", h.deleteConfigRule)
|
mux.HandleFunc("DELETE "+prefix+"/config/rules/{id}", h.deleteConfigRule)
|
||||||
@@ -183,6 +198,7 @@ func (h *Handler) getMe(w http.ResponseWriter, r *http.Request) {
|
|||||||
"groups": groups,
|
"groups": groups,
|
||||||
"canEditLogic": h.policy.CanEditLogic(user),
|
"canEditLogic": h.policy.CanEditLogic(user),
|
||||||
"canViewAudit": h.policy.CanViewAudit(user),
|
"canViewAudit": h.policy.CanViewAudit(user),
|
||||||
|
"canAdmin": h.policy.CanAdmin(user),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,23 +300,25 @@ func (h *Handler) capByGlobal(user string, p panelacl.Perm) panelacl.Perm {
|
|||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
|
|
||||||
// panelPerm returns the caller's effective permission on a panel. A request
|
// panelPerm returns the caller's effective permission on a panel. A request with
|
||||||
// with no resolved identity (no proxy header and no default_user) is a trusted
|
// no resolved identity (no proxy header and no default_user) has no per-user ACL,
|
||||||
// LAN deployment with no per-user enforcement, so it gets full write.
|
// 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 {
|
func (h *Handler) panelPerm(r *http.Request, id string) panelacl.Perm {
|
||||||
user := caller(r)
|
user := caller(r)
|
||||||
if user == "" {
|
if user == "" {
|
||||||
return panelacl.PermWrite
|
return h.capByGlobal("", panelacl.PermWrite)
|
||||||
}
|
}
|
||||||
return h.capByGlobal(user, h.acl.PanelPerm(id, user, h.policy.GroupsOf(user)))
|
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
|
// 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 {
|
func (h *Handler) folderPerm(r *http.Request, id string) panelacl.Perm {
|
||||||
user := caller(r)
|
user := caller(r)
|
||||||
if user == "" {
|
if user == "" {
|
||||||
return panelacl.PermWrite
|
return h.capByGlobal("", panelacl.PermWrite)
|
||||||
}
|
}
|
||||||
return h.capByGlobal(user, h.acl.FolderPerm(id, user, h.policy.GroupsOf(user)))
|
return h.capByGlobal(user, h.acl.FolderPerm(id, user, h.policy.GroupsOf(user)))
|
||||||
}
|
}
|
||||||
@@ -1114,6 +1132,209 @@ func (h *Handler) listUserGroups(w http.ResponseWriter, _ *http.Request) {
|
|||||||
jsonOK(w, names)
|
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").
|
// genID returns a short unique id with the given prefix (e.g. "fld-1a2b3c4d").
|
||||||
func genID(prefix string) string {
|
func genID(prefix string) string {
|
||||||
var b [8]byte
|
var b [8]byte
|
||||||
@@ -1221,6 +1442,36 @@ func (h *Handler) deleteSynthetic(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(http.StatusNoContent)
|
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) {
|
func (h *Handler) listSyntheticVersions(w http.ResponseWriter, r *http.Request) {
|
||||||
if h.synthetic == nil {
|
if h.synthetic == nil {
|
||||||
jsonError(w, http.StatusServiceUnavailable, "synthetic data source not enabled")
|
jsonError(w, http.StatusServiceUnavailable, "synthetic data source not enabled")
|
||||||
|
|||||||
+147
-1
@@ -61,7 +61,7 @@ func setup(t *testing.T) (*httptest.Server, func()) {
|
|||||||
clEngine := controllogic.NewEngine(ctx, brk, clStore, cfgStore, audit.Nop(), log)
|
clEngine := controllogic.NewEngine(ctx, brk, clStore, cfgStore, audit.Nop(), log)
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
api.New(brk, nil, store, cfgStore, access.New("", nil, nil, nil, nil), acl, clStore, clEngine, audit.Nop(), "", "", log).Register(mux, "/api/v1")
|
api.New(brk, nil, store, cfgStore, access.New("", nil), acl, clStore, clEngine, audit.Nop(), "", "", log).Register(mux, "/api/v1")
|
||||||
|
|
||||||
srv := httptest.NewServer(mux)
|
srv := httptest.NewServer(mux)
|
||||||
return srv, func() {
|
return srv, func() {
|
||||||
@@ -452,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) ─────────────────────────────────────
|
// ── Ensure os is used (blank import guard) ─────────────────────────────────────
|
||||||
|
|
||||||
var _ = os.DevNull
|
var _ = os.DevNull
|
||||||
|
|||||||
@@ -765,3 +765,37 @@ func (h *Handler) checkConfigRule(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
jsonOK(w, confmgr.EvaluateRule(body.Source, body.Values))
|
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})
|
||||||
|
}
|
||||||
|
|||||||
+21
-32
@@ -19,28 +19,27 @@ type Config struct {
|
|||||||
|
|
||||||
// AuditConfig controls the audit trail. When enabled, every user and automated
|
// AuditConfig controls the audit trail. When enabled, every user and automated
|
||||||
// action that could affect the controlled system (signal writes, control-logic
|
// action that could affect the controlled system (signal writes, control-logic
|
||||||
// changes) is recorded to a SQLite database for later review by audit staff.
|
// 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 {
|
type AuditConfig struct {
|
||||||
Enabled bool `toml:"enabled"`
|
Enabled bool `toml:"enabled"`
|
||||||
DBPath string `toml:"db_path"` // SQLite file; default {storage_dir}/audit.db
|
DBPath string `toml:"db_path"` // SQLite file; default {storage_dir}/audit.db
|
||||||
// Readers lists users or group names allowed to view the audit log. Empty
|
|
||||||
// leaves viewing unrestricted (any caller with read access). Anonymous /
|
|
||||||
// trusted-LAN callers are always permitted.
|
|
||||||
Readers []string `toml:"readers"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GroupDef is a named set of users defined as [[groups]] in the config file.
|
// GroupDef 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 {
|
type GroupDef struct {
|
||||||
Name string `toml:"name"`
|
Name string `toml:"name"`
|
||||||
Members []string `toml:"members"`
|
Parent string `toml:"parent"`
|
||||||
}
|
Viewers []string `toml:"viewers"`
|
||||||
|
Operators []string `toml:"operators"`
|
||||||
// BlacklistEntry downgrades a specific user's global access level. Levels:
|
LogicEditors []string `toml:"logiceditors"`
|
||||||
// "readonly" (no writes) or "noaccess" (denied). Users not listed are trusted
|
Auditors []string `toml:"auditors"`
|
||||||
// with full access.
|
Admins []string `toml:"admins"`
|
||||||
type BlacklistEntry struct {
|
|
||||||
User string `toml:"user"`
|
|
||||||
Level string `toml:"level"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type ServerConfig struct {
|
type ServerConfig struct {
|
||||||
@@ -58,18 +57,14 @@ type ServerConfig struct {
|
|||||||
|
|
||||||
// DefaultUser is the identity used when the trusted user header is absent or
|
// 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.
|
// 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"`
|
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 {
|
type DatasourceConfig struct {
|
||||||
@@ -151,18 +146,12 @@ func applyEnv(cfg *Config) {
|
|||||||
if v := env("UOPI_SERVER_DEFAULT_USER"); v != "" {
|
if v := env("UOPI_SERVER_DEFAULT_USER"); v != "" {
|
||||||
cfg.Server.DefaultUser = 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 != "" {
|
if v := env("UOPI_AUDIT_ENABLED"); v != "" {
|
||||||
cfg.Audit.Enabled = (v == "true" || v == "YES")
|
cfg.Audit.Enabled = (v == "true" || v == "YES")
|
||||||
}
|
}
|
||||||
if v := env("UOPI_AUDIT_DB_PATH"); v != "" {
|
if v := env("UOPI_AUDIT_DB_PATH"); v != "" {
|
||||||
cfg.Audit.DBPath = v
|
cfg.Audit.DBPath = v
|
||||||
}
|
}
|
||||||
if v := env("UOPI_AUDIT_READERS"); v != "" {
|
|
||||||
cfg.Audit.Readers = strings.Fields(v)
|
|
||||||
}
|
|
||||||
if v := env("UOPI_EPICS_CA_ADDR_LIST"); v != "" {
|
if v := env("UOPI_EPICS_CA_ADDR_LIST"); v != "" {
|
||||||
cfg.Datasource.EPICS.CAAddrList = v
|
cfg.Datasource.EPICS.CAAddrList = v
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,12 +24,20 @@ type ConfigRule struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
SetID string `json:"setId"`
|
SetID string `json:"setId"`
|
||||||
Description string `json:"description,omitempty"`
|
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"`
|
Version int `json:"version"`
|
||||||
Tag string `json:"tag,omitempty"`
|
Tag string `json:"tag,omitempty"`
|
||||||
Owner string `json:"owner,omitempty"`
|
Owner string `json:"owner,omitempty"`
|
||||||
Source string `json:"source"`
|
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.
|
// RuleViolation is a single constraint failure from evaluating a rule.
|
||||||
type RuleViolation struct {
|
type RuleViolation struct {
|
||||||
Rule string `json:"rule,omitempty"` // rule ID that produced it (aggregate runs)
|
Rule string `json:"rule,omitempty"` // rule ID that produced it (aggregate runs)
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ type Meta struct {
|
|||||||
// empty for sets. Lets clients group/filter instances by set without a GET
|
// empty for sets. Lets clients group/filter instances by set without a GET
|
||||||
// per instance.
|
// per instance.
|
||||||
SetID string `json:"setId,omitempty"`
|
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.
|
// VersionMeta describes a single persisted revision.
|
||||||
@@ -85,6 +89,7 @@ type header struct {
|
|||||||
Version int `json:"version"`
|
Version int `json:"version"`
|
||||||
Tag string `json:"tag"`
|
Tag string `json:"tag"`
|
||||||
SetID string `json:"setId"`
|
SetID string `json:"setId"`
|
||||||
|
Enabled *bool `json:"enabled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateID(id string) error {
|
func validateID(id string) error {
|
||||||
@@ -149,7 +154,7 @@ func (s *Store) List(k Kind) ([]Meta, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
out = append(out, Meta{ID: id, Name: h.Name, Version: h.Version, SetID: h.SetID})
|
out = append(out, Meta{ID: id, Name: h.Name, Version: h.Version, SetID: h.SetID, Enabled: h.Enabled})
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
@@ -587,6 +592,9 @@ func (s *Store) rulesForSet(setID string) ([]ConfigRule, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if !rule.IsEnabled() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
out = append(out, rule)
|
out = append(out, rule)
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
|
|||||||
@@ -95,6 +95,26 @@ func TestRuleTransformPersists(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
func asRuleError(err error, target **RuleError) bool {
|
||||||
for err != nil {
|
for err != nil {
|
||||||
if re, ok := err.(*RuleError); ok {
|
if re, ok := err.(*RuleError); ok {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,6 +60,20 @@ type Engine struct {
|
|||||||
// holds while it waits for those same goroutines to drain).
|
// holds while it waits for those same goroutines to drain).
|
||||||
notifier atomic.Value // notifierBox
|
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").
|
// Shared live signal cache for the current generation (key "ds\0name").
|
||||||
liveMu sync.RWMutex
|
liveMu sync.RWMutex
|
||||||
live map[string]float64
|
live map[string]float64
|
||||||
@@ -91,6 +105,7 @@ func NewEngine(root context.Context, brk *broker.Broker, store *Store, cfg *conf
|
|||||||
log: log,
|
log: log,
|
||||||
root: root,
|
root: root,
|
||||||
live: map[string]float64{},
|
live: map[string]float64{},
|
||||||
|
fireChs: map[string]chan string{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,6 +202,7 @@ func (e *Engine) Reload() {
|
|||||||
cg.engine = e
|
cg.engine = e
|
||||||
cg.genCtx = genCtx
|
cg.genCtx = genCtx
|
||||||
cg.wg = wg
|
cg.wg = wg
|
||||||
|
e.registerFire(cg.id, cg.fireCh)
|
||||||
}
|
}
|
||||||
|
|
||||||
// One shared updates channel feeds a single dispatch goroutine; every
|
// One shared updates channel feeds a single dispatch goroutine; every
|
||||||
@@ -209,6 +225,9 @@ func (e *Engine) Reload() {
|
|||||||
for _, u := range unsubs {
|
for _, u := range unsubs {
|
||||||
u()
|
u()
|
||||||
}
|
}
|
||||||
|
for _, cg := range compiled {
|
||||||
|
e.unregisterFire(cg.id, cg.fireCh)
|
||||||
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Dispatch goroutine: keep the live cache fresh and drive level/edge triggers.
|
// Dispatch goroutine: keep the live cache fresh and drive level/edge triggers.
|
||||||
@@ -268,6 +287,9 @@ func (e *Engine) write(cg *compiledGraph, target string, val float64) {
|
|||||||
cg.setLocal(name, val)
|
cg.setLocal(name, val)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if cg.dryRun {
|
||||||
|
return // simulate: no real data-source write
|
||||||
|
}
|
||||||
src, ok := e.broker.Source(ds)
|
src, ok := e.broker.Source(ds)
|
||||||
if !ok {
|
if !ok {
|
||||||
e.log.Warn("control logic: write to unknown data source", "ds", ds, "signal", name)
|
e.log.Warn("control logic: write to unknown data source", "ds", ds, "signal", name)
|
||||||
@@ -558,6 +580,14 @@ type compiledGraph struct {
|
|||||||
genCtx context.Context
|
genCtx context.Context
|
||||||
wg *sync.WaitGroup
|
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
|
name string
|
||||||
byId map[string]Node
|
byId map[string]Node
|
||||||
out map[string][]wireOut
|
out map[string][]wireOut
|
||||||
@@ -567,6 +597,11 @@ type compiledGraph struct {
|
|||||||
watchers map[string][]string // signal key → trigger node ids
|
watchers map[string][]string // signal key → trigger node ids
|
||||||
luaNodes map[string]*luaRuntime
|
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
|
stateMu sync.Mutex
|
||||||
levelState map[string]bool // current truth of level triggers (threshold/alarm)
|
levelState map[string]bool // current truth of level triggers (threshold/alarm)
|
||||||
prevBool map[string]bool // edge detection for threshold/alarm
|
prevBool map[string]bool // edge detection for threshold/alarm
|
||||||
@@ -578,6 +613,7 @@ type compiledGraph struct {
|
|||||||
|
|
||||||
func compile(g Graph) *compiledGraph {
|
func compile(g Graph) *compiledGraph {
|
||||||
cg := &compiledGraph{
|
cg := &compiledGraph{
|
||||||
|
id: g.ID,
|
||||||
name: g.Name,
|
name: g.Name,
|
||||||
byId: map[string]Node{},
|
byId: map[string]Node{},
|
||||||
out: map[string][]wireOut{},
|
out: map[string][]wireOut{},
|
||||||
@@ -591,6 +627,7 @@ func compile(g Graph) *compiledGraph {
|
|||||||
hasVal: map[string]bool{},
|
hasVal: map[string]bool{},
|
||||||
lastFire: map[string]int64{},
|
lastFire: map[string]int64{},
|
||||||
locals: map[string]float64{},
|
locals: map[string]float64{},
|
||||||
|
fireCh: make(chan string, 16),
|
||||||
}
|
}
|
||||||
for _, n := range g.Nodes {
|
for _, n := range g.Nodes {
|
||||||
cg.byId[n.ID] = n
|
cg.byId[n.ID] = n
|
||||||
@@ -663,6 +700,24 @@ func (cg *compiledGraph) getLocal(name string) float64 {
|
|||||||
|
|
||||||
// startTriggers launches timer and cron trigger goroutines for the generation.
|
// startTriggers launches timer and cron trigger goroutines for the generation.
|
||||||
func (cg *compiledGraph) startTriggers() {
|
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
|
hasCron := false
|
||||||
for _, n := range cg.byId {
|
for _, n := range cg.byId {
|
||||||
switch n.Kind {
|
switch n.Kind {
|
||||||
@@ -856,6 +911,8 @@ func (cg *compiledGraph) activate(triggerID string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cg.emitDebug(triggerID, 0, false)
|
||||||
|
|
||||||
cg.wg.Add(1)
|
cg.wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer cg.wg.Done()
|
defer cg.wg.Done()
|
||||||
@@ -888,6 +945,8 @@ func (cg *compiledGraph) run(nodeID string, ctx *runCtx) {
|
|||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cg.emitDebug(node.ID, 0, false)
|
||||||
|
|
||||||
switch node.Kind {
|
switch node.Kind {
|
||||||
case "gate.and":
|
case "gate.and":
|
||||||
if cg.gateSatisfied(node.ID, ctx.fired) {
|
if cg.gateSatisfied(node.ID, ctx.fired) {
|
||||||
@@ -896,9 +955,15 @@ func (cg *compiledGraph) run(nodeID string, ctx *runCtx) {
|
|||||||
|
|
||||||
case "flow.if":
|
case "flow.if":
|
||||||
branch := "else"
|
branch := "else"
|
||||||
if EvalBool(node.param("cond"), ctx.resolve) {
|
pass := EvalBool(node.param("cond"), ctx.resolve)
|
||||||
|
if pass {
|
||||||
branch = "then"
|
branch = "then"
|
||||||
}
|
}
|
||||||
|
v := 0.0
|
||||||
|
if pass {
|
||||||
|
v = 1
|
||||||
|
}
|
||||||
|
cg.emitDebug(node.ID, v, true)
|
||||||
cg.follow(node.ID, branch, ctx)
|
cg.follow(node.ID, branch, ctx)
|
||||||
|
|
||||||
case "flow.loop":
|
case "flow.loop":
|
||||||
@@ -922,11 +987,14 @@ func (cg *compiledGraph) run(nodeID string, ctx *runCtx) {
|
|||||||
|
|
||||||
case "action.write":
|
case "action.write":
|
||||||
val := EvalExpr(node.param("expr"), ctx.resolve)
|
val := EvalExpr(node.param("expr"), ctx.resolve)
|
||||||
|
cg.emitDebug(node.ID, val, true)
|
||||||
cg.engine.write(cg, node.param("target"), val)
|
cg.engine.write(cg, node.param("target"), val)
|
||||||
cg.follow(node.ID, "out", ctx)
|
cg.follow(node.ID, "out", ctx)
|
||||||
|
|
||||||
case "action.config.apply":
|
case "action.config.apply":
|
||||||
|
if !cg.dryRun {
|
||||||
cg.engine.applyConfig(cg, strings.TrimSpace(node.param("instance")))
|
cg.engine.applyConfig(cg, strings.TrimSpace(node.param("instance")))
|
||||||
|
}
|
||||||
cg.follow(node.ID, "out", ctx)
|
cg.follow(node.ID, "out", ctx)
|
||||||
|
|
||||||
case "action.config.read":
|
case "action.config.read":
|
||||||
@@ -938,15 +1006,22 @@ func (cg *compiledGraph) run(nodeID string, ctx *runCtx) {
|
|||||||
|
|
||||||
case "action.config.write":
|
case "action.config.write":
|
||||||
val := EvalExpr(node.param("expr"), ctx.resolve)
|
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.engine.writeConfigParam(cg, strings.TrimSpace(node.param("instance")), strings.TrimSpace(node.param("key")), val)
|
||||||
|
}
|
||||||
cg.follow(node.ID, "out", ctx)
|
cg.follow(node.ID, "out", ctx)
|
||||||
|
|
||||||
case "action.config.create":
|
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.engine.createConfigInstance(cg, strings.TrimSpace(node.param("set")), strings.TrimSpace(node.param("name")), strings.TrimSpace(node.param("from")))
|
||||||
|
}
|
||||||
cg.follow(node.ID, "out", ctx)
|
cg.follow(node.ID, "out", ctx)
|
||||||
|
|
||||||
case "action.config.snapshot":
|
case "action.config.snapshot":
|
||||||
|
if !cg.dryRun {
|
||||||
cg.engine.snapshotConfig(cg, strings.TrimSpace(node.param("set")), strings.TrimSpace(node.param("name")))
|
cg.engine.snapshotConfig(cg, strings.TrimSpace(node.param("set")), strings.TrimSpace(node.param("name")))
|
||||||
|
}
|
||||||
cg.follow(node.ID, "out", ctx)
|
cg.follow(node.ID, "out", ctx)
|
||||||
|
|
||||||
case "action.delay":
|
case "action.delay":
|
||||||
@@ -967,6 +1042,7 @@ func (cg *compiledGraph) run(nodeID string, ctx *runCtx) {
|
|||||||
|
|
||||||
case "action.log":
|
case "action.log":
|
||||||
val := EvalExpr(node.param("expr"), ctx.resolve)
|
val := EvalExpr(node.param("expr"), ctx.resolve)
|
||||||
|
cg.emitDebug(node.ID, val, true)
|
||||||
label := strings.TrimSpace(node.param("label"))
|
label := strings.TrimSpace(node.param("label"))
|
||||||
cg.engine.log.Info("control logic log", "graph", cg.name, "label", label, "value", val)
|
cg.engine.log.Info("control logic log", "graph", cg.name, "label", label, "value", val)
|
||||||
cg.follow(node.ID, "out", ctx)
|
cg.follow(node.ID, "out", ctx)
|
||||||
@@ -976,7 +1052,9 @@ func (cg *compiledGraph) run(nodeID string, ctx *runCtx) {
|
|||||||
cg.follow(node.ID, "out", ctx)
|
cg.follow(node.ID, "out", ctx)
|
||||||
|
|
||||||
case "action.dialog":
|
case "action.dialog":
|
||||||
|
if !cg.dryRun {
|
||||||
cg.engine.emitDialog(node)
|
cg.engine.emitDialog(node)
|
||||||
|
}
|
||||||
cg.follow(node.ID, "out", ctx)
|
cg.follow(node.ID, "out", ctx)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -50,6 +50,15 @@ type Wire struct {
|
|||||||
To string `json:"to"`
|
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.
|
// Graph is a named, independently-enableable control-logic flow.
|
||||||
type Graph struct {
|
type Graph struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
@@ -59,6 +68,7 @@ type Graph struct {
|
|||||||
Tag string `json:"tag,omitempty"` // optional revision label (e.g. "restored from v3")
|
Tag string `json:"tag,omitempty"` // optional revision label (e.g. "restored from v3")
|
||||||
Nodes []Node `json:"nodes"`
|
Nodes []Node `json:"nodes"`
|
||||||
Wires []Wire `json:"wires"`
|
Wires []Wire `json:"wires"`
|
||||||
|
Groups []NodeGroup `json:"groups,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n Node) param(key string) string {
|
func (n Node) param(key string) string {
|
||||||
|
|||||||
@@ -49,6 +49,17 @@ type NodeDef struct {
|
|||||||
type Graph struct {
|
type Graph struct {
|
||||||
Nodes []GraphNode `json:"nodes"`
|
Nodes []GraphNode `json:"nodes"`
|
||||||
Output string `json:"output"` // id of the output node
|
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.
|
// GraphNode is one node in a Graph.
|
||||||
|
|||||||
@@ -52,7 +52,20 @@ func (rg *runtimeGraph) sourceRefs() []broker.SignalRef {
|
|||||||
// - stateful ops (filters) and lua are scalar-only; an array input errors,
|
// - stateful ops (filters) and lua are scalar-only; an array input errors,
|
||||||
// since their per-evaluation state cannot be split across array lanes.
|
// since their per-evaluation state cannot be split across array lanes.
|
||||||
func (rg *runtimeGraph) evalSample(sourceVals map[string]dsp.Sample) (dsp.Sample, error) {
|
func (rg *runtimeGraph) evalSample(sourceVals map[string]dsp.Sample) (dsp.Sample, error) {
|
||||||
vals := make(map[string]dsp.Sample, len(rg.order))
|
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 {
|
for id, v := range sourceVals {
|
||||||
vals[id] = v
|
vals[id] = v
|
||||||
}
|
}
|
||||||
@@ -65,7 +78,7 @@ func (rg *runtimeGraph) evalSample(sourceVals map[string]dsp.Sample) (dsp.Sample
|
|||||||
}
|
}
|
||||||
r, err := evalOp(n, in)
|
r, err := evalOp(n, in)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return dsp.Sample{}, fmt.Errorf("node %s (%s): %w", n.id, n.op.Type(), err)
|
return vals, fmt.Errorf("node %s (%s): %w", n.id, n.op.Type(), err)
|
||||||
}
|
}
|
||||||
vals[n.id] = r
|
vals[n.id] = r
|
||||||
case "output":
|
case "output":
|
||||||
@@ -74,7 +87,7 @@ func (rg *runtimeGraph) evalSample(sourceVals map[string]dsp.Sample) (dsp.Sample
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return vals[rg.outputID], nil
|
return vals, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// evalOp runs a single op node over its Sample inputs, choosing the right
|
// evalOp runs a single op node over its Sample inputs, choosing the right
|
||||||
|
|||||||
@@ -462,6 +462,65 @@ func outTypeOf(st *signalState) dsp.ValType {
|
|||||||
return st.rg.outType
|
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
|
// toSample coerces a datasource.Value.Data into a dsp.Sample: arrays become
|
||||||
// array Samples (waveforms), everything else a scalar Sample.
|
// array Samples (waveforms), everything else a scalar Sample.
|
||||||
func toSample(v any) dsp.Sample {
|
func toSample(v any) dsp.Sample {
|
||||||
|
|||||||
@@ -22,8 +22,20 @@ var (
|
|||||||
"moving_average": true, "rms": true, "lowpass": true,
|
"moving_average": true, "rms": true, "lowpass": true,
|
||||||
"derivative": true, "integrate": true, "lua": 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
|
// 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
|
// 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
|
// ValUnknown (a source whose real type is not yet known at compile time); such
|
||||||
|
|||||||
@@ -38,6 +38,30 @@ func IncWrites() { writeOps.Add(1) }
|
|||||||
// IncHistoryReqs increments the history request counter.
|
// IncHistoryReqs increments the history request counter.
|
||||||
func IncHistoryReqs() { historyReqs.Add(1) }
|
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.
|
// Handler returns an http.HandlerFunc that renders Prometheus-format metrics.
|
||||||
// activeSubs is called each request to read the current number of unique signal
|
// activeSubs is called each request to read the current number of unique signal
|
||||||
// subscriptions from the broker; pass nil to omit the metric.
|
// 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
|
||||||
|
}
|
||||||
@@ -29,7 +29,7 @@ type Server struct {
|
|||||||
|
|
||||||
// New creates the HTTP server, registers all routes, and returns a ready-to-start Server.
|
// New creates the HTTP server, registers all routes, and returns a ready-to-start Server.
|
||||||
// synth may be nil if the synthetic data source is not enabled.
|
// synth may be nil if the synthetic data source is not enabled.
|
||||||
func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, cfgStore *confmgr.Store, policy *access.Policy, acl *panelacl.Store, ctrlLogic *controllogic.Store, ctrlEngine *controllogic.Engine, dialogs *DialogHub, rec audit.Recorder, channelFinderURL, archiverURL, trustedUserHeader string, log *slog.Logger) *Server {
|
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 {
|
if rec == nil {
|
||||||
rec = audit.Nop()
|
rec = audit.Nop()
|
||||||
}
|
}
|
||||||
@@ -42,7 +42,7 @@ func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Syntheti
|
|||||||
})
|
})
|
||||||
|
|
||||||
// WebSocket endpoint
|
// WebSocket endpoint
|
||||||
mux.Handle("/ws", &wsHandler{broker: brk, log: log, userHeader: trustedUserHeader, policy: policy, audit: rec, dialogs: dialogs})
|
mux.Handle("/ws", &wsHandler{broker: brk, log: log, userHeader: trustedUserHeader, policy: policy, audit: rec, dialogs: dialogs, debug: debug})
|
||||||
|
|
||||||
// Prometheus-format metrics
|
// Prometheus-format metrics
|
||||||
mux.HandleFunc("/metrics", metrics.Handler(brk.ActiveSubscriptions))
|
mux.HandleFunc("/metrics", metrics.Handler(brk.ActiveSubscriptions))
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
"github.com/uopi/uopi/internal/access"
|
"github.com/uopi/uopi/internal/access"
|
||||||
"github.com/uopi/uopi/internal/audit"
|
"github.com/uopi/uopi/internal/audit"
|
||||||
"github.com/uopi/uopi/internal/broker"
|
"github.com/uopi/uopi/internal/broker"
|
||||||
|
"github.com/uopi/uopi/internal/controllogic"
|
||||||
"github.com/uopi/uopi/internal/datasource"
|
"github.com/uopi/uopi/internal/datasource"
|
||||||
"github.com/uopi/uopi/internal/metrics"
|
"github.com/uopi/uopi/internal/metrics"
|
||||||
)
|
)
|
||||||
@@ -37,6 +38,14 @@ type inMsg struct {
|
|||||||
// dialogResponse — id of the control-logic dialog being answered.
|
// dialogResponse — id of the control-logic dialog being answered.
|
||||||
ID string `json:"id,omitempty"`
|
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
|
// history
|
||||||
Start time.Time `json:"start"`
|
Start time.Time `json:"start"`
|
||||||
End time.Time `json:"end"`
|
End time.Time `json:"end"`
|
||||||
@@ -106,6 +115,8 @@ type wsHandler struct {
|
|||||||
audit audit.Recorder
|
audit audit.Recorder
|
||||||
// dialogs fans control-logic dialogs to clients and routes responses.
|
// dialogs fans control-logic dialogs to clients and routes responses.
|
||||||
dialogs *DialogHub
|
dialogs *DialogHub
|
||||||
|
// debug fans control-logic node-execution events to watching editors.
|
||||||
|
debug *DebugHub
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *wsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
func (h *wsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -149,6 +160,7 @@ func (h *wsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
policy: h.policy,
|
policy: h.policy,
|
||||||
audit: rec,
|
audit: rec,
|
||||||
dialogs: h.dialogs,
|
dialogs: h.dialogs,
|
||||||
|
debug: h.debug,
|
||||||
outCh: make(chan []byte, 512),
|
outCh: make(chan []byte, 512),
|
||||||
updateCh: make(chan broker.Update, 1024),
|
updateCh: make(chan broker.Update, 1024),
|
||||||
subs: make(map[broker.SignalRef]func()),
|
subs: make(map[broker.SignalRef]func()),
|
||||||
@@ -160,6 +172,10 @@ func (h *wsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
h.dialogs.add(c)
|
h.dialogs.add(c)
|
||||||
defer h.dialogs.remove(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
|
var wg sync.WaitGroup
|
||||||
wg.Add(3)
|
wg.Add(3)
|
||||||
@@ -189,6 +205,7 @@ type wsClient struct {
|
|||||||
policy *access.Policy // global access-level enforcement
|
policy *access.Policy // global access-level enforcement
|
||||||
audit audit.Recorder // signal-write audit recorder (never nil)
|
audit audit.Recorder // signal-write audit recorder (never nil)
|
||||||
dialogs *DialogHub // control-logic dialog fan-out (nil if disabled)
|
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
|
outCh chan []byte // serialised outgoing messages
|
||||||
updateCh chan broker.Update // raw updates from the broker
|
updateCh chan broker.Update // raw updates from the broker
|
||||||
@@ -287,6 +304,16 @@ func (c *wsClient) handleMessage(ctx context.Context, data []byte) {
|
|||||||
c.handleHistory(ctx, msg)
|
c.handleHistory(ctx, msg)
|
||||||
case "dialogResponse":
|
case "dialogResponse":
|
||||||
c.handleDialogResponse(ctx, msg)
|
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:
|
default:
|
||||||
c.sendError(ctx, "UNKNOWN_TYPE", "unknown message type: "+msg.Type)
|
c.sendError(ctx, "UNKNOWN_TYPE", "unknown message type: "+msg.Type)
|
||||||
}
|
}
|
||||||
@@ -423,6 +450,26 @@ func (c *wsClient) handleDialogResponse(ctx context.Context, msg inMsg) {
|
|||||||
c.dialogs.respond(ctx, c, msg.ID, num)
|
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) {
|
func (c *wsClient) handleHistory(ctx context.Context, msg inMsg) {
|
||||||
metrics.IncHistoryReqs()
|
metrics.IncHistoryReqs()
|
||||||
ds, ok := c.broker.Source(msg.DS)
|
ds, ok := c.broker.Source(msg.DS)
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ type InterfaceMeta struct {
|
|||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Version int `json:"version"`
|
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.
|
// VersionMeta describes a single persisted revision of an interface.
|
||||||
@@ -134,6 +136,7 @@ type rootAttrs struct {
|
|||||||
Name string `xml:"name,attr"`
|
Name string `xml:"name,attr"`
|
||||||
Version int `xml:"version,attr"`
|
Version int `xml:"version,attr"`
|
||||||
Tag string `xml:"tag,attr"`
|
Tag string `xml:"tag,attr"`
|
||||||
|
Kind string `xml:"kind,attr"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) readMeta(id string) (InterfaceMeta, error) {
|
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 {
|
if err := xml.Unmarshal(data, &root); err != nil {
|
||||||
return InterfaceMeta{}, err
|
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.
|
// Get returns the raw XML bytes for the interface with the given ID.
|
||||||
|
|||||||
+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.
|
# Identity used when the trusted header is absent/empty. Empty = anonymous.
|
||||||
default_user = ""
|
default_user = ""
|
||||||
|
|
||||||
# Every user is trusted with full write access by default. The blacklist
|
# Role-based access through group memberships. Each [[groups]] block lists
|
||||||
# downgrades specific users. Levels: "readonly" (view only, no writes) or
|
# members by role; a user's effective global capability is the highest role
|
||||||
# "noaccess" (denied entirely).
|
# across all their memberships, along this ladder:
|
||||||
# [[server.blacklist]]
|
# viewer < operator < logiceditor < auditor < admin
|
||||||
# user = "guest"
|
# Capabilities: operator+ may write signals; logiceditor+ may edit panel and
|
||||||
# level = "readonly"
|
# control logic; auditor+ may view the audit log; admin may open the admin pane.
|
||||||
|
#
|
||||||
# Named sets of users, referenced by per-panel sharing rules.
|
# 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]]
|
# [[groups]]
|
||||||
# name = "operators"
|
# name = "public"
|
||||||
# members = ["alice", "bob"]
|
# admins = ["alice"] # alice is a global admin
|
||||||
|
# [[groups]]
|
||||||
# Optional: restrict who may add or edit panel logic (the <logic> block of
|
# name = "operations"
|
||||||
# interfaces) AND server-side control logic. Entries are usernames or group
|
# operators = ["bob"]
|
||||||
# names. Empty/unset = no restriction (any writer may edit logic). Also settable
|
# auditors = ["carol"]
|
||||||
# via UOPI_SERVER_LOGIC_EDITORS (space-separated).
|
# [[groups]]
|
||||||
# logic_editors = ["operators", "alice"]
|
# name = "engineers"
|
||||||
|
# parent = "operations" # bob inherits operator here
|
||||||
|
# logiceditors = ["dave"]
|
||||||
|
# viewers = ["erin"]
|
||||||
|
|
||||||
[datasource.epics]
|
[datasource.epics]
|
||||||
enabled = true
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+54
-1
@@ -12,15 +12,19 @@ import BarV from './widgets/BarV';
|
|||||||
import Led from './widgets/Led';
|
import Led from './widgets/Led';
|
||||||
import MultiLed from './widgets/MultiLed';
|
import MultiLed from './widgets/MultiLed';
|
||||||
import SetValue from './widgets/SetValue';
|
import SetValue from './widgets/SetValue';
|
||||||
|
import Toggle from './widgets/Toggle';
|
||||||
import Button from './widgets/Button';
|
import Button from './widgets/Button';
|
||||||
import PlotWidget from './widgets/PlotWidget';
|
import PlotWidget from './widgets/PlotWidget';
|
||||||
import ImageWidget from './widgets/ImageWidget';
|
import ImageWidget from './widgets/ImageWidget';
|
||||||
import LinkWidget from './widgets/LinkWidget';
|
import LinkWidget from './widgets/LinkWidget';
|
||||||
import ConfigSelect from './widgets/ConfigSelect';
|
import ConfigSelect from './widgets/ConfigSelect';
|
||||||
|
import Container from './widgets/Container';
|
||||||
|
import TableWidget from './widgets/TableWidget';
|
||||||
import ContextMenu from './ContextMenu';
|
import ContextMenu from './ContextMenu';
|
||||||
import InfoPanel from './InfoPanel';
|
import InfoPanel from './InfoPanel';
|
||||||
import SplitLayout from './SplitLayout';
|
import SplitLayout from './SplitLayout';
|
||||||
import LogicDialogs from './LogicDialogs';
|
import LogicDialogs from './LogicDialogs';
|
||||||
|
import { centreInside, widgetTab, tabPanes } from './lib/containers';
|
||||||
|
|
||||||
const COMPONENTS: Record<string, any> = {
|
const COMPONENTS: Record<string, any> = {
|
||||||
textview: TextView,
|
textview: TextView,
|
||||||
@@ -31,13 +35,24 @@ const COMPONENTS: Record<string, any> = {
|
|||||||
led: Led,
|
led: Led,
|
||||||
multiled: MultiLed,
|
multiled: MultiLed,
|
||||||
setvalue: SetValue,
|
setvalue: SetValue,
|
||||||
|
toggle: Toggle,
|
||||||
button: Button,
|
button: Button,
|
||||||
plot: PlotWidget,
|
plot: PlotWidget,
|
||||||
image: ImageWidget,
|
image: ImageWidget,
|
||||||
link: LinkWidget,
|
link: LinkWidget,
|
||||||
configselect: ConfigSelect,
|
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 {
|
interface CtxState {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
x: number;
|
x: number;
|
||||||
@@ -82,6 +97,26 @@ export default function Canvas({ iface, onNavigate, timeRange }: Props) {
|
|||||||
visible: false, x: 0, y: 0, signal: null,
|
visible: false, x: 0, y: 0, signal: null,
|
||||||
});
|
});
|
||||||
const [infoSignal, setInfoSignal] = useState<SignalRef | null>(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.
|
// Instantiate this panel's local state variables from their initial values.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -155,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 (
|
return (
|
||||||
<div class="canvas-container">
|
<div class="canvas-container">
|
||||||
<div class="canvas-area canvas-view-bare" style={`width:${iface.w}px;height:${iface.h}px;`}>
|
<div class="canvas-area canvas-view-bare" style={`width:${iface.w}px;height:${iface.h}px;`}>
|
||||||
{iface.widgets.map(widget => {
|
{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 Comp = COMPONENTS[widget.type];
|
||||||
const inner = Comp
|
const inner = Comp
|
||||||
? <Comp
|
? <Comp
|
||||||
@@ -166,6 +215,10 @@ export default function Canvas({ iface, onNavigate, timeRange }: Props) {
|
|||||||
onContextMenu={(e: MouseEvent) => onCtxMenu(e, widget)}
|
onContextMenu={(e: MouseEvent) => onCtxMenu(e, widget)}
|
||||||
onNavigate={onNavigate}
|
onNavigate={onNavigate}
|
||||||
timeRange={timeRange}
|
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
|
<div
|
||||||
|
|||||||
+111
-6
@@ -46,7 +46,7 @@ interface ConfigInstance {
|
|||||||
values: Record<string, any>;
|
values: Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Meta { id: string; name: string; version: number; setId?: string; }
|
interface Meta { id: string; name: string; version: number; setId?: string; enabled?: boolean; }
|
||||||
|
|
||||||
// ConfigRule mirrors internal/confmgr.ConfigRule: CUE validation/transformation
|
// ConfigRule mirrors internal/confmgr.ConfigRule: CUE validation/transformation
|
||||||
// logic bound to a set, run when instances of that set are saved.
|
// logic bound to a set, run when instances of that set are saved.
|
||||||
@@ -55,6 +55,7 @@ interface ConfigRule {
|
|||||||
name: string;
|
name: string;
|
||||||
setId: string;
|
setId: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
enabled?: boolean;
|
||||||
version: number;
|
version: number;
|
||||||
tag?: string;
|
tag?: string;
|
||||||
owner?: string;
|
owner?: string;
|
||||||
@@ -65,6 +66,14 @@ interface ConfigRule {
|
|||||||
interface RuleViolation { rule?: string; path?: string; message: string; }
|
interface RuleViolation { rule?: string; path?: string; message: string; }
|
||||||
interface RuleResult { ok: boolean; violations?: RuleViolation[]; transformed?: Record<string, any>; compileError?: string; }
|
interface RuleResult { ok: boolean; violations?: RuleViolation[]; transformed?: Record<string, any>; compileError?: string; }
|
||||||
|
|
||||||
|
// Snapshot / preview payloads mirror the backend /config/rules/preview response.
|
||||||
|
interface SnapshotEntry { key: string; ds: string; signal: string; value?: any; ok: boolean; error?: string; }
|
||||||
|
interface SnapshotResult { setId: string; entries: SnapshotEntry[]; captured: number; failed: number; }
|
||||||
|
interface RulePreview { snapshot: SnapshotResult; result: RuleResult; }
|
||||||
|
|
||||||
|
// A rule with no explicit enabled flag (legacy) is treated as enabled.
|
||||||
|
const ruleEnabled = (r: { enabled?: boolean }) => r.enabled !== false;
|
||||||
|
|
||||||
interface ApplyEntry { key: string; ds: string; signal: string; value?: any; ok: boolean; skipped?: boolean; error?: string; }
|
interface ApplyEntry { key: string; ds: string; signal: string; value?: any; ok: boolean; skipped?: boolean; error?: string; }
|
||||||
interface ApplyResult { instanceId: string; setId: string; entries: ApplyEntry[]; applied: number; failed: number; skipped: number; }
|
interface ApplyResult { instanceId: string; setId: string; entries: ApplyEntry[]; applied: number; failed: number; skipped: number; }
|
||||||
|
|
||||||
@@ -795,6 +804,7 @@ function ParamEditor({ param, allOptions, onOpenSignals, signalMeta, onChange, o
|
|||||||
function InstancesManager({ registerGuard }: { registerGuard: (fn: CloseGuard | null) => void }) {
|
function InstancesManager({ registerGuard }: { registerGuard: (fn: CloseGuard | null) => void }) {
|
||||||
const [instances, setInstances] = useState<Meta[]>([]);
|
const [instances, setInstances] = useState<Meta[]>([]);
|
||||||
const [sets, setSets] = useState<Meta[]>([]);
|
const [sets, setSets] = useState<Meta[]>([]);
|
||||||
|
const [setFilter, setSetFilter] = useState<string>('');
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
const hist = useUndo<ConfigInstance>();
|
const hist = useUndo<ConfigInstance>();
|
||||||
const working = hist.present;
|
const working = hist.present;
|
||||||
@@ -974,6 +984,7 @@ function InstancesManager({ registerGuard }: { registerGuard: (fn: CloseGuard |
|
|||||||
}
|
}
|
||||||
|
|
||||||
const setName = (id: string) => sets.find(s => s.id === id)?.name ?? id;
|
const setName = (id: string) => sets.find(s => s.id === id)?.name ?? id;
|
||||||
|
const visibleInstances = instances.filter(s => !setFilter || s.setId === setFilter);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="cl-body">
|
<div class="cl-body">
|
||||||
@@ -983,9 +994,19 @@ function InstancesManager({ registerGuard }: { registerGuard: (fn: CloseGuard |
|
|||||||
<button class="panel-btn" disabled={busy || sets.length === 0} title="Snapshot a set's current live signal values into a new instance" onClick={() => setShowSnap(true)}>⎙ Snapshot</button>
|
<button class="panel-btn" disabled={busy || sets.length === 0} title="Snapshot a set's current live signal values into a new instance" onClick={() => setShowSnap(true)}>⎙ Snapshot</button>
|
||||||
<button class="panel-btn" disabled={busy || sets.length === 0} onClick={() => setShowNew(true)}>+ New</button>
|
<button class="panel-btn" disabled={busy || sets.length === 0} onClick={() => setShowNew(true)}>+ New</button>
|
||||||
</div>
|
</div>
|
||||||
|
{sets.length > 1 && (
|
||||||
|
<div class="cl-list-filter">
|
||||||
|
<select class="prop-select" value={setFilter} title="Filter instances by config set"
|
||||||
|
onChange={(e) => setSetFilter((e.target as HTMLSelectElement).value)}>
|
||||||
|
<option value="">All sets</option>
|
||||||
|
{sets.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{sets.length === 0 && <div class="hint cl-list-empty">Create a config set first.</div>}
|
{sets.length === 0 && <div class="hint cl-list-empty">Create a config set first.</div>}
|
||||||
{sets.length > 0 && instances.length === 0 && <div class="hint cl-list-empty">No instances yet.</div>}
|
{sets.length > 0 && instances.length === 0 && <div class="hint cl-list-empty">No instances yet.</div>}
|
||||||
{instances.map(s => (
|
{sets.length > 0 && instances.length > 0 && visibleInstances.length === 0 && <div class="hint cl-list-empty">No instances in this set.</div>}
|
||||||
|
{visibleInstances.map(s => (
|
||||||
<div key={s.id} class={`cl-list-item${selectedId === s.id ? ' cl-list-item-active' : ''}`} onClick={() => select(s.id)}>
|
<div key={s.id} class={`cl-list-item${selectedId === s.id ? ' cl-list-item-active' : ''}`} onClick={() => select(s.id)}>
|
||||||
<span class="cl-list-name" title={s.name}>{s.name || '(unnamed)'}</span>
|
<span class="cl-list-name" title={s.name}>{s.name || '(unnamed)'}</span>
|
||||||
<span class="cfg-badge">v{s.version}</span>
|
<span class="cfg-badge">v{s.version}</span>
|
||||||
@@ -1446,6 +1467,8 @@ function RulesManager({ registerGuard }: { registerGuard: (fn: CloseGuard | null
|
|||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [check, setCheck] = useState<RuleResult | null>(null);
|
const [check, setCheck] = useState<RuleResult | null>(null);
|
||||||
|
const [setFilter, setSetFilter] = useState<string>('');
|
||||||
|
const [preview, setPreview] = useState<RulePreview | null>(null);
|
||||||
const [showNew, setShowNew] = useState(false);
|
const [showNew, setShowNew] = useState(false);
|
||||||
const [viewingVersion, setViewingVersion] = useState<number | null>(null);
|
const [viewingVersion, setViewingVersion] = useState<number | null>(null);
|
||||||
const [verReload, setVerReload] = useState(0);
|
const [verReload, setVerReload] = useState(0);
|
||||||
@@ -1469,9 +1492,20 @@ function RulesManager({ registerGuard }: { registerGuard: (fn: CloseGuard | null
|
|||||||
return apiJSON<ConfigSet>(`/api/v1/config/sets/${encodeURIComponent(setId)}`);
|
return apiJSON<ConfigSet>(`/api/v1/config/sets/${encodeURIComponent(setId)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function runPreview() {
|
||||||
|
if (!working) return;
|
||||||
|
setBusy(true); setError(null);
|
||||||
|
try {
|
||||||
|
const res = await apiJSON<RulePreview>('/api/v1/config/rules/preview',
|
||||||
|
jsonPost({ setId: working.setId, source: working.source }));
|
||||||
|
setPreview(res);
|
||||||
|
} catch (err) { setError(`Preview failed: ${msg(err)}`); }
|
||||||
|
finally { setBusy(false); }
|
||||||
|
}
|
||||||
|
|
||||||
async function select(id: string) {
|
async function select(id: string) {
|
||||||
if (dirty && !confirm('Discard unsaved changes?')) return;
|
if (dirty && !confirm('Discard unsaved changes?')) return;
|
||||||
setError(null); setCheck(null);
|
setError(null); setCheck(null); setPreview(null);
|
||||||
try {
|
try {
|
||||||
const rule = await apiJSON<ConfigRule>(`/api/v1/config/rules/${encodeURIComponent(id)}`);
|
const rule = await apiJSON<ConfigRule>(`/api/v1/config/rules/${encodeURIComponent(id)}`);
|
||||||
const set = await loadSet(rule!.setId);
|
const set = await loadSet(rule!.setId);
|
||||||
@@ -1485,7 +1519,7 @@ function RulesManager({ registerGuard }: { registerGuard: (fn: CloseGuard | null
|
|||||||
async function createRule(name: string, setId: string) {
|
async function createRule(name: string, setId: string) {
|
||||||
setBusy(true); setError(null);
|
setBusy(true); setError(null);
|
||||||
try {
|
try {
|
||||||
const body = { name, setId, source: '// CUE rule: constrain or derive parameter values.\n' };
|
const body = { name, setId, enabled: true, source: '// CUE rule: constrain or derive parameter values.\n' };
|
||||||
const created = await apiJSON<ConfigRule>('/api/v1/config/rules', jsonPost(body));
|
const created = await apiJSON<ConfigRule>('/api/v1/config/rules', jsonPost(body));
|
||||||
const set = await loadSet(created!.setId);
|
const set = await loadSet(created!.setId);
|
||||||
await reload();
|
await reload();
|
||||||
@@ -1573,6 +1607,7 @@ function RulesManager({ registerGuard }: { registerGuard: (fn: CloseGuard | null
|
|||||||
const completions = boundSet
|
const completions = boundSet
|
||||||
? boundSet.parameters.flatMap(p => [p.key, `${p.ds}:${p.signal}`]).filter(Boolean)
|
? boundSet.parameters.flatMap(p => [p.key, `${p.ds}:${p.signal}`]).filter(Boolean)
|
||||||
: [];
|
: [];
|
||||||
|
const visibleRules = rules.filter(s => !setFilter || s.setId === setFilter);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="cl-body">
|
<div class="cl-body">
|
||||||
@@ -1581,11 +1616,22 @@ function RulesManager({ registerGuard }: { registerGuard: (fn: CloseGuard | null
|
|||||||
<span>Rules</span>
|
<span>Rules</span>
|
||||||
<button class="panel-btn" disabled={busy || sets.length === 0} onClick={() => setShowNew(true)}>+ New</button>
|
<button class="panel-btn" disabled={busy || sets.length === 0} onClick={() => setShowNew(true)}>+ New</button>
|
||||||
</div>
|
</div>
|
||||||
|
{sets.length > 1 && (
|
||||||
|
<div class="cl-list-filter">
|
||||||
|
<select class="prop-select" value={setFilter} title="Filter rules by config set"
|
||||||
|
onChange={(e) => setSetFilter((e.target as HTMLSelectElement).value)}>
|
||||||
|
<option value="">All sets</option>
|
||||||
|
{sets.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{sets.length === 0 && <div class="hint cl-list-empty">Create a config set first.</div>}
|
{sets.length === 0 && <div class="hint cl-list-empty">Create a config set first.</div>}
|
||||||
{sets.length > 0 && rules.length === 0 && <div class="hint cl-list-empty">No rules yet.</div>}
|
{sets.length > 0 && rules.length === 0 && <div class="hint cl-list-empty">No rules yet.</div>}
|
||||||
{rules.map(s => (
|
{sets.length > 0 && rules.length > 0 && visibleRules.length === 0 && <div class="hint cl-list-empty">No rules in this set.</div>}
|
||||||
<div key={s.id} class={`cl-list-item${selectedId === s.id ? ' cl-list-item-active' : ''}`} onClick={() => select(s.id)}>
|
{visibleRules.map(s => (
|
||||||
|
<div key={s.id} class={`cl-list-item${selectedId === s.id ? ' cl-list-item-active' : ''}${ruleEnabled(s) ? '' : ' cfg-rule-disabled'}`} onClick={() => select(s.id)}>
|
||||||
<span class="cl-list-name" title={s.name}>{s.name || '(unnamed)'}</span>
|
<span class="cl-list-name" title={s.name}>{s.name || '(unnamed)'}</span>
|
||||||
|
{!ruleEnabled(s) && <span class="hint cfg-rule-off" title="Disabled — not run on save/apply">off</span>}
|
||||||
{s.setId && <span class="hint cfg-rule-set" title="Bound set">{setName(s.setId)}</span>}
|
{s.setId && <span class="hint cfg-rule-set" title="Bound set">{setName(s.setId)}</span>}
|
||||||
<span class="cfg-badge">v{s.version}</span>
|
<span class="cfg-badge">v{s.version}</span>
|
||||||
<button class="cl-mini-btn" title="Delete" onClick={(e) => { e.stopPropagation(); remove(s.id); }}>✕</button>
|
<button class="cl-mini-btn" title="Delete" onClick={(e) => { e.stopPropagation(); remove(s.id); }}>✕</button>
|
||||||
@@ -1605,6 +1651,7 @@ function RulesManager({ registerGuard }: { registerGuard: (fn: CloseGuard | null
|
|||||||
{dirty && <span class="cl-dirty">unsaved</span>}
|
{dirty && <span class="cl-dirty">unsaved</span>}
|
||||||
<button class="panel-btn" disabled={!hist.canUndo} title="Undo (Ctrl+Z)" onClick={undo}>↩</button>
|
<button class="panel-btn" disabled={!hist.canUndo} title="Undo (Ctrl+Z)" onClick={undo}>↩</button>
|
||||||
<button class="panel-btn" disabled={!hist.canRedo} title="Redo (Ctrl+Shift+Z)" onClick={redo}>↪</button>
|
<button class="panel-btn" disabled={!hist.canRedo} title="Redo (Ctrl+Shift+Z)" onClick={redo}>↪</button>
|
||||||
|
<button class="panel-btn" disabled={busy} title="Run this rule against a live snapshot of the set's signals (nothing is stored)" onClick={runPreview}>⚡ Preview</button>
|
||||||
<button class="panel-btn panel-btn-primary" disabled={busy || !dirty} onClick={save}>Save</button>
|
<button class="panel-btn panel-btn-primary" disabled={busy || !dirty} onClick={save}>Save</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1621,6 +1668,14 @@ function RulesManager({ registerGuard }: { registerGuard: (fn: CloseGuard | null
|
|||||||
<input class="prop-input" value={working.description ?? ''} placeholder="(optional)"
|
<input class="prop-input" value={working.description ?? ''} placeholder="(optional)"
|
||||||
onInput={(e) => patch({ description: (e.target as HTMLInputElement).value })} />
|
onInput={(e) => patch({ description: (e.target as HTMLInputElement).value })} />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="cfg-field cfg-field-enabled">
|
||||||
|
<label>Enabled</label>
|
||||||
|
<label class="cfg-rule-enable" title="When enabled, this rule runs every time an instance of the bound set is saved or applied">
|
||||||
|
<input type="checkbox" checked={ruleEnabled(working)}
|
||||||
|
onChange={(e) => patch({ enabled: (e.target as HTMLInputElement).checked })} />
|
||||||
|
<span>{ruleEnabled(working) ? 'Runs on save/apply' : 'Disabled (not run)'}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="cfg-section-head"><span>CUE source</span></div>
|
<div class="cfg-section-head"><span>CUE source</span></div>
|
||||||
@@ -1635,6 +1690,8 @@ function RulesManager({ registerGuard }: { registerGuard: (fn: CloseGuard | null
|
|||||||
|
|
||||||
<RuleCheckView result={check} />
|
<RuleCheckView result={check} />
|
||||||
|
|
||||||
|
{preview && <RulePreviewView preview={preview} onClose={() => setPreview(null)} />}
|
||||||
|
|
||||||
<VersionPanel kind="rules" id={working.id}
|
<VersionPanel kind="rules" id={working.id}
|
||||||
viewing={viewingVersion} dirty={dirty} reloadKey={verReload}
|
viewing={viewingVersion} dirty={dirty} reloadKey={verReload}
|
||||||
onView={viewVersion}
|
onView={viewVersion}
|
||||||
@@ -1697,6 +1754,54 @@ function RuleCheckView({ result }: { result: RuleResult | null }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RulePreviewView shows the outcome of running a rule against a live snapshot of
|
||||||
|
// the bound set's signals: the captured input values on the left and the
|
||||||
|
// processed output (input merged with rule-derived values) on the right, plus
|
||||||
|
// any compile error / violations. Nothing is persisted.
|
||||||
|
function RulePreviewView({ preview, onClose }: { preview: RulePreview; onClose: () => void }) {
|
||||||
|
const input: Record<string, any> = {};
|
||||||
|
for (const e of preview.snapshot.entries) if (e.ok) input[e.key] = e.value;
|
||||||
|
const output = { ...input, ...(preview.result.transformed ?? {}) };
|
||||||
|
const failed = preview.snapshot.entries.filter(e => !e.ok);
|
||||||
|
return (
|
||||||
|
<div class="cfg-rule-preview">
|
||||||
|
<div class="cfg-rule-preview-head">
|
||||||
|
<b>Live preview</b>
|
||||||
|
<span class="hint">{preview.snapshot.captured} captured · {preview.snapshot.failed} failed</span>
|
||||||
|
<button class="cl-mini-btn" title="Close preview" onClick={onClose}>✕</button>
|
||||||
|
</div>
|
||||||
|
{preview.result.compileError && (
|
||||||
|
<div class="cfg-rule-check cfg-rule-check-err"><b>Compile error:</b> {preview.result.compileError}</div>
|
||||||
|
)}
|
||||||
|
{!preview.result.compileError && !preview.result.ok && (
|
||||||
|
<div class="cfg-rule-check cfg-rule-check-err">
|
||||||
|
<b>{(preview.result.violations ?? []).length} violation(s):</b>
|
||||||
|
<ul class="cfg-rule-violations">
|
||||||
|
{(preview.result.violations ?? []).map((v, i) => (
|
||||||
|
<li key={i}>{v.path ? <code>{v.path}</code> : null} {v.message}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{failed.length > 0 && (
|
||||||
|
<div class="hint cfg-rule-preview-failed">
|
||||||
|
Could not read: {failed.map(e => `${e.key} (${e.error})`).join(', ')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div class="cfg-rule-preview-cols">
|
||||||
|
<div class="cfg-rule-preview-col">
|
||||||
|
<div class="hint">Snapshot input</div>
|
||||||
|
<pre class="cfg-json">{JSON.stringify(input, null, 2)}</pre>
|
||||||
|
</div>
|
||||||
|
<div class="cfg-rule-preview-col">
|
||||||
|
<div class="hint">Processed output{preview.result.ok ? '' : ' (would be rejected)'}</div>
|
||||||
|
<pre class="cfg-json">{JSON.stringify(output, null, 2)}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function NewRuleDialog({ sets, busy, onCancel, onCreate }: {
|
function NewRuleDialog({ sets, busy, onCancel, onCreate }: {
|
||||||
sets: Meta[]; busy: boolean; onCancel: () => void; onCreate: (name: string, setId: string) => void;
|
sets: Meta[]; busy: boolean; onCancel: () => void; onCreate: (name: string, setId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
|
|||||||
+347
-26
@@ -4,6 +4,10 @@ import SignalPicker, { SignalOption } from './SignalPicker';
|
|||||||
import LuaEditor from './LuaEditor';
|
import LuaEditor from './LuaEditor';
|
||||||
import { checkExpr } from './lib/expr';
|
import { checkExpr } from './lib/expr';
|
||||||
import { VersionTree, DiffViewer } from './VersionHistory';
|
import { VersionTree, DiffViewer } from './VersionHistory';
|
||||||
|
import { groupBounds, groupContaining, genGroupId, pruneGroups, type NodeGroup, type Rect } from './lib/nodeGroups';
|
||||||
|
import { useFlowZoom, type Box } from './lib/flowZoom';
|
||||||
|
import { formatBadge, badgeTitle, nodeDebugClass, type DebugSnapshot } from './lib/flowDebug';
|
||||||
|
import { wsClient } from './lib/ws';
|
||||||
|
|
||||||
// ── Types (mirror internal/controllogic/model.go) ────────────────────────────
|
// ── Types (mirror internal/controllogic/model.go) ────────────────────────────
|
||||||
|
|
||||||
@@ -41,6 +45,7 @@ interface CLGraph {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
nodes: CLNode[];
|
nodes: CLNode[];
|
||||||
wires: CLWire[];
|
wires: CLWire[];
|
||||||
|
groups?: NodeGroup[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DataSource { name: string; }
|
interface DataSource { name: string; }
|
||||||
@@ -59,6 +64,8 @@ const REM = (() => {
|
|||||||
return parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
|
return parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
|
||||||
})();
|
})();
|
||||||
const NODE_W = 11.5 * REM;
|
const NODE_W = 11.5 * REM;
|
||||||
|
const CANVAS_W = 125 * REM; // logical canvas size (matches .flow-canvas-inner)
|
||||||
|
const CANVAS_H = 87.5 * REM;
|
||||||
const PORT_TOP = 2 * REM;
|
const PORT_TOP = 2 * REM;
|
||||||
const PORT_GAP = 1.375 * REM;
|
const PORT_GAP = 1.375 * REM;
|
||||||
const PORT_R = 0.375 * REM;
|
const PORT_R = 0.375 * REM;
|
||||||
@@ -67,6 +74,11 @@ const PORT_R = 0.375 * REM;
|
|||||||
// offsets so wires meet the port centers, not their top edges.
|
// offsets so wires meet the port centers, not their top edges.
|
||||||
const BORDER = 1;
|
const BORDER = 1;
|
||||||
const BORDER_TOP = 3;
|
const BORDER_TOP = 3;
|
||||||
|
// Group frame geometry.
|
||||||
|
const GROUP_PAD = 0.65 * REM;
|
||||||
|
const GROUP_HEADER = 1.65 * REM;
|
||||||
|
const GROUP_BOX_W = NODE_W;
|
||||||
|
const GROUP_BOX_H = GROUP_HEADER + 1.1 * REM;
|
||||||
|
|
||||||
interface PaletteEntry { kind: CLNodeKind; label: string; params: Record<string, string>; }
|
interface PaletteEntry { kind: CLNodeKind; label: string; params: Record<string, string>; }
|
||||||
|
|
||||||
@@ -354,7 +366,7 @@ export default function ControlLogicEditor({ onClose }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="cl-editor-main">
|
<div class="cl-editor-main">
|
||||||
<FlowEditor graph={graph}
|
<FlowEditor graph={graph}
|
||||||
onChange={(g) => { setGraph({ ...graph, nodes: g.nodes, wires: g.wires }); setDirty(true); }} />
|
onChange={(g) => { setGraph({ ...graph, nodes: g.nodes, wires: g.wires, groups: g.groups }); setDirty(true); }} />
|
||||||
{showVersions && (
|
{showVersions && (
|
||||||
<div class="ver-pane">
|
<div class="ver-pane">
|
||||||
<div class="ver-pane-head">Version history</div>
|
<div class="ver-pane-head">Version history</div>
|
||||||
@@ -405,24 +417,85 @@ export default function ControlLogicEditor({ onClose }: Props) {
|
|||||||
|
|
||||||
function FlowEditor({ graph, onChange }: {
|
function FlowEditor({ graph, onChange }: {
|
||||||
graph: CLGraph;
|
graph: CLGraph;
|
||||||
onChange: (g: { nodes: CLNode[]; wires: CLWire[] }) => void;
|
onChange: (g: { nodes: CLNode[]; wires: CLWire[]; groups?: NodeGroup[] }) => void;
|
||||||
}) {
|
}) {
|
||||||
const nodes = graph.nodes;
|
const nodes = graph.nodes;
|
||||||
const wires = graph.wires;
|
const wires = graph.wires;
|
||||||
|
const groups = graph.groups ?? [];
|
||||||
|
|
||||||
const [selectedNode, setSelectedNode] = useState<string | null>(null);
|
const [selectedNode, setSelectedNode] = useState<string | null>(null);
|
||||||
const [selectedWire, setSelectedWire] = useState<number | null>(null);
|
const [selectedWire, setSelectedWire] = useState<number | null>(null);
|
||||||
|
const [selSet, setSelSet] = useState<Set<string>>(new Set());
|
||||||
|
const [selectedGroup, setSelectedGroup] = useState<string | null>(null);
|
||||||
const [dataSources, setDataSources] = useState<string[]>([]);
|
const [dataSources, setDataSources] = useState<string[]>([]);
|
||||||
const [dsSignals, setDsSignals] = useState<Record<string, string[]>>({});
|
const [dsSignals, setDsSignals] = useState<Record<string, string[]>>({});
|
||||||
const [configInstances, setConfigInstances] = useState<{ id: string; name: string }[]>([]);
|
const [configInstances, setConfigInstances] = useState<{ id: string; name: string }[]>([]);
|
||||||
const [configSets, setConfigSets] = useState<{ id: string; name: string }[]>([]);
|
const [configSets, setConfigSets] = useState<{ id: string; name: string }[]>([]);
|
||||||
|
|
||||||
const canvasRef = useRef<HTMLDivElement>(null);
|
const canvasRef = useRef<HTMLDivElement>(null);
|
||||||
const dragNode = useRef<{ id: string; dx: number; dy: number } | null>(null);
|
const 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 [pendingWire, setPendingWire] = useState<{ from: string; port: string; x: number; y: number } | null>(null);
|
||||||
const pendingRef = useRef(pendingWire);
|
const pendingRef = useRef(pendingWire);
|
||||||
pendingRef.current = pendingWire;
|
pendingRef.current = pendingWire;
|
||||||
|
|
||||||
|
// Undo/redo history + clipboard, scoped to this graph. `graphRef` always holds
|
||||||
|
// the latest {nodes,wires,groups} so the ref-based stacks read a stable value
|
||||||
|
// across renders. Snapshots are pushed right before each discrete edit.
|
||||||
|
type Snapshot = { nodes: CLNode[]; wires: CLWire[]; groups: NodeGroup[] };
|
||||||
|
const graphRef = useRef<Snapshot>({ nodes, wires, groups });
|
||||||
|
graphRef.current = { nodes, wires, groups };
|
||||||
|
const undoStack = useRef<Snapshot[]>([]);
|
||||||
|
const redoStack = useRef<Snapshot[]>([]);
|
||||||
|
const clipboard = useRef<{ nodes: CLNode[]; wires: CLWire[] } | null>(null);
|
||||||
|
const [, setHistTick] = useState(0); // re-render so toolbar enabled-state updates
|
||||||
|
const bump = () => setHistTick(t => t + 1);
|
||||||
|
const canUndo = undoStack.current.length > 0;
|
||||||
|
const canRedo = redoStack.current.length > 0;
|
||||||
|
|
||||||
|
// Live debug: observe the running graph ('live') or dry-run the unsaved edits
|
||||||
|
// ('simulate'). Node events arrive over the WS; debugStates holds the latest
|
||||||
|
// value/timestamp per node and debugSnap is the decayed view the nodes render.
|
||||||
|
const [debug, setDebug] = useState(false);
|
||||||
|
const [debugMode, setDebugMode] = useState<'live' | 'simulate'>('live');
|
||||||
|
const [debugSnap, setDebugSnap] = useState<DebugSnapshot>(new Map());
|
||||||
|
const debugStates = useRef<Map<string, { value: number; hasValue: boolean; ts: number }>>(new Map());
|
||||||
|
|
||||||
|
// Debug session: register the node-event listener, (live) subscribe to the
|
||||||
|
// running graph, and rebuild the rendered snapshot on a 250 ms tick so node
|
||||||
|
// highlights fade ~0.8 s after a node last fired. The simulate subscription is
|
||||||
|
// handled by the separate effect below so it can re-send on each edit.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!debug || !graph) return;
|
||||||
|
debugStates.current = new Map();
|
||||||
|
setDebugSnap(new Map());
|
||||||
|
const off = wsClient.onDebugNode((ev) => {
|
||||||
|
debugStates.current.set(ev.nodeId, { value: ev.value, hasValue: ev.hasValue, ts: Date.now() });
|
||||||
|
});
|
||||||
|
if (debugMode === 'live') wsClient.debugSubscribeLive(graph.id);
|
||||||
|
const iv = setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
const snap: DebugSnapshot = new Map();
|
||||||
|
for (const [id, s] of debugStates.current) {
|
||||||
|
snap.set(id, { value: s.hasValue ? s.value : undefined, type: 'scalar', active: now - s.ts < 800 });
|
||||||
|
}
|
||||||
|
setDebugSnap(snap);
|
||||||
|
}, 250);
|
||||||
|
return () => { off(); wsClient.debugUnsubscribe(); clearInterval(iv); };
|
||||||
|
}, [debug, debugMode, graph?.id]);
|
||||||
|
|
||||||
|
// Simulate mode: (re)compile the unsaved graph server-side on toggle and after
|
||||||
|
// each edit (debounced) so the sandbox always reflects what's on screen.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!debug || debugMode !== 'simulate' || !graph) return;
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
wsClient.debugSubscribeSimulate({ id: graph.id, name: graph.name, nodes, wires, groups });
|
||||||
|
}, 400);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [debug, debugMode, nodes, wires, groups, graph?.id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/api/v1/datasources')
|
fetch('/api/v1/datasources')
|
||||||
.then(r => r.ok ? r.json() : [])
|
.then(r => r.ok ? r.json() : [])
|
||||||
@@ -482,9 +555,46 @@ function FlowEditor({ graph, onChange }: {
|
|||||||
if (selected?.kind === 'action.write') loadSignals(splitRef(selected.params.target ?? '').ds);
|
if (selected?.kind === 'action.write') loadSignals(splitRef(selected.params.target ?? '').ds);
|
||||||
}, [selectedNode, selected?.kind]);
|
}, [selectedNode, selected?.kind]);
|
||||||
|
|
||||||
function emit(next: { nodes: CLNode[]; wires: CLWire[] }) { onChange(next); }
|
// ── History ────────────────────────────────────────────────────────────────
|
||||||
function setNodes(next: CLNode[]) { emit({ nodes: next, wires }); }
|
// Push the current graph onto the undo stack (clearing redo). Call right
|
||||||
function setWires(next: CLWire[]) { emit({ nodes, wires: next }); }
|
// before a discrete edit; for continuous gestures (node drag) call once.
|
||||||
|
function pushUndo() {
|
||||||
|
undoStack.current = [...undoStack.current.slice(-49), graphRef.current];
|
||||||
|
redoStack.current = [];
|
||||||
|
bump();
|
||||||
|
}
|
||||||
|
function undo() {
|
||||||
|
if (undoStack.current.length === 0) return;
|
||||||
|
const prev = undoStack.current[undoStack.current.length - 1];
|
||||||
|
redoStack.current = [graphRef.current, ...redoStack.current];
|
||||||
|
undoStack.current = undoStack.current.slice(0, -1);
|
||||||
|
setSelectedNode(null); setSelectedWire(null); setSelectedGroup(null); setSelSet(new Set());
|
||||||
|
onChange(prev);
|
||||||
|
bump();
|
||||||
|
}
|
||||||
|
function redo() {
|
||||||
|
if (redoStack.current.length === 0) return;
|
||||||
|
const next = redoStack.current[0];
|
||||||
|
undoStack.current = [...undoStack.current, graphRef.current];
|
||||||
|
redoStack.current = redoStack.current.slice(1);
|
||||||
|
setSelectedNode(null); setSelectedWire(null); setSelectedGroup(null); setSelSet(new Set());
|
||||||
|
onChange(next);
|
||||||
|
bump();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply a graph change, recording an undo entry unless `record` is false (used
|
||||||
|
// for the intermediate frames of a node drag, which record once on first move).
|
||||||
|
function emit(next: { nodes?: CLNode[]; wires?: CLWire[]; groups?: NodeGroup[] }, record = true) {
|
||||||
|
if (record) pushUndo();
|
||||||
|
onChange({
|
||||||
|
nodes: next.nodes ?? nodes,
|
||||||
|
wires: next.wires ?? wires,
|
||||||
|
groups: next.groups ?? groups,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function setNodes(next: CLNode[], record = true) { emit({ nodes: next }, record); }
|
||||||
|
function setWires(next: CLWire[]) { emit({ wires: next }); }
|
||||||
|
function setGroups(next: NodeGroup[]) { emit({ groups: next }); }
|
||||||
|
|
||||||
function addNode(entry: PaletteEntry, x?: number, y?: number) {
|
function addNode(entry: PaletteEntry, x?: number, y?: number) {
|
||||||
const node: CLNode = {
|
const node: CLNode = {
|
||||||
@@ -494,19 +604,49 @@ function FlowEditor({ graph, onChange }: {
|
|||||||
y: y ?? 40 + (nodes.length % 5) * 30,
|
y: y ?? 40 + (nodes.length % 5) * 30,
|
||||||
params: { ...entry.params },
|
params: { ...entry.params },
|
||||||
};
|
};
|
||||||
emit({ nodes: [...nodes, node], wires });
|
emit({ nodes: [...nodes, node] });
|
||||||
setSelectedNode(node.id);
|
setSelectedNode(node.id);
|
||||||
setSelectedWire(null);
|
setSelectedWire(null);
|
||||||
|
setSelSet(new Set([node.id]));
|
||||||
|
setSelectedGroup(null);
|
||||||
}
|
}
|
||||||
function patchParams(id: string, patch: Record<string, string>) {
|
function patchParams(id: string, patch: Record<string, string>) {
|
||||||
setNodes(nodes.map(n => (n.id === id ? { ...n, params: { ...n.params, ...patch } } : n)));
|
setNodes(nodes.map(n => (n.id === id ? { ...n, params: { ...n.params, ...patch } } : n)));
|
||||||
}
|
}
|
||||||
function moveNode(id: string, x: number, y: number) {
|
function moveNodes(pos: Map<string, { x: number; y: number }>, record = false) {
|
||||||
setNodes(nodes.map(n => (n.id === id ? { ...n, x, y } : n)));
|
setNodes(nodes.map(n => (pos.has(n.id) ? { ...n, ...pos.get(n.id)! } : n)), record);
|
||||||
}
|
}
|
||||||
function deleteNode(id: string) {
|
function deleteNode(id: string) {
|
||||||
emit({ nodes: nodes.filter(n => n.id !== id), wires: wires.filter(w => w.from !== id && w.to !== id) });
|
const nextNodes = nodes.filter(n => n.id !== id);
|
||||||
|
emit({
|
||||||
|
nodes: nextNodes,
|
||||||
|
wires: wires.filter(w => w.from !== id && w.to !== id),
|
||||||
|
groups: pruneGroups(groups, new Set(nextNodes.map(n => n.id))),
|
||||||
|
});
|
||||||
if (selectedNode === id) setSelectedNode(null);
|
if (selectedNode === id) setSelectedNode(null);
|
||||||
|
if (selSet.has(id)) { const s = new Set(selSet); s.delete(id); setSelSet(s); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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)));
|
||||||
}
|
}
|
||||||
function addWire(from: string, port: string, to: string) {
|
function addWire(from: string, port: string, to: string) {
|
||||||
if (from === to) return;
|
if (from === to) return;
|
||||||
@@ -518,26 +658,89 @@ function FlowEditor({ graph, onChange }: {
|
|||||||
if (selectedWire === idx) setSelectedWire(null);
|
if (selectedWire === idx) setSelectedWire(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Clipboard ────────────────────────────────────────────────────────────
|
||||||
|
// Copy the current selection (multi-select aware) plus any wires fully
|
||||||
|
// internal to it, with params deep-cloned.
|
||||||
|
function copySelection() {
|
||||||
|
const cur = graphRef.current;
|
||||||
|
const ids = selSet.size > 0 ? selSet : (selectedNode ? new Set([selectedNode]) : new Set<string>());
|
||||||
|
if (ids.size === 0) return;
|
||||||
|
const picked = cur.nodes.filter(n => ids.has(n.id));
|
||||||
|
if (picked.length === 0) return;
|
||||||
|
const innerWires = cur.wires.filter(w => ids.has(w.from) && ids.has(w.to));
|
||||||
|
clipboard.current = {
|
||||||
|
nodes: picked.map(n => ({ ...n, params: { ...n.params } })),
|
||||||
|
wires: innerWires.map(w => ({ ...w })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function pasteClipboard() {
|
||||||
|
const clip = clipboard.current;
|
||||||
|
if (!clip || clip.nodes.length === 0) return;
|
||||||
|
const idMap = new Map<string, string>();
|
||||||
|
const cur = graphRef.current;
|
||||||
|
const newNodes = clip.nodes.map(n => {
|
||||||
|
const id = genId();
|
||||||
|
idMap.set(n.id, id);
|
||||||
|
return { ...n, id, x: n.x + 30, y: n.y + 30, params: { ...n.params } };
|
||||||
|
});
|
||||||
|
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)! }));
|
||||||
|
emit({ nodes: [...cur.nodes, ...newNodes], wires: [...cur.wires, ...newWires] });
|
||||||
|
setSelSet(new Set(newNodes.map(n => n.id)));
|
||||||
|
setSelectedNode(newNodes.length === 1 ? newNodes[0].id : null);
|
||||||
|
setSelectedWire(null);
|
||||||
|
setSelectedGroup(null);
|
||||||
|
}
|
||||||
|
|
||||||
function toCanvas(e: MouseEvent): { x: number; y: number } {
|
function toCanvas(e: MouseEvent): { x: number; y: number } {
|
||||||
const el = canvasRef.current!;
|
const el = canvasRef.current!;
|
||||||
const rect = el.getBoundingClientRect();
|
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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
function startNodeDrag(e: MouseEvent, node: CLNode) {
|
function beginDrag(e: MouseEvent, ids: string[]) {
|
||||||
e.stopPropagation();
|
|
||||||
const p = toCanvas(e);
|
const p = toCanvas(e);
|
||||||
dragNode.current = { id: node.id, dx: p.x - node.x, dy: p.y - node.y };
|
const starts = new Map<string, { x: number; y: number }>();
|
||||||
setSelectedNode(node.id);
|
for (const id of ids) {
|
||||||
setSelectedWire(null);
|
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('mousemove', onNodeDragMove);
|
||||||
window.addEventListener('mouseup', onNodeDragUp);
|
window.addEventListener('mouseup', onNodeDragUp);
|
||||||
}
|
}
|
||||||
|
function startNodeDrag(e: MouseEvent, node: CLNode) {
|
||||||
|
e.stopPropagation();
|
||||||
|
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);
|
||||||
|
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) {
|
function onNodeDragMove(e: MouseEvent) {
|
||||||
const d = dragNode.current;
|
const d = dragNode.current;
|
||||||
if (!d) return;
|
if (!d) return;
|
||||||
const p = toCanvas(e);
|
const p = toCanvas(e);
|
||||||
moveNode(d.id, Math.max(0, p.x - d.dx), Math.max(0, p.y - d.dy));
|
// Record a single undo entry per drag (on the first move, so a plain
|
||||||
|
// click-to-select doesn't create a spurious history step).
|
||||||
|
const record = !d.pushed;
|
||||||
|
d.pushed = true;
|
||||||
|
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() {
|
function onNodeDragUp() {
|
||||||
dragNode.current = null;
|
dragNode.current = null;
|
||||||
@@ -592,13 +795,20 @@ function FlowEditor({ graph, onChange }: {
|
|||||||
function onKey(e: KeyboardEvent) {
|
function onKey(e: KeyboardEvent) {
|
||||||
const t = e.target as Element;
|
const t = e.target as Element;
|
||||||
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT')) return;
|
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT')) return;
|
||||||
|
const mod = e.ctrlKey || e.metaKey;
|
||||||
|
if (mod && e.key.toLowerCase() === 'z' && !e.shiftKey) { e.preventDefault(); undo(); return; }
|
||||||
|
if (mod && (e.key.toLowerCase() === 'y' || (e.key.toLowerCase() === 'z' && e.shiftKey))) { e.preventDefault(); redo(); return; }
|
||||||
|
if (mod && e.key.toLowerCase() === 'c') { e.preventDefault(); copySelection(); return; }
|
||||||
|
if (mod && e.key.toLowerCase() === 'v') { e.preventDefault(); pasteClipboard(); return; }
|
||||||
|
if (e.key.toLowerCase() === 'g' && selSet.size >= 1) { e.preventDefault(); groupSelection(); return; }
|
||||||
if (e.key !== 'Delete' && e.key !== 'Backspace') 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);
|
else if (selectedNode) deleteNode(selectedNode);
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', onKey);
|
window.addEventListener('keydown', onKey);
|
||||||
return () => window.removeEventListener('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]));
|
const byId = new Map(nodes.map(n => [n.id, n]));
|
||||||
|
|
||||||
@@ -607,9 +817,68 @@ function FlowEditor({ graph, onChange }: {
|
|||||||
patchParams(id, { [field]: `${cur}{${ds}:${sig}}` });
|
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[] => 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);
|
||||||
|
|
||||||
|
const resolveOut = (n: CLNode, 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: CLNode) => {
|
||||||
|
const box = collapsedBoxOf(n.id);
|
||||||
|
return box ? { x: box.x, y: box.y + box.h / 2 } : inAnchor(n);
|
||||||
|
};
|
||||||
|
const wireHidden = (from: string, to: string) => {
|
||||||
|
const gf = groupContaining(groups, from), gt = groupContaining(groups, to);
|
||||||
|
return !!gf && gf === gt && gf.collapsed;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flow-editor">
|
<div class="flow-editor">
|
||||||
<div class="flow-palette">
|
<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={debugMode === 'live'
|
||||||
|
? 'Live debug — observe the running graph\u2019s node values'
|
||||||
|
: 'Live debug — dry-run the unsaved edits (no real writes)'}>◉</button>
|
||||||
|
{debug && (
|
||||||
|
<button class="toolbar-btn flow-debug-mode" onClick={() => setDebugMode(m => m === 'live' ? 'simulate' : 'live')}
|
||||||
|
title="Switch between observing the running graph (Live) and dry-running the unsaved edits (Simulate)">
|
||||||
|
{debugMode === 'live' ? 'Live' : 'Sim'}
|
||||||
|
</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>
|
<div class="flow-palette-title">Triggers</div>
|
||||||
{PALETTE.filter(e => category(e.kind) === 'trigger').map(paletteBtn)}
|
{PALETTE.filter(e => category(e.kind) === 'trigger').map(paletteBtn)}
|
||||||
<div class="flow-palette-title">Logic</div>
|
<div class="flow-palette-title">Logic</div>
|
||||||
@@ -623,17 +892,60 @@ function FlowEditor({ graph, onChange }: {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flow-canvas" ref={canvasRef}
|
<div class="flow-canvas" ref={canvasRef}
|
||||||
onMouseDown={() => { setSelectedNode(null); setSelectedWire(null); }}
|
onMouseDown={() => { setSelectedNode(null); setSelectedWire(null); setSelectedGroup(null); setSelSet(new Set()); }}
|
||||||
onDragOver={onCanvasDragOver}
|
onDragOver={onCanvasDragOver}
|
||||||
onDrop={onCanvasDrop}>
|
onDrop={onCanvasDrop}>
|
||||||
<div class="flow-canvas-inner">
|
<div class="flow-canvas-inner" style={innerStyle}>
|
||||||
|
<div class="flow-canvas-zoom" style={zoomStyle}>
|
||||||
|
{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">
|
<svg class="flow-wires">
|
||||||
{wires.map((w, idx) => {
|
{wires.map((w, idx) => {
|
||||||
const a = byId.get(w.from);
|
const a = byId.get(w.from);
|
||||||
const b = byId.get(w.to);
|
const b = byId.get(w.to);
|
||||||
if (!a || !b) return null;
|
if (!a || !b) return null;
|
||||||
const p1 = outAnchor(a, w.fromPort ?? 'out');
|
if (wireHidden(w.from, w.to)) return null;
|
||||||
const p2 = inAnchor(b);
|
const p1 = resolveOut(a, w.fromPort ?? 'out');
|
||||||
|
const p2 = resolveIn(b);
|
||||||
return (
|
return (
|
||||||
<path key={idx}
|
<path key={idx}
|
||||||
class={`flow-wire${selectedWire === idx ? ' flow-wire-selected' : ''}`}
|
class={`flow-wire${selectedWire === idx ? ' flow-wire-selected' : ''}`}
|
||||||
@@ -649,12 +961,19 @@ function FlowEditor({ graph, onChange }: {
|
|||||||
})()}
|
})()}
|
||||||
</svg>
|
</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}
|
<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;`}
|
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)}
|
onMouseDown={(e) => startNodeDrag(e, node)}
|
||||||
|
onDblClick={() => { if (firable) wsClient.debugFireTrigger(node.id); }}
|
||||||
onMouseUp={() => { if (pendingRef.current) finishWire(node); }}>
|
onMouseUp={() => { if (pendingRef.current) finishWire(node); }}>
|
||||||
|
{dbg && <span class="flow-node-badge" title={badgeTitle(dbg)}>{formatBadge(dbg)}</span>}
|
||||||
<div class="flow-node-header">
|
<div class="flow-node-header">
|
||||||
<span class="flow-node-title">{KIND_LABEL[node.kind]}</span>
|
<span class="flow-node-title">{KIND_LABEL[node.kind]}</span>
|
||||||
<button class="flow-node-del" title="Delete node"
|
<button class="flow-node-del" title="Delete node"
|
||||||
@@ -681,7 +1000,9 @@ function FlowEditor({ graph, onChange }: {
|
|||||||
</Fragment>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+66
-10
@@ -9,19 +9,33 @@ import BarV from './widgets/BarV';
|
|||||||
import Led from './widgets/Led';
|
import Led from './widgets/Led';
|
||||||
import MultiLed from './widgets/MultiLed';
|
import MultiLed from './widgets/MultiLed';
|
||||||
import SetValue from './widgets/SetValue';
|
import SetValue from './widgets/SetValue';
|
||||||
|
import Toggle from './widgets/Toggle';
|
||||||
import Button from './widgets/Button';
|
import Button from './widgets/Button';
|
||||||
import PlotWidget from './widgets/PlotWidget';
|
import PlotWidget from './widgets/PlotWidget';
|
||||||
import ImageWidget from './widgets/ImageWidget';
|
import ImageWidget from './widgets/ImageWidget';
|
||||||
import LinkWidget from './widgets/LinkWidget';
|
import LinkWidget from './widgets/LinkWidget';
|
||||||
|
import Container from './widgets/Container';
|
||||||
|
import TableWidget from './widgets/TableWidget';
|
||||||
import WidgetTypePicker from './WidgetTypePicker';
|
import WidgetTypePicker from './WidgetTypePicker';
|
||||||
|
import { centreInside, widgetTab, tabPanes, withContainedWidgets } from './lib/containers';
|
||||||
|
|
||||||
const COMPONENTS: Record<string, any> = {
|
const COMPONENTS: Record<string, any> = {
|
||||||
textview: TextView, textlabel: TextLabel, gauge: Gauge,
|
textview: TextView, textlabel: TextLabel, gauge: Gauge,
|
||||||
barh: BarH, barv: BarV, led: Led, multiled: MultiLed,
|
barh: BarH, barv: BarV, led: Led, multiled: MultiLed,
|
||||||
setvalue: SetValue, button: Button, plot: PlotWidget,
|
setvalue: SetValue, toggle: Toggle, button: Button, plot: PlotWidget,
|
||||||
image: ImageWidget, link: LinkWidget,
|
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]> = {
|
export const DEFAULT_SIZES: Record<string, [number, number]> = {
|
||||||
textview: [200, 50],
|
textview: [200, 50],
|
||||||
textlabel: [150, 36],
|
textlabel: [150, 36],
|
||||||
@@ -31,17 +45,20 @@ export const DEFAULT_SIZES: Record<string, [number, number]> = {
|
|||||||
led: [60, 60],
|
led: [60, 60],
|
||||||
multiled: [200, 80],
|
multiled: [200, 80],
|
||||||
setvalue: [200, 60],
|
setvalue: [200, 60],
|
||||||
|
toggle: [140, 50],
|
||||||
button: [120, 50],
|
button: [120, 50],
|
||||||
plot: [400, 200],
|
plot: [400, 200],
|
||||||
image: [200, 150],
|
image: [200, 150],
|
||||||
link: [140, 50],
|
link: [140, 50],
|
||||||
configselect: [220, 50],
|
configselect: [220, 50],
|
||||||
|
container: [320, 220],
|
||||||
|
table: [280, 160],
|
||||||
};
|
};
|
||||||
|
|
||||||
// Widget types that support multiple signals (signal can be appended)
|
// 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)
|
// 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 {
|
interface PickerState {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@@ -132,6 +149,17 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna
|
|||||||
|
|
||||||
const [signalDropMenu, setSignalDropMenu] = useState<SignalDropMenuState | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
function onMouseMove(e: MouseEvent) {
|
function onMouseMove(e: MouseEvent) {
|
||||||
const grid = snapGridRef.current;
|
const grid = snapGridRef.current;
|
||||||
@@ -248,8 +276,10 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna
|
|||||||
|
|
||||||
const { cx, cy } = getCanvasPos(e);
|
const { cx, cy } = getCanvasPos(e);
|
||||||
|
|
||||||
// Check if drop lands on an existing widget
|
// Check if drop lands on an existing widget. Ignore container panes so a
|
||||||
const hit = iface.widgets.find(w =>
|
// 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
|
cx >= w.x && cx <= w.x + w.w && cy >= w.y && cy <= w.y + w.h
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -303,6 +333,12 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna
|
|||||||
signals: [picker.signal],
|
signals: [picker.signal],
|
||||||
options: {},
|
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]);
|
onChange([...iface.widgets, newWidget]);
|
||||||
onSelect([newWidget.id]);
|
onSelect([newWidget.id]);
|
||||||
setPicker(p => ({ ...p, visible: false }));
|
setPicker(p => ({ ...p, visible: false }));
|
||||||
@@ -325,7 +361,11 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna
|
|||||||
|
|
||||||
if (ctrl && isSelected) return;
|
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 = {
|
dragRef.current = {
|
||||||
startMouseX: e.clientX,
|
startMouseX: e.clientX,
|
||||||
startMouseY: e.clientY,
|
startMouseY: e.clientY,
|
||||||
@@ -355,6 +395,15 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna
|
|||||||
onSelect(selectedIds.filter(s => s !== id));
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
class="edit-canvas-container"
|
class="edit-canvas-container"
|
||||||
@@ -365,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;`}>
|
<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];
|
const Comp = COMPONENTS[widget.type];
|
||||||
return Comp
|
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"
|
<div key={widget.id} class="unknown-widget"
|
||||||
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}>
|
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}>
|
||||||
@@ -377,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);
|
const isSelected = selectedIds.includes(widget.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
+89
-1
@@ -11,6 +11,7 @@ import PropertiesPane from './PropertiesPane';
|
|||||||
import HelpModal from './HelpModal';
|
import HelpModal from './HelpModal';
|
||||||
import ZoomControl from './ZoomControl';
|
import ZoomControl from './ZoomControl';
|
||||||
import { useAuth, canWrite } from './lib/auth';
|
import { useAuth, canWrite } from './lib/auth';
|
||||||
|
import { withContainedWidgets } from './lib/containers';
|
||||||
import { VersionTree, DiffViewer } from './VersionHistory';
|
import { VersionTree, DiffViewer } from './VersionHistory';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -22,6 +23,31 @@ function blankInterface(): Interface {
|
|||||||
return { id: '', name: 'New Interface', version: 1, w: 1200, h: 800, widgets: [] };
|
return { id: '', name: 'New Interface', version: 1, w: 1200, h: 800, widgets: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 ──────────────────────────────────────────────
|
// ── Align / distribute helpers ──────────────────────────────────────────────
|
||||||
|
|
||||||
function alignWidgets(widgets: Widget[], ids: string[], mode: string): Widget[] {
|
function alignWidgets(widgets: Widget[], ids: string[], mode: string): Widget[] {
|
||||||
@@ -77,6 +103,8 @@ const STATIC_WIDGET_TYPES = [
|
|||||||
{ type: 'image', label: 'Image' },
|
{ type: 'image', label: 'Image' },
|
||||||
{ type: 'link', label: 'Link' },
|
{ type: 'link', label: 'Link' },
|
||||||
{ type: 'configselect', label: 'Config Selector' },
|
{ type: 'configselect', label: 'Config Selector' },
|
||||||
|
{ type: 'container', label: 'Container Pane' },
|
||||||
|
{ type: 'table', label: 'Table' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function EditMode({ initial, onDone }: Props) {
|
export default function EditMode({ initial, onDone }: Props) {
|
||||||
@@ -90,6 +118,7 @@ export default function EditMode({ initial, onDone }: Props) {
|
|||||||
const [snapGrid, setSnapGrid] = useState(10);
|
const [snapGrid, setSnapGrid] = useState(10);
|
||||||
const [showSnapGrid, setShowSnapGrid] = useState(true);
|
const [showSnapGrid, setShowSnapGrid] = useState(true);
|
||||||
const [showInsertMenu, setShowInsertMenu] = useState(false);
|
const [showInsertMenu, setShowInsertMenu] = useState(false);
|
||||||
|
const [showGroupMenu, setShowGroupMenu] = useState(false);
|
||||||
const [showHelp, setShowHelp] = useState(false);
|
const [showHelp, setShowHelp] = useState(false);
|
||||||
const [helpSection, setHelpSection] = useState('edit');
|
const [helpSection, setHelpSection] = useState('edit');
|
||||||
const [leftW, setLeftW] = useState(220);
|
const [leftW, setLeftW] = useState(220);
|
||||||
@@ -195,6 +224,13 @@ export default function EditMode({ initial, onDone }: Props) {
|
|||||||
setDirty(true);
|
setDirty(true);
|
||||||
}, [pushUndo]);
|
}, [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) => {
|
const handleIfaceChange = useCallback((updated: Interface) => {
|
||||||
setIface(updated);
|
setIface(updated);
|
||||||
setDirty(true);
|
setDirty(true);
|
||||||
@@ -284,8 +320,10 @@ export default function EditMode({ initial, onDone }: Props) {
|
|||||||
const step = e.shiftKey ? (snapGrid || 10) * 2 : (snapGrid || 1);
|
const step = e.shiftKey ? (snapGrid || 10) * 2 : (snapGrid || 1);
|
||||||
const dx = e.key === 'ArrowLeft' ? -step : e.key === 'ArrowRight' ? step : 0;
|
const dx = e.key === 'ArrowLeft' ? -step : e.key === 'ArrowRight' ? step : 0;
|
||||||
const dy = e.key === 'ArrowUp' ? -step : e.key === 'ArrowDown' ? 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 =>
|
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]);
|
}, [selectedIds, snapGrid, undo, redo, handleWidgetsChange, openHelp, pushUndo, iface.kind, centerTab]);
|
||||||
@@ -307,6 +345,43 @@ export default function EditMode({ initial, onDone }: Props) {
|
|||||||
handleWidgetsChange(distributed);
|
handleWidgetsChange(distributed);
|
||||||
}, [iface.widgets, selectedIds, handleWidgetsChange]);
|
}, [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 ───────────────────────────────────────────────────
|
// ── Insert static widget ───────────────────────────────────────────────────
|
||||||
|
|
||||||
const insertWidget = useCallback((type: string) => {
|
const insertWidget = useCallback((type: string) => {
|
||||||
@@ -324,6 +399,8 @@ export default function EditMode({ initial, onDone }: Props) {
|
|||||||
type === 'textlabel' ? { label: 'Label' } :
|
type === 'textlabel' ? { label: 'Label' } :
|
||||||
type === 'button' ? { label: 'Button', mode: 'oneshot' } :
|
type === 'button' ? { label: 'Button', mode: 'oneshot' } :
|
||||||
type === 'configselect' ? { label: 'Config', set: '', output: '', instances: '' } :
|
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]);
|
handleWidgetsChange([...(iface.widgets || []), newWidget]);
|
||||||
@@ -565,6 +642,16 @@ export default function EditMode({ initial, onDone }: Props) {
|
|||||||
<button class="toolbar-btn icon-only" onClick={() => doDistribute('v')} title="Distribute V">↕↕</button>
|
<button class="toolbar-btn icon-only" onClick={() => doDistribute('v')} title="Distribute V">↕↕</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -659,6 +746,7 @@ export default function EditMode({ initial, onDone }: Props) {
|
|||||||
multiCount={multiSelected ? selectedIds.length : 0}
|
multiCount={multiSelected ? selectedIds.length : 0}
|
||||||
iface={iface}
|
iface={iface}
|
||||||
onChange={handleWidgetChange}
|
onChange={handleWidgetChange}
|
||||||
|
onRenameId={handleWidgetRename}
|
||||||
onIfaceChange={handleIfaceChange}
|
onIfaceChange={handleIfaceChange}
|
||||||
width={rightW}
|
width={rightW}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -15,6 +15,30 @@ interface Props {
|
|||||||
onToggleCollapse: () => void;
|
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) {
|
export default function InterfaceList({ onLoad, onSelect, onEdit, onNewPlot, onEditId, width, collapsed, onToggleCollapse }: Props) {
|
||||||
const [interfaces, setInterfaces] = useState<InterfaceListItem[]>([]);
|
const [interfaces, setInterfaces] = useState<InterfaceListItem[]>([]);
|
||||||
const [folders, setFolders] = useState<Folder[]>([]);
|
const [folders, setFolders] = useState<Folder[]>([]);
|
||||||
@@ -87,6 +111,7 @@ export default function InterfaceList({ onLoad, onSelect, onEdit, onNewPlot, onE
|
|||||||
id: String(item.id || item.ID || ''),
|
id: String(item.id || item.ID || ''),
|
||||||
name: String(item.name || item.Name || ''),
|
name: String(item.name || item.Name || ''),
|
||||||
version: Number(item.version || item.Version || 0),
|
version: Number(item.version || item.Version || 0),
|
||||||
|
kind: item.kind === 'plot' ? ('plot' as const) : undefined,
|
||||||
owner: item.owner ? String(item.owner) : '',
|
owner: item.owner ? String(item.owner) : '',
|
||||||
folder: item.folder ? String(item.folder) : '',
|
folder: item.folder ? String(item.folder) : '',
|
||||||
order: Number(item.order || 0),
|
order: Number(item.order || 0),
|
||||||
@@ -171,19 +196,21 @@ export default function InterfaceList({ onLoad, onSelect, onEdit, onNewPlot, onE
|
|||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
key={item.id}
|
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}
|
draggable={drag}
|
||||||
|
onClick={() => onSelect?.(item.id)}
|
||||||
onDragStart={drag ? (e) => { dragId.current = item.id; e.dataTransfer!.effectAllowed = 'move'; } : undefined}
|
onDragStart={drag ? (e) => { dragId.current = item.id; e.dataTransfer!.effectAllowed = 'move'; } : undefined}
|
||||||
onDragEnd={endDrag}
|
onDragEnd={endDrag}
|
||||||
onDragOver={(e) => { if (dragId.current) { e.preventDefault(); e.stopPropagation(); setDropTarget(`p:${item.id}`); } }}
|
onDragOver={(e) => { if (dragId.current) { e.preventDefault(); e.stopPropagation(); setDropTarget(`p:${item.id}`); } }}
|
||||||
onDragLeave={() => setDropTarget(t => t === `p:${item.id}` ? null : t)}
|
onDragLeave={() => setDropTarget(t => t === `p:${item.id}` ? null : t)}
|
||||||
onDrop={(e) => dropOnPanel(item, e)}
|
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.name || item.id}
|
||||||
{item.perm === 'read' && <span class="iface-badge" title="Read-only">ro</span>}
|
{item.perm === 'read' && <span class="iface-badge" title="Read-only">ro</span>}
|
||||||
</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>}
|
{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>
|
<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>}
|
{writable && <button class="icon-btn" title="Clone" onClick={() => handleClone(item.id)}>⎘</button>}
|
||||||
|
|||||||
+239
-24
@@ -3,6 +3,10 @@ import { useState, useEffect, useRef } from 'preact/hooks';
|
|||||||
import SignalPicker, { SignalOption } from './SignalPicker';
|
import SignalPicker, { SignalOption } from './SignalPicker';
|
||||||
import { checkExpr } from './lib/expr';
|
import { checkExpr } from './lib/expr';
|
||||||
import type { LogicGraph, LogicNode, LogicNodeKind, LogicWire, StateVar, Widget } from './lib/types';
|
import type { LogicGraph, LogicNode, LogicNodeKind, LogicWire, StateVar, Widget } from './lib/types';
|
||||||
|
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 DataSource { name: string; }
|
||||||
interface SignalInfo { name: string; }
|
interface SignalInfo { name: string; }
|
||||||
@@ -34,6 +38,8 @@ const REM = (() => {
|
|||||||
return parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
|
return parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
|
||||||
})();
|
})();
|
||||||
const NODE_W = 11.5 * REM; // node width
|
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_TOP = 2 * REM; // y of the first port row, relative to node top
|
||||||
const PORT_GAP = 1.375 * REM; // vertical spacing between stacked output ports
|
const PORT_GAP = 1.375 * REM; // vertical spacing between stacked output ports
|
||||||
const PORT_R = 0.375 * REM; // port radius (half the 0.75rem dot)
|
const PORT_R = 0.375 * REM; // port radius (half the 0.75rem dot)
|
||||||
@@ -42,6 +48,11 @@ const PORT_R = 0.375 * REM; // port radius (half the 0.75rem dot)
|
|||||||
// offsets so wires meet the port centers, not their top edges.
|
// offsets so wires meet the port centers, not their top edges.
|
||||||
const BORDER = 1;
|
const BORDER = 1;
|
||||||
const BORDER_TOP = 3;
|
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 {
|
interface PaletteEntry {
|
||||||
kind: LogicNodeKind;
|
kind: LogicNodeKind;
|
||||||
@@ -147,6 +158,12 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
|
|||||||
|
|
||||||
const [selectedNode, setSelectedNode] = useState<string | null>(null);
|
const [selectedNode, setSelectedNode] = useState<string | null>(null);
|
||||||
const [selectedWire, setSelectedWire] = useState<number | 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 [dataSources, setDataSources] = useState<string[]>([]);
|
||||||
const [dsSignals, setDsSignals] = useState<Record<string, string[]>>({});
|
const [dsSignals, setDsSignals] = useState<Record<string, string[]>>({});
|
||||||
@@ -154,7 +171,10 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
|
|||||||
const [configSets, setConfigSets] = useState<{ id: string; name: string }[]>([]);
|
const [configSets, setConfigSets] = useState<{ id: string; name: string }[]>([]);
|
||||||
|
|
||||||
const canvasRef = useRef<HTMLDivElement>(null);
|
const canvasRef = useRef<HTMLDivElement>(null);
|
||||||
const dragNode = useRef<{ id: string; dx: number; dy: number; pushed: boolean } | null>(null);
|
const 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 [pendingWire, setPendingWire] = useState<{ from: string; port: string; x: number; y: number } | null>(null);
|
||||||
const pendingRef = useRef(pendingWire);
|
const pendingRef = useRef(pendingWire);
|
||||||
pendingRef.current = pendingWire;
|
pendingRef.current = pendingWire;
|
||||||
@@ -172,6 +192,12 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
|
|||||||
const canUndo = undoStack.current.length > 0;
|
const canUndo = undoStack.current.length > 0;
|
||||||
const canRedo = redoStack.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(() => {
|
useEffect(() => {
|
||||||
fetch('/api/v1/datasources')
|
fetch('/api/v1/datasources')
|
||||||
.then(r => r.ok ? r.json() : [])
|
.then(r => r.ok ? r.json() : [])
|
||||||
@@ -284,6 +310,8 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
|
|||||||
undoStack.current = undoStack.current.slice(0, -1);
|
undoStack.current = undoStack.current.slice(0, -1);
|
||||||
setSelectedNode(null);
|
setSelectedNode(null);
|
||||||
setSelectedWire(null);
|
setSelectedWire(null);
|
||||||
|
setSelectedGroup(null);
|
||||||
|
setSelSet(new Set());
|
||||||
onChange(prev);
|
onChange(prev);
|
||||||
bump();
|
bump();
|
||||||
}
|
}
|
||||||
@@ -305,8 +333,9 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
|
|||||||
if (record) pushUndo();
|
if (record) pushUndo();
|
||||||
onChange(next);
|
onChange(next);
|
||||||
}
|
}
|
||||||
function setNodes(next: LogicNode[], record = true) { setGraph({ nodes: next, wires }, record); }
|
function setNodes(next: LogicNode[], record = true) { setGraph({ nodes: next, wires, ...(groups.length ? { groups } : {}) }, record); }
|
||||||
function setWires(next: LogicWire[]) { setGraph({ nodes, wires: next }); }
|
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) {
|
function addNode(entry: PaletteEntry, x?: number, y?: number) {
|
||||||
const node: LogicNode = {
|
const node: LogicNode = {
|
||||||
@@ -316,23 +345,52 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
|
|||||||
y: y ?? 40 + (nodes.length % 5) * 30,
|
y: y ?? 40 + (nodes.length % 5) * 30,
|
||||||
params: { ...entry.params },
|
params: { ...entry.params },
|
||||||
};
|
};
|
||||||
setGraph({ nodes: [...nodes, node], wires });
|
setGraph({ nodes: [...nodes, node], wires, ...(groups.length ? { groups } : {}) });
|
||||||
setSelectedNode(node.id);
|
setSelectedNode(node.id);
|
||||||
setSelectedWire(null);
|
setSelectedWire(null);
|
||||||
|
setSelSet(new Set([node.id]));
|
||||||
|
setSelectedGroup(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
function patchParams(id: string, patch: Record<string, string>) {
|
function patchParams(id: string, patch: Record<string, string>) {
|
||||||
setNodes(nodes.map(n => (n.id === id ? { ...n, params: { ...n.params, ...patch } } : n)));
|
setNodes(nodes.map(n => (n.id === id ? { ...n, params: { ...n.params, ...patch } } : n)));
|
||||||
}
|
}
|
||||||
function moveNode(id: string, x: number, y: number, record = false) {
|
// Move several nodes at once (group / multi-select drag). `pos` maps id→{x,y}.
|
||||||
setNodes(nodes.map(n => (n.id === id ? { ...n, x, y } : n)), record);
|
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) {
|
function deleteNode(id: string) {
|
||||||
|
const nextNodes = nodes.filter(n => n.id !== id);
|
||||||
|
const nextGroups = pruneGroups(groups, new Set(nextNodes.map(n => n.id)));
|
||||||
setGraph({
|
setGraph({
|
||||||
nodes: nodes.filter(n => n.id !== id),
|
nodes: nextNodes,
|
||||||
wires: wires.filter(w => w.from !== id && w.to !== id),
|
wires: wires.filter(w => w.from !== id && w.to !== id),
|
||||||
|
...(nextGroups.length ? { groups: nextGroups } : {}),
|
||||||
});
|
});
|
||||||
if (selectedNode === id) setSelectedNode(null);
|
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) {
|
function addWire(from: string, port: string, to: string) {
|
||||||
if (from === to) return;
|
if (from === to) return;
|
||||||
@@ -365,7 +423,7 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
|
|||||||
const newWires = clip.wires
|
const newWires = clip.wires
|
||||||
.filter(w => idMap.has(w.from) && idMap.has(w.to))
|
.filter(w => idMap.has(w.from) && idMap.has(w.to))
|
||||||
.map(w => ({ ...w, from: idMap.get(w.from)!, to: idMap.get(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); }
|
if (newNodes.length === 1) { setSelectedNode(newNodes[0].id); setSelectedWire(null); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -373,19 +431,44 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
|
|||||||
function toCanvas(e: MouseEvent): { x: number; y: number } {
|
function toCanvas(e: MouseEvent): { x: number; y: number } {
|
||||||
const el = canvasRef.current!;
|
const el = canvasRef.current!;
|
||||||
const rect = el.getBoundingClientRect();
|
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 ──────────────────────────────────────────────────────────
|
// ── Node dragging ──────────────────────────────────────────────────────────
|
||||||
function startNodeDrag(e: MouseEvent, node: LogicNode) {
|
// Begin dragging `ids` (one node, a multi-selection, or a whole group). All
|
||||||
e.stopPropagation();
|
// members move together, keeping their relative offsets.
|
||||||
|
function beginDrag(e: MouseEvent, ids: string[]) {
|
||||||
const p = toCanvas(e);
|
const p = toCanvas(e);
|
||||||
dragNode.current = { id: node.id, dx: p.x - node.x, dy: p.y - node.y, pushed: false };
|
const starts = new Map<string, { x: number; y: number }>();
|
||||||
setSelectedNode(node.id);
|
for (const id of ids) {
|
||||||
setSelectedWire(null);
|
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('mousemove', onNodeDragMove);
|
||||||
window.addEventListener('mouseup', onNodeDragUp);
|
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) {
|
function onNodeDragMove(e: MouseEvent) {
|
||||||
const d = dragNode.current;
|
const d = dragNode.current;
|
||||||
if (!d) return;
|
if (!d) return;
|
||||||
@@ -394,7 +477,10 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
|
|||||||
// click-to-select doesn't create a spurious history step).
|
// click-to-select doesn't create a spurious history step).
|
||||||
const record = !d.pushed;
|
const record = !d.pushed;
|
||||||
d.pushed = true;
|
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() {
|
function onNodeDragUp() {
|
||||||
dragNode.current = null;
|
dragNode.current = null;
|
||||||
@@ -466,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() === '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() === 'c') { e.preventDefault(); copySelection(); return; }
|
||||||
if (mod && e.key.toLowerCase() === 'v') { e.preventDefault(); pasteClipboard(); 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 (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);
|
else if (selectedNode) deleteNode(selectedNode);
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', onKey);
|
window.addEventListener('keydown', onKey);
|
||||||
return () => window.removeEventListener('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]));
|
const byId = new Map(nodes.map(n => [n.id, n]));
|
||||||
|
|
||||||
@@ -488,12 +577,85 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
|
|||||||
patchParams(id, { [field]: `${cur}{${ds}:${sig}}` });
|
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 (
|
return (
|
||||||
<div class="flow-editor">
|
<div class="flow-editor">
|
||||||
<div class="flow-palette">
|
<div class="flow-palette">
|
||||||
<div class="flow-palette-toolbar">
|
<div class="flow-palette-toolbar">
|
||||||
<button class="toolbar-btn" disabled={!canUndo} onClick={undo} title="Undo (Ctrl+Z)">↩</button>
|
<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={!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>
|
||||||
<div class="flow-palette-title">Triggers</div>
|
<div class="flow-palette-title">Triggers</div>
|
||||||
{PALETTE.filter(e => category(e.kind) === 'trigger').map(paletteBtn)}
|
{PALETTE.filter(e => category(e.kind) === 'trigger').map(paletteBtn)}
|
||||||
@@ -514,17 +676,61 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flow-canvas" ref={canvasRef}
|
<div class="flow-canvas" ref={canvasRef}
|
||||||
onMouseDown={() => { setSelectedNode(null); setSelectedWire(null); }}
|
onMouseDown={() => { setSelectedNode(null); setSelectedWire(null); setSelectedGroup(null); setSelSet(new Set()); }}
|
||||||
onDragOver={onCanvasDragOver}
|
onDragOver={onCanvasDragOver}
|
||||||
onDrop={onCanvasDrop}>
|
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">
|
<svg class="flow-wires">
|
||||||
{wires.map((w, idx) => {
|
{wires.map((w, idx) => {
|
||||||
const a = byId.get(w.from);
|
const a = byId.get(w.from);
|
||||||
const b = byId.get(w.to);
|
const b = byId.get(w.to);
|
||||||
if (!a || !b) return null;
|
if (!a || !b) return null;
|
||||||
const p1 = outAnchor(a, w.fromPort ?? 'out');
|
if (wireHidden(w.from, w.to)) return null;
|
||||||
const p2 = inAnchor(b);
|
const p1 = resolveOut(a, w.fromPort ?? 'out');
|
||||||
|
const p2 = resolveIn(b);
|
||||||
return (
|
return (
|
||||||
<path key={idx}
|
<path key={idx}
|
||||||
class={`flow-wire${selectedWire === idx ? ' flow-wire-selected' : ''}`}
|
class={`flow-wire${selectedWire === idx ? ' flow-wire-selected' : ''}`}
|
||||||
@@ -540,12 +746,19 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
|
|||||||
})()}
|
})()}
|
||||||
</svg>
|
</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}
|
<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;`}
|
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)}
|
onMouseDown={(e) => startNodeDrag(e, node)}
|
||||||
|
onDblClick={() => { if (firable) dbgEngine.current?.fireTrigger(node.id); }}
|
||||||
onMouseUp={() => { if (pendingRef.current) finishWire(node); }}>
|
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">
|
<div class="flow-node-header">
|
||||||
<span class="flow-node-title">{KIND_LABEL[node.kind]}</span>
|
<span class="flow-node-title">{KIND_LABEL[node.kind]}</span>
|
||||||
<button class="flow-node-del" title="Delete node"
|
<button class="flow-node-del" title="Delete node"
|
||||||
@@ -572,7 +785,9 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
|
|||||||
</Fragment>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+186
-4
@@ -1,12 +1,14 @@
|
|||||||
import { h } from 'preact';
|
import { h } from 'preact';
|
||||||
import { useState, useEffect } from 'preact/hooks';
|
import { useState, useEffect } from 'preact/hooks';
|
||||||
import type { Widget, Interface } from './lib/types';
|
import type { Widget, Interface } from './lib/types';
|
||||||
|
import { containingTabPane, tabLabels } from './lib/containers';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
selected: Widget | null;
|
selected: Widget | null;
|
||||||
multiCount: number;
|
multiCount: number;
|
||||||
iface: Interface;
|
iface: Interface;
|
||||||
onChange: (updated: Widget) => void;
|
onChange: (updated: Widget) => void;
|
||||||
|
onRenameId: (oldId: string, newId: string) => void;
|
||||||
onIfaceChange: (updated: Interface) => void;
|
onIfaceChange: (updated: Interface) => void;
|
||||||
width?: number;
|
width?: number;
|
||||||
}
|
}
|
||||||
@@ -44,16 +46,47 @@ 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)
|
// Widgets that show units from signal metadata (can be overridden)
|
||||||
const UNIT_WIDGETS = new Set(['textview', 'gauge', 'barh', 'barv', 'setvalue']);
|
const UNIT_WIDGETS = new Set(['textview', 'gauge', 'barh', 'barv', 'setvalue']);
|
||||||
// Widgets where the user can set a numeric format string
|
// Widgets where the user can set a numeric format string
|
||||||
const FORMAT_WIDGETS = new Set(['textview', 'gauge', 'barh', 'barv']);
|
const FORMAT_WIDGETS = new Set(['textview', 'gauge', 'barh', 'barv']);
|
||||||
// Widgets with a signal-based label (can be overridden)
|
// 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
|
// 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 [collapsed, setCollapsed] = useState(false);
|
||||||
const [configSets, setConfigSets] = useState<{ id: string; name: string }[]>([]);
|
const [configSets, setConfigSets] = useState<{ id: string; name: string }[]>([]);
|
||||||
|
|
||||||
@@ -77,6 +110,17 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
|
|||||||
onChange({ ...selected, [key]: value } as Widget);
|
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) {
|
function removeSignal(idx: number) {
|
||||||
if (!selected) return;
|
if (!selected) return;
|
||||||
const signals = selected.signals.filter((_, i) => i !== idx);
|
const signals = selected.signals.filter((_, i) => i !== idx);
|
||||||
@@ -135,6 +179,11 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
|
|||||||
<div class="props-section" key={w.id}>
|
<div class="props-section" key={w.id}>
|
||||||
<div class="props-section-title">{w.type}</div>
|
<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 */}
|
{/* Position / size */}
|
||||||
<Field label="X">
|
<Field label="X">
|
||||||
<input class="prop-input prop-input-num" type="number" value={w.x}
|
<input class="prop-input prop-input-num" type="number" value={w.x}
|
||||||
@@ -180,7 +229,7 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
|
|||||||
|
|
||||||
{/* Label: button and textlabel use it as display text;
|
{/* Label: button and textlabel use it as display text;
|
||||||
signal widgets use it as header label */}
|
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'}>
|
<Field label={LABEL_WIDGETS.has(w.type) ? 'Label override' : 'Label'}>
|
||||||
{LABEL_WIDGETS.has(w.type) ? (
|
{LABEL_WIDGETS.has(w.type) ? (
|
||||||
<div class="prop-field-col">
|
<div class="prop-field-col">
|
||||||
@@ -247,6 +296,30 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
|
|||||||
</div>
|
</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) */}
|
{/* Button options (issue #2) */}
|
||||||
{w.type === 'button' && (
|
{w.type === 'button' && (
|
||||||
<div>
|
<div>
|
||||||
@@ -404,6 +477,115 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
|
|||||||
</Field>
|
</Field>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,11 @@ import { useState, useEffect, useRef } from 'preact/hooks';
|
|||||||
import SignalPicker, { SignalOption } from './SignalPicker';
|
import SignalPicker, { SignalOption } from './SignalPicker';
|
||||||
import LuaEditor from './LuaEditor';
|
import LuaEditor from './LuaEditor';
|
||||||
import type { SignalDef, SynthGraph, SynthGraphNode } from './lib/types';
|
import type { SignalDef, SynthGraph, SynthGraphNode } from './lib/types';
|
||||||
import { inferNodeTypes, SynthType } from './lib/synthTypes';
|
import { inferNodeTypes, portAccept, typesCompatible, SynthType } from './lib/synthTypes';
|
||||||
import { VersionTree, DiffViewer } from './VersionHistory';
|
import { VersionTree, DiffViewer } from './VersionHistory';
|
||||||
|
import { groupBounds, groupContaining, genGroupId, pruneGroups, type NodeGroup, type Rect } from './lib/nodeGroups';
|
||||||
|
import { useFlowZoom, type Box } from './lib/flowZoom';
|
||||||
|
import { useFlowDebug, formatBadge, badgeTitle, nodeDebugClass, type DebugSnapshot } from './lib/flowDebug';
|
||||||
|
|
||||||
interface DataSource { name: string; }
|
interface DataSource { name: string; }
|
||||||
interface SignalInfo { name: string; type?: string; }
|
interface SignalInfo { name: string; type?: string; }
|
||||||
@@ -91,7 +94,7 @@ interface GNode {
|
|||||||
}
|
}
|
||||||
// A wire terminates on a specific input port (index) of the target node.
|
// A wire terminates on a specific input port (index) of the target node.
|
||||||
interface GWire { from: string; to: string; toPort: number; }
|
interface GWire { from: string; to: string; toPort: number; }
|
||||||
interface Graph { nodes: GNode[]; wires: GWire[]; }
|
interface Graph { nodes: GNode[]; wires: GWire[]; groups?: NodeGroup[]; }
|
||||||
|
|
||||||
// ── Geometry (rem-based, like the logic editor) ─────────────────────────────
|
// ── Geometry (rem-based, like the logic editor) ─────────────────────────────
|
||||||
const REM = (() => {
|
const REM = (() => {
|
||||||
@@ -99,6 +102,9 @@ const REM = (() => {
|
|||||||
return parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
|
return parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
|
||||||
})();
|
})();
|
||||||
const NODE_W = 10 * REM;
|
const NODE_W = 10 * REM;
|
||||||
|
// Logical canvas size (matches .flow-canvas-inner in styles.css); zoom scales it.
|
||||||
|
const CANVAS_W = 125 * REM;
|
||||||
|
const CANVAS_H = 87.5 * REM;
|
||||||
const PORT_TOP = 1.7 * REM; // y of the first input/output port row
|
const PORT_TOP = 1.7 * REM; // y of the first input/output port row
|
||||||
const PORT_GAP = 1.25 * REM; // vertical spacing between stacked input ports
|
const PORT_GAP = 1.25 * REM; // vertical spacing between stacked input ports
|
||||||
const PORT_R = 0.375 * REM;
|
const PORT_R = 0.375 * REM;
|
||||||
@@ -108,6 +114,11 @@ const PORT_R = 0.375 * REM;
|
|||||||
// offsets or they land at the top edge of the port circle instead of its center.
|
// offsets or they land at the top edge of the port circle instead of its center.
|
||||||
const BORDER = 1;
|
const BORDER = 1;
|
||||||
const BORDER_TOP = 3;
|
const BORDER_TOP = 3;
|
||||||
|
// ── Group geometry (node grouping / collapsing) ─────────────────────────────
|
||||||
|
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
|
||||||
|
|
||||||
function genId(): string { return 'g_' + Math.random().toString(36).slice(2, 9); }
|
function genId(): string { return 'g_' + Math.random().toString(36).slice(2, 9); }
|
||||||
function hasOutput(k: NodeKind): boolean { return k !== 'output'; }
|
function hasOutput(k: NodeKind): boolean { return k !== 'output'; }
|
||||||
@@ -163,7 +174,8 @@ function buildInitial(def: SignalDef): Graph {
|
|||||||
for (const n of def.graph.nodes) {
|
for (const n of def.graph.nodes) {
|
||||||
(n.inputs ?? []).forEach((from, port) => wires.push({ from, to: n.id, toPort: port }));
|
(n.inputs ?? []).forEach((from, port) => wires.push({ from, to: n.id, toPort: port }));
|
||||||
}
|
}
|
||||||
return { nodes, wires };
|
const groups = def.graph.groups;
|
||||||
|
return { nodes, wires, ...(groups && groups.length ? { groups: groups.map(g => ({ ...g, members: [...g.members] })) } : {}) };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Legacy linear layout: sources stacked left, pipeline a row of op nodes, output right.
|
// Legacy linear layout: sources stacked left, pipeline a row of op nodes, output right.
|
||||||
@@ -212,7 +224,8 @@ function compile(g: Graph): SynthGraph {
|
|||||||
if (n.kind === 'output') return { id: n.id, kind: 'output', inputs, x: n.x, y: n.y };
|
if (n.kind === 'output') return { id: n.id, kind: 'output', inputs, x: n.x, y: n.y };
|
||||||
return { id: n.id, kind: 'op', op: n.op, params: n.params, inputs, x: n.x, y: n.y };
|
return { id: n.id, kind: 'op', op: n.op, params: n.params, inputs, x: n.x, y: n.y };
|
||||||
});
|
});
|
||||||
return { nodes, output: output?.id ?? '' };
|
const groups = pruneGroups(g.groups, new Set(g.nodes.map(n => n.id)));
|
||||||
|
return { nodes, output: output?.id ?? '', ...(groups.length ? { groups } : {}) };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect a cycle in the graph (Kahn count over input edges).
|
// Detect a cycle in the graph (Kahn count over input edges).
|
||||||
@@ -308,6 +321,9 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
|
|||||||
const [graph, setGraph] = useState<Graph>({ nodes: [], wires: [] });
|
const [graph, setGraph] = useState<Graph>({ nodes: [], wires: [] });
|
||||||
const [selected, setSelected] = useState<string | null>(null);
|
const [selected, setSelected] = useState<string | null>(null);
|
||||||
const [selectedWire, setSelectedWire] = useState<number | null>(null);
|
const [selectedWire, setSelectedWire] = useState<number | null>(null);
|
||||||
|
// Multi-selection (Shift+click) and the selected group, for node grouping.
|
||||||
|
const [selSet, setSelSet] = useState<Set<string>>(new Set());
|
||||||
|
const [selectedGroup, setSelectedGroup] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
@@ -333,7 +349,8 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
|
|||||||
const [dsSignals, setDsSignals] = useState<Record<string, SignalInfo[]>>({});
|
const [dsSignals, setDsSignals] = useState<Record<string, SignalInfo[]>>({});
|
||||||
|
|
||||||
const canvasRef = useRef<HTMLDivElement>(null);
|
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; x: number; y: number } | null>(null);
|
const [pendingWire, setPendingWire] = useState<{ from: string; x: number; y: number } | null>(null);
|
||||||
const pendingRef = useRef(pendingWire);
|
const pendingRef = useRef(pendingWire);
|
||||||
pendingRef.current = pendingWire;
|
pendingRef.current = pendingWire;
|
||||||
@@ -342,6 +359,8 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
|
|||||||
graphRef.current = graph;
|
graphRef.current = graph;
|
||||||
const undoStack = useRef<Graph[]>([]);
|
const undoStack = useRef<Graph[]>([]);
|
||||||
const redoStack = useRef<Graph[]>([]);
|
const redoStack = useRef<Graph[]>([]);
|
||||||
|
const clipboard = useRef<{ nodes: GNode[]; wires: GWire[] } | null>(null);
|
||||||
|
const [debug, setDebug] = useState(false);
|
||||||
const [, setTick] = useState(0);
|
const [, setTick] = useState(0);
|
||||||
const bump = () => setTick(t => t + 1);
|
const bump = () => setTick(t => t + 1);
|
||||||
const canUndo = undoStack.current.length > 0;
|
const canUndo = undoStack.current.length > 0;
|
||||||
@@ -427,16 +446,20 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
|
|||||||
undoStack.current = [...undoStack.current.slice(-49), graphRef.current];
|
undoStack.current = [...undoStack.current.slice(-49), graphRef.current];
|
||||||
redoStack.current = [];
|
redoStack.current = [];
|
||||||
}
|
}
|
||||||
|
// Commit a new graph. When `next.groups` is left undefined the current groups
|
||||||
|
// are carried over, so ordinary node/wire edits never lose grouping; group
|
||||||
|
// mutators pass an explicit (possibly empty) list to change it.
|
||||||
function commit(next: Graph, record = true) {
|
function commit(next: Graph, record = true) {
|
||||||
if (record) pushUndo();
|
if (record) pushUndo();
|
||||||
setGraph(next);
|
const groups = next.groups !== undefined ? next.groups : graphRef.current.groups;
|
||||||
|
setGraph({ nodes: next.nodes, wires: next.wires, ...(groups && groups.length ? { groups } : {}) });
|
||||||
}
|
}
|
||||||
function undo() {
|
function undo() {
|
||||||
if (undoStack.current.length === 0) return;
|
if (undoStack.current.length === 0) return;
|
||||||
const prev = undoStack.current[undoStack.current.length - 1];
|
const prev = undoStack.current[undoStack.current.length - 1];
|
||||||
redoStack.current = [graphRef.current, ...redoStack.current];
|
redoStack.current = [graphRef.current, ...redoStack.current];
|
||||||
undoStack.current = undoStack.current.slice(0, -1);
|
undoStack.current = undoStack.current.slice(0, -1);
|
||||||
setSelected(null); setSelectedWire(null);
|
setSelected(null); setSelectedWire(null); setSelSet(new Set()); setSelectedGroup(null);
|
||||||
setGraph(prev); bump();
|
setGraph(prev); bump();
|
||||||
}
|
}
|
||||||
function redo() {
|
function redo() {
|
||||||
@@ -444,7 +467,7 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
|
|||||||
const next = redoStack.current[0];
|
const next = redoStack.current[0];
|
||||||
undoStack.current = [...undoStack.current, graphRef.current];
|
undoStack.current = [...undoStack.current, graphRef.current];
|
||||||
redoStack.current = redoStack.current.slice(1);
|
redoStack.current = redoStack.current.slice(1);
|
||||||
setSelected(null); setSelectedWire(null);
|
setSelected(null); setSelectedWire(null); setSelSet(new Set()); setSelectedGroup(null);
|
||||||
setGraph(next); bump();
|
setGraph(next); bump();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -527,21 +550,93 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
|
|||||||
.map(w => (w.to === id && w.toPort > idx ? { ...w, toPort: w.toPort - 1 } : w));
|
.map(w => (w.to === id && w.toPort > idx ? { ...w, toPort: w.toPort - 1 } : w));
|
||||||
commit({ nodes, wires });
|
commit({ nodes, wires });
|
||||||
}
|
}
|
||||||
function moveNode(id: string, x: number, y: number, record: boolean) {
|
// Move several nodes at once (group / multi-select drag). `pos` maps id→{x,y}.
|
||||||
|
function moveNodes(pos: Map<string, { x: number; y: number }>, record: boolean) {
|
||||||
const g = graphRef.current;
|
const g = graphRef.current;
|
||||||
commit({ nodes: g.nodes.map(n => (n.id === id ? { ...n, x, y } : n)), wires: g.wires }, record);
|
commit({ nodes: g.nodes.map(n => (pos.has(n.id) ? { ...n, ...pos.get(n.id)! } : n)), wires: g.wires }, record);
|
||||||
}
|
}
|
||||||
function deleteNode(id: string) {
|
function deleteNode(id: string) {
|
||||||
const n = graph.nodes.find(x => x.id === id);
|
const n = graph.nodes.find(x => x.id === id);
|
||||||
if (!n || n.kind === 'output') return; // the output node is permanent
|
if (!n || n.kind === 'output') return; // the output node is permanent
|
||||||
commit({ nodes: graph.nodes.filter(x => x.id !== id), wires: graph.wires.filter(w => w.from !== id && w.to !== id) });
|
const nextNodes = graph.nodes.filter(x => x.id !== id);
|
||||||
|
const nextGroups = pruneGroups(graph.groups, new Set(nextNodes.map(nn => nn.id)));
|
||||||
|
commit({ nodes: nextNodes, wires: graph.wires.filter(w => w.from !== id && w.to !== id), groups: nextGroups });
|
||||||
if (selected === id) setSelected(null);
|
if (selected === id) setSelected(null);
|
||||||
|
if (selSet.has(id)) { const s = new Set(selSet); s.delete(id); setSelSet(s); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Clipboard ────────────────────────────────────────────────────────────
|
||||||
|
// Copy the current selection (multi-select aware) plus any wires internal to
|
||||||
|
// it, with params deep-cloned. The permanent output node is never copied.
|
||||||
|
function copySelection() {
|
||||||
|
const cur = graphRef.current;
|
||||||
|
const ids = selSet.size > 0 ? selSet : (selected ? new Set([selected]) : new Set<string>());
|
||||||
|
const picked = cur.nodes.filter(n => ids.has(n.id) && n.kind !== 'output');
|
||||||
|
if (picked.length === 0) return;
|
||||||
|
const pickedIds = new Set(picked.map(n => n.id));
|
||||||
|
const innerWires = cur.wires.filter(w => pickedIds.has(w.from) && pickedIds.has(w.to));
|
||||||
|
clipboard.current = {
|
||||||
|
nodes: picked.map(n => ({ ...n, params: n.params ? JSON.parse(JSON.stringify(n.params)) : undefined })),
|
||||||
|
wires: innerWires.map(w => ({ ...w })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function pasteClipboard() {
|
||||||
|
const clip = clipboard.current;
|
||||||
|
if (!clip || clip.nodes.length === 0) return;
|
||||||
|
const idMap = new Map<string, string>();
|
||||||
|
const cur = graphRef.current;
|
||||||
|
const newNodes = clip.nodes.map(n => {
|
||||||
|
const id = genId();
|
||||||
|
idMap.set(n.id, id);
|
||||||
|
return { ...n, id, x: n.x + 1.5 * REM, y: n.y + 1.5 * REM, params: n.params ? JSON.parse(JSON.stringify(n.params)) : undefined };
|
||||||
|
});
|
||||||
|
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)! }));
|
||||||
|
for (const n of newNodes) if (n.kind === 'source' && n.ds) loadSignals(n.ds);
|
||||||
|
commit({ nodes: [...cur.nodes, ...newNodes], wires: [...cur.wires, ...newWires] });
|
||||||
|
setSelSet(new Set(newNodes.map(n => n.id)));
|
||||||
|
setSelected(newNodes.length === 1 ? newNodes[0].id : null);
|
||||||
|
setSelectedWire(null);
|
||||||
|
setSelectedGroup(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Groups ──────────────────────────────────────────────────────────────────
|
||||||
|
function setGroups(next: NodeGroup[], record = true) {
|
||||||
|
const g = graphRef.current;
|
||||||
|
commit({ nodes: g.nodes, wires: g.wires, groups: next }, record);
|
||||||
|
}
|
||||||
|
function groupSelection() {
|
||||||
|
if (selSet.size < 1) return;
|
||||||
|
const members = graph.nodes.filter(n => selSet.has(n.id)).map(n => n.id);
|
||||||
|
if (members.length < 1) return;
|
||||||
|
const grp: NodeGroup = { id: genGroupId(), label: 'Group', members, collapsed: false };
|
||||||
|
setGroups([...(graph.groups ?? []), grp]);
|
||||||
|
setSelectedGroup(grp.id); setSelected(null); setSelectedWire(null);
|
||||||
|
}
|
||||||
|
function ungroup(gid: string) {
|
||||||
|
setGroups((graph.groups ?? []).filter(g => g.id !== gid));
|
||||||
|
if (selectedGroup === gid) setSelectedGroup(null);
|
||||||
|
}
|
||||||
|
function toggleCollapse(gid: string) {
|
||||||
|
setGroups((graph.groups ?? []).map(g => (g.id === gid ? { ...g, collapsed: !g.collapsed } : g)));
|
||||||
|
}
|
||||||
|
function renameGroup(gid: string, label: string) {
|
||||||
|
setGroups((graph.groups ?? []).map(g => (g.id === gid ? { ...g, label } : g)), false);
|
||||||
}
|
}
|
||||||
function addWire(from: string, to: string, toPort: number) {
|
function addWire(from: string, to: string, toPort: number) {
|
||||||
if (from === to) return;
|
if (from === to) return;
|
||||||
const fn = graph.nodes.find(n => n.id === from);
|
const fn = graph.nodes.find(n => n.id === from);
|
||||||
const tn = graph.nodes.find(n => n.id === to);
|
const tn = graph.nodes.find(n => n.id === to);
|
||||||
if (!fn || !tn || !hasOutput(fn.kind) || tn.kind === 'source') return;
|
if (!fn || !tn || !hasOutput(fn.kind) || tn.kind === 'source') return;
|
||||||
|
// Reject a definitely type-incompatible link (e.g. a scalar into an array
|
||||||
|
// reduction). 'unknown'/'any' ports never block — runtime typing is final.
|
||||||
|
const { types } = inferNodeTypes(compile(graph), sourceSynthType);
|
||||||
|
const accept = tn.kind === 'op' ? portAccept(tn.op ?? '') : 'any';
|
||||||
|
if (!typesCompatible(types.get(from) ?? 'unknown', accept)) {
|
||||||
|
setError(`Incompatible connection: ${accept} port can't take a ${types.get(from)} value`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
// One wire per input port: replace any existing wire on that port.
|
// One wire per input port: replace any existing wire on that port.
|
||||||
const wires = graph.wires.filter(w => !(w.to === to && w.toPort === toPort));
|
const wires = graph.wires.filter(w => !(w.to === to && w.toPort === toPort));
|
||||||
if (wires.some(w => w.from === from && w.to === to && w.toPort === toPort)) return;
|
if (wires.some(w => w.from === from && w.to === to && w.toPort === toPort)) return;
|
||||||
@@ -564,13 +659,35 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
|
|||||||
function toCanvas(e: MouseEvent) {
|
function toCanvas(e: MouseEvent) {
|
||||||
const el = canvasRef.current!;
|
const el = canvasRef.current!;
|
||||||
const rect = el.getBoundingClientRect();
|
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 };
|
||||||
|
}
|
||||||
|
// 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);
|
||||||
|
const starts = new Map<string, { x: number; y: number }>();
|
||||||
|
for (const id of ids) {
|
||||||
|
const n = graphRef.current.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 };
|
||||||
}
|
}
|
||||||
function startNodeDrag(e: MouseEvent, node: GNode) {
|
function startNodeDrag(e: MouseEvent, node: GNode) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const p = toCanvas(e);
|
// Shift+click toggles the node in the multi-selection without dragging.
|
||||||
dragNode.current = { id: node.id, dx: p.x - node.x, dy: p.y - node.y, pushed: false };
|
if (e.shiftKey) {
|
||||||
setSelected(node.id); setSelectedWire(null);
|
const s = new Set(selSet);
|
||||||
|
if (s.has(node.id)) s.delete(node.id); else s.add(node.id);
|
||||||
|
setSelSet(s); setSelected(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]));
|
||||||
|
setSelected(node.id);
|
||||||
|
beginDrag(e, ids);
|
||||||
}
|
}
|
||||||
function startWire(e: MouseEvent, node: GNode) {
|
function startWire(e: MouseEvent, node: GNode) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -596,7 +713,10 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
|
|||||||
const p = toCanvas(e);
|
const p = toCanvas(e);
|
||||||
const record = !d.pushed;
|
const record = !d.pushed;
|
||||||
d.pushed = true;
|
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);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const cur = pendingRef.current;
|
const cur = pendingRef.current;
|
||||||
@@ -643,22 +763,86 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
|
|||||||
const mod = e.ctrlKey || e.metaKey;
|
const mod = e.ctrlKey || e.metaKey;
|
||||||
if (mod && e.key.toLowerCase() === 'z' && !e.shiftKey) { e.preventDefault(); undo(); return; }
|
if (mod && e.key.toLowerCase() === 'z' && !e.shiftKey) { e.preventDefault(); undo(); return; }
|
||||||
if (mod && (e.key.toLowerCase() === 'y' || (e.key.toLowerCase() === 'z' && e.shiftKey))) { e.preventDefault(); redo(); return; }
|
if (mod && (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; }
|
||||||
if (mod) return;
|
if (mod) return;
|
||||||
if (e.key === 'Escape') { if (hud) closeHud(); return; }
|
if (e.key === 'Escape') { if (hud) closeHud(); return; }
|
||||||
if (e.key.toLowerCase() === 's') { e.preventDefault(); openHud('signal'); return; }
|
if (e.key.toLowerCase() === 's') { e.preventDefault(); openHud('signal'); return; }
|
||||||
if (e.key.toLowerCase() === 'n') { e.preventDefault(); openHud('node'); return; }
|
if (e.key.toLowerCase() === 'n') { e.preventDefault(); openHud('node'); return; }
|
||||||
|
// 'g' groups the current multi-selection.
|
||||||
|
if (e.key.toLowerCase() === 'g' && selSet.size >= 1) { e.preventDefault(); groupSelection(); return; }
|
||||||
if (e.key !== 'Delete' && e.key !== 'Backspace') 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 (selected) deleteNode(selected);
|
else if (selected) deleteNode(selected);
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', onKey);
|
window.addEventListener('keydown', onKey);
|
||||||
return () => window.removeEventListener('keydown', onKey);
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
}, [selected, selectedWire, graph, hud]);
|
}, [selected, selectedWire, selectedGroup, selSet, graph, hud]);
|
||||||
|
|
||||||
const byId = new Map(graph.nodes.map(n => [n.id, n]));
|
const byId = new Map(graph.nodes.map(n => [n.id, n]));
|
||||||
|
const groups = graph.groups ?? [];
|
||||||
const sel = graph.nodes.find(n => n.id === selected) ?? null;
|
const sel = graph.nodes.find(n => n.id === selected) ?? null;
|
||||||
const { errors: nodeErrors, first: validationError } = validate(graph);
|
const { errors: nodeErrors, first: validationError } = validate(graph);
|
||||||
|
|
||||||
|
// ── 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: nodeMinHeight(n, graph.wires) } : 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 hiddenNodes = 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) hiddenNodes.add(m);
|
||||||
|
}
|
||||||
|
const collapsedBoxOf = (id: string): Rect | null => {
|
||||||
|
const g = groupContaining(groups, id);
|
||||||
|
return g && g.collapsed ? (boxByGroup.get(g.id) ?? null) : null;
|
||||||
|
};
|
||||||
|
const resolveOut = (n: GNode) => {
|
||||||
|
const box = collapsedBoxOf(n.id);
|
||||||
|
return box ? { x: box.x + box.w, y: box.y + box.h / 2 } : outAnchor(n);
|
||||||
|
};
|
||||||
|
const resolveIn = (n: GNode, port: number) => {
|
||||||
|
const box = collapsedBoxOf(n.id);
|
||||||
|
return box ? { x: box.x, y: box.y + box.h / 2 } : inAnchor(n, port);
|
||||||
|
};
|
||||||
|
// 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;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Pan / zoom ───────────────────────────────────────────────────────────────
|
||||||
|
const getRects = (): Box[] => graph.nodes.map(n => ({ x: n.x, y: n.y, w: NODE_W, h: nodeMinHeight(n, graph.wires) }));
|
||||||
|
const { zoom, zoomIn, zoomOut, zoomHome, zoomFit, innerStyle, zoomStyle } =
|
||||||
|
useFlowZoom(canvasRef, zoomRef, CANVAS_W, CANVAS_H, getRects);
|
||||||
|
|
||||||
|
// ── Live / debug mode ────────────────────────────────────────────────────────
|
||||||
|
// Polls the server with the current (unsaved) graph; each node lights up with
|
||||||
|
// its computed value. Stateful DSP nodes are flagged "approx" (single-shot eval).
|
||||||
|
const debugSnap: DebugSnapshot = useFlowDebug(debug, async () => {
|
||||||
|
const res = await fetch('/api/v1/synthetic/trace', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ graph: compile(graphRef.current) }),
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const j = await res.json();
|
||||||
|
const snap: DebugSnapshot = new Map();
|
||||||
|
for (const [id, n] of Object.entries(j.nodes ?? {})) {
|
||||||
|
const node = n as { value: any; type?: 'scalar' | 'array'; approx?: boolean };
|
||||||
|
snap.set(id, { value: node.value, type: node.type, approx: node.approx, active: node.value != null });
|
||||||
|
}
|
||||||
|
return snap;
|
||||||
|
});
|
||||||
|
|
||||||
// Static type propagation: infer each node's output type (scalar/array) to
|
// Static type propagation: infer each node's output type (scalar/array) to
|
||||||
// colour wires and surface type-incompatible wirings as node errors. Mirrors
|
// colour wires and surface type-incompatible wirings as node errors. Mirrors
|
||||||
// the backend dsp.OpOutputType rules (see lib/synthTypes.ts).
|
// the backend dsp.OpOutputType rules (see lib/synthTypes.ts).
|
||||||
@@ -754,6 +938,20 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Port colour class. Output ports take the node's own inferred output type;
|
||||||
|
// input ports take what the op accepts (gray when it accepts either type).
|
||||||
|
function portTypeClass(t: SynthType | 'any'): string {
|
||||||
|
if (t === 'array') return ' flow-port-type-array';
|
||||||
|
if (t === 'scalar') return ' flow-port-type-scalar';
|
||||||
|
return ' flow-port-type-any';
|
||||||
|
}
|
||||||
|
function inPortTypeClass(n: GNode): string {
|
||||||
|
return portTypeClass(n.kind === 'op' ? portAccept(n.op ?? '') : 'any');
|
||||||
|
}
|
||||||
|
function outPortTypeClass(n: GNode): string {
|
||||||
|
return portTypeClass(nodeTypes.get(n.id) ?? 'unknown');
|
||||||
|
}
|
||||||
|
|
||||||
function nodeClass(n: GNode): string {
|
function nodeClass(n: GNode): string {
|
||||||
const cat = n.kind === 'source' ? 'trigger' : n.kind === 'output' ? 'action' : 'flow';
|
const cat = n.kind === 'source' ? 'trigger' : n.kind === 'output' ? 'action' : 'flow';
|
||||||
const err = nodeErrors.has(n.id) ? ' flow-node-error' : '';
|
const err = nodeErrors.has(n.id) ? ' flow-node-error' : '';
|
||||||
@@ -780,6 +978,16 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
|
|||||||
<div class="flow-palette-toolbar">
|
<div class="flow-palette-toolbar">
|
||||||
<button class="toolbar-btn" disabled={!canUndo} onClick={undo} title="Undo (Ctrl+Z)">↩</button>
|
<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={!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 — evaluate the graph and show each node's value">◉</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>
|
||||||
<div class="flow-palette-title">Inputs</div>
|
<div class="flow-palette-title">Inputs</div>
|
||||||
<button class="flow-palette-btn flow-palette-trigger" onClick={addSource}>+ Signal</button>
|
<button class="flow-palette-btn flow-palette-trigger" onClick={addSource}>+ Signal</button>
|
||||||
@@ -798,15 +1006,59 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flow-canvas" ref={canvasRef}
|
<div class="flow-canvas" ref={canvasRef}
|
||||||
onMouseDown={() => { setSelected(null); setSelectedWire(null); }}
|
onMouseDown={() => { setSelected(null); setSelectedWire(null); setSelectedGroup(null); setSelSet(new Set()); }}
|
||||||
onDragOver={onCanvasDragOver}
|
onDragOver={onCanvasDragOver}
|
||||||
onDrop={onCanvasDrop}>
|
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 gsel = selectedGroup === g.id;
|
||||||
|
if (g.collapsed && box) {
|
||||||
|
return (
|
||||||
|
<div key={g.id}
|
||||||
|
class={`flow-group-box${gsel ? ' 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); setSelected(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${gsel ? ' 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); setSelected(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">
|
<svg class="flow-wires">
|
||||||
{graph.wires.map((w, idx) => {
|
{graph.wires.map((w, idx) => {
|
||||||
const a = byId.get(w.from); const b = byId.get(w.to);
|
const a = byId.get(w.from); const b = byId.get(w.to);
|
||||||
if (!a || !b) return null;
|
if (!a || !b) return null;
|
||||||
const p1 = outAnchor(a); const p2 = inAnchor(b, w.toPort);
|
if (wireHidden(w.from, w.to)) return null;
|
||||||
|
const p1 = resolveOut(a); const p2 = resolveIn(b, w.toPort);
|
||||||
const tClass = nodeTypes.get(w.from) === 'array' ? ' synth-wire-array' : ' synth-wire-scalar';
|
const tClass = nodeTypes.get(w.from) === 'array' ? ' synth-wire-array' : ' synth-wire-scalar';
|
||||||
return (
|
return (
|
||||||
<path key={idx}
|
<path key={idx}
|
||||||
@@ -823,15 +1075,22 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
|
|||||||
})()}
|
})()}
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
{graph.nodes.map(node => {
|
{graph.nodes.filter(n => !hiddenNodes.has(n.id)).map(node => {
|
||||||
const nIn = inPortCount(node, graph.wires);
|
const nIn = inPortCount(node, graph.wires);
|
||||||
|
const dbg = debug ? debugSnap.get(node.id) : undefined;
|
||||||
|
const grouped = groupContaining(groups, node.id) ? ' flow-node-grouped' : '';
|
||||||
return (
|
return (
|
||||||
<div key={node.id}
|
<div key={node.id}
|
||||||
class={nodeClass(node)}
|
class={`${nodeClass(node)}${selSet.has(node.id) && selSet.size > 1 ? ' flow-node-multi' : ''}${dbg ? ' ' + nodeDebugClass(dbg) : ''}${grouped}`}
|
||||||
title={nodeErrors.get(node.id) || undefined}
|
title={nodeErrors.get(node.id) || undefined}
|
||||||
style={`left:${node.x}px; top:${node.y}px; width:${NODE_W}px; min-height:${nodeMinHeight(node, graph.wires)}px;`}
|
style={`left:${node.x}px; top:${node.y}px; width:${NODE_W}px; min-height:${nodeMinHeight(node, graph.wires)}px;`}
|
||||||
onMouseDown={(e) => startNodeDrag(e, node)}
|
onMouseDown={(e) => startNodeDrag(e, node)}
|
||||||
onMouseUp={() => { if (pendingRef.current) finishWire(node, firstFreePort(node)); }}>
|
onMouseUp={() => { if (pendingRef.current) finishWire(node, firstFreePort(node)); }}>
|
||||||
|
{dbg && (
|
||||||
|
<span class={`flow-node-badge${dbg.approx ? ' approx' : ''}`} title={badgeTitle(dbg)}>
|
||||||
|
{(dbg.approx ? '~' : '') + formatBadge(dbg)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<div class="flow-node-header">
|
<div class="flow-node-header">
|
||||||
<span class="flow-node-title">{node.kind === 'source' ? 'Signal' : node.kind === 'output' ? 'Output' : opLabel(node.op ?? '')}</span>
|
<span class="flow-node-title">{node.kind === 'source' ? 'Signal' : node.kind === 'output' ? 'Output' : opLabel(node.op ?? '')}</span>
|
||||||
{node.kind !== 'output' && (
|
{node.kind !== 'output' && (
|
||||||
@@ -846,7 +1105,7 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
|
|||||||
const label = portLabel(node, port);
|
const label = portLabel(node, port);
|
||||||
return (
|
return (
|
||||||
<Fragment key={port}>
|
<Fragment key={port}>
|
||||||
<div class="flow-port flow-port-in" title={label || `Input ${port + 1}`}
|
<div class={`flow-port flow-port-in${inPortTypeClass(node)}`} title={label || `Input ${port + 1}`}
|
||||||
style={`top:${PORT_TOP + port * PORT_GAP - PORT_R}px; left:${-PORT_R}px;`}
|
style={`top:${PORT_TOP + port * PORT_GAP - PORT_R}px; left:${-PORT_R}px;`}
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
onMouseUp={(e) => { e.stopPropagation(); finishWire(node, port); }} />
|
onMouseUp={(e) => { e.stopPropagation(); finishWire(node, port); }} />
|
||||||
@@ -858,7 +1117,7 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{hasOutput(node.kind) && (
|
{hasOutput(node.kind) && (
|
||||||
<div class="flow-port flow-port-out" title="Output"
|
<div class={`flow-port flow-port-out${outPortTypeClass(node)}`} title="Output"
|
||||||
style={`top:${PORT_TOP - PORT_R}px; right:${-PORT_R}px;`}
|
style={`top:${PORT_TOP - PORT_R}px; right:${-PORT_R}px;`}
|
||||||
onMouseDown={(e) => startWire(e, node)} />
|
onMouseDown={(e) => startWire(e, node)} />
|
||||||
)}
|
)}
|
||||||
@@ -867,6 +1126,7 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flow-inspector">
|
<div class="flow-inspector">
|
||||||
{!sel && (
|
{!sel && (
|
||||||
|
|||||||
+55
-27
@@ -12,6 +12,7 @@ import ZoomControl from './ZoomControl';
|
|||||||
import ControlLogicEditor from './ControlLogicEditor';
|
import ControlLogicEditor from './ControlLogicEditor';
|
||||||
import AuditViewer from './AuditViewer';
|
import AuditViewer from './AuditViewer';
|
||||||
import ConfigManager from './ConfigManager';
|
import ConfigManager from './ConfigManager';
|
||||||
|
import AdminPane from './AdminPane';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onEdit?: (iface?: Interface) => void;
|
onEdit?: (iface?: Interface) => void;
|
||||||
@@ -36,10 +37,14 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) {
|
|||||||
const [showControlLogic, setShowControlLogic] = useState(false);
|
const [showControlLogic, setShowControlLogic] = useState(false);
|
||||||
const [showAudit, setShowAudit] = useState(false);
|
const [showAudit, setShowAudit] = useState(false);
|
||||||
const [showConfig, setShowConfig] = useState(false);
|
const [showConfig, setShowConfig] = useState(false);
|
||||||
|
const [showAdmin, setShowAdmin] = useState(false);
|
||||||
|
const [toolsOpen, setToolsOpen] = useState(false);
|
||||||
const [leftW, setLeftW] = useState(220);
|
const [leftW, setLeftW] = useState(220);
|
||||||
const [listCollapsed, setListCollapsed] = useState(false);
|
const [listCollapsed, setListCollapsed] = useState(false);
|
||||||
const me = useAuth();
|
const me = useAuth();
|
||||||
const writable = canWrite(me.level);
|
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) {
|
function startResize(e: MouseEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -69,6 +74,15 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) {
|
|||||||
return unsub;
|
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
|
// When returning from edit mode, show the edited interface
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialInterface) setCurrentInterface(initialInterface);
|
if (initialInterface) setCurrentInterface(initialInterface);
|
||||||
@@ -140,14 +154,6 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div class="toolbar-right">
|
<div class="toolbar-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>
|
|
||||||
<button
|
<button
|
||||||
class={`toolbar-btn${showTimeNav ? ' toolbar-btn-active' : ''}`}
|
class={`toolbar-btn${showTimeNav ? ' toolbar-btn-active' : ''}`}
|
||||||
title="Toggle historical time navigation"
|
title="Toggle historical time navigation"
|
||||||
@@ -163,32 +169,32 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) {
|
|||||||
📖
|
📖
|
||||||
</button>
|
</button>
|
||||||
<ZoomControl />
|
<ZoomControl />
|
||||||
{writable && me.canEditLogic && (
|
{hasTools && (
|
||||||
|
<div class="toolbar-dropdown" onMouseDown={(e) => e.stopPropagation()}>
|
||||||
<button
|
<button
|
||||||
class="toolbar-btn"
|
class={`toolbar-btn${toolsOpen ? ' toolbar-btn-active' : ''}`}
|
||||||
onClick={() => setShowControlLogic(true)}
|
title="Advanced tools"
|
||||||
title="Open server-side control logic"
|
onClick={() => setToolsOpen(o => !o)}
|
||||||
>
|
>
|
||||||
⚙ Control logic
|
⋯ Tools ▾
|
||||||
</button>
|
</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 && (
|
{me.canViewAudit && (
|
||||||
<button
|
<button class="ctx-item" onClick={() => setShowAudit(true)}>🛡 Audit log</button>
|
||||||
class="toolbar-btn"
|
|
||||||
onClick={() => setShowAudit(true)}
|
|
||||||
title="View the audit log"
|
|
||||||
>
|
|
||||||
🛡 Audit
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
{writable && (
|
{writable && (
|
||||||
<button
|
<button class="ctx-item" onClick={() => setShowConfig(true)}>🗂 Config manager</button>
|
||||||
class="toolbar-btn"
|
)}
|
||||||
onClick={() => setShowConfig(true)}
|
{me.canAdmin && (
|
||||||
title="Open configuration manager"
|
<button class="ctx-item" onClick={() => setShowAdmin(true)}>{'\u{1F464}\u{FE0E}'} Admin</button>
|
||||||
>
|
)}
|
||||||
🗂 Config
|
</div>
|
||||||
</button>
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{writable && (
|
{writable && (
|
||||||
<button
|
<button
|
||||||
@@ -269,6 +275,25 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</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 && (
|
{showHelp && (
|
||||||
<HelpModal initialSection={helpSection} onClose={() => setShowHelp(false)} />
|
<HelpModal initialSection={helpSection} onClose={() => setShowHelp(false)} />
|
||||||
)}
|
)}
|
||||||
@@ -283,6 +308,9 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) {
|
|||||||
{showConfig && (
|
{showConfig && (
|
||||||
<ConfigManager onClose={() => setShowConfig(false)} />
|
<ConfigManager onClose={() => setShowConfig(false)} />
|
||||||
)}
|
)}
|
||||||
|
{showAdmin && (
|
||||||
|
<AdminPane onClose={() => setShowAdmin(false)} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,8 +15,10 @@ const WIDGET_TYPES = [
|
|||||||
{ type: 'led', label: 'LED', desc: 'State indicator' },
|
{ type: 'led', label: 'LED', desc: 'State indicator' },
|
||||||
{ type: 'multiled', label: 'Multi-LED', desc: 'Bitset indicator' },
|
{ type: 'multiled', label: 'Multi-LED', desc: 'Bitset indicator' },
|
||||||
{ type: 'setvalue', label: 'Set Value', desc: 'Input + write control' },
|
{ 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: 'button', label: 'Button', desc: 'Send command on click' },
|
||||||
{ type: 'plot', label: 'Plot', desc: 'Time-series / charts' },
|
{ 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: 'textlabel', label: 'Label', desc: 'Static text label' },
|
||||||
{ type: 'image', label: 'Image', desc: 'Image from URL' },
|
{ type: 'image', label: 'Image', desc: 'Image from URL' },
|
||||||
{ type: 'link', label: 'Link', desc: 'Navigate to interface' },
|
{ type: 'link', label: 'Link', desc: 'Navigate to interface' },
|
||||||
|
|||||||
+2
-1
@@ -4,7 +4,7 @@ import type { Me, AccessLevel } from './types';
|
|||||||
|
|
||||||
// Default identity used before /api/v1/me resolves (or if it fails): assume a
|
// Default identity used before /api/v1/me resolves (or if it fails): assume a
|
||||||
// trusted LAN user with full access, matching the backend default.
|
// trusted LAN user with full access, matching the backend default.
|
||||||
export const DEFAULT_ME: Me = { user: '', level: 'write', groups: [], canEditLogic: true, canViewAudit: 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
|
// AuthContext carries the resolved identity + global access level for the
|
||||||
// current user throughout the app.
|
// current user throughout the app.
|
||||||
@@ -38,6 +38,7 @@ export async function fetchMe(): Promise<Me> {
|
|||||||
groups: Array.isArray(data.groups) ? data.groups : [],
|
groups: Array.isArray(data.groups) ? data.groups : [],
|
||||||
canEditLogic: data.canEditLogic !== false,
|
canEditLogic: data.canEditLogic !== false,
|
||||||
canViewAudit: data.canViewAudit !== false,
|
canViewAudit: data.canViewAudit !== false,
|
||||||
|
canAdmin: data.canAdmin !== false,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return DEFAULT_ME;
|
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,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 };
|
||||||
|
}
|
||||||
+71
-18
@@ -204,7 +204,7 @@ interface WireOut { to: string; port: string }
|
|||||||
// expression resolver that exposes the system signals {sys:time}/{sys:dt}.
|
// expression resolver that exposes the system signals {sys:time}/{sys:dt}.
|
||||||
interface RunCtx { firedTrigger: string; steps: { n: number }; dt: number; resolve: Resolver }
|
interface RunCtx { firedTrigger: string; steps: { n: number }; dt: number; resolve: Resolver }
|
||||||
|
|
||||||
class LogicEngine {
|
export class LogicEngine {
|
||||||
private graph: LogicGraph = { nodes: [], wires: [] };
|
private graph: LogicGraph = { nodes: [], wires: [] };
|
||||||
private byId = new Map<string, LogicNode>();
|
private byId = new Map<string, LogicNode>();
|
||||||
// Outgoing wires grouped by source node id.
|
// Outgoing wires grouped by source node id.
|
||||||
@@ -227,6 +227,37 @@ class LogicEngine {
|
|||||||
private lastFire = new Map<string, number>();
|
private lastFire = new Map<string, number>();
|
||||||
private cleanups: Array<() => void> = [];
|
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
|
// Resolve an expression reference. Two built-in system signals are served
|
||||||
// synthetically (never subscribed): {sys:time} = current epoch seconds,
|
// synthetically (never subscribed): {sys:time} = current epoch seconds,
|
||||||
// {sys:dt} = seconds since the firing trigger last fired. Everything else
|
// {sys:dt} = seconds since the firing trigger last fired. Everything else
|
||||||
@@ -320,9 +351,11 @@ class LogicEngine {
|
|||||||
this.watchers = new Map();
|
this.watchers = new Map();
|
||||||
this.arrays = new Map();
|
this.arrays = new Map();
|
||||||
this.lastFire = new Map();
|
this.lastFire = new Map();
|
||||||
|
this.debugStates = new Map();
|
||||||
// Restore every widget to its default state so reopening a panel (whose
|
// Restore every widget to its default state so reopening a panel (whose
|
||||||
// widget ids are stable) does not inherit disabled/hidden/paused flags.
|
// widget ids are stable) does not inherit disabled/hidden/paused flags. The
|
||||||
resetWidgetCmds();
|
// 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. */
|
/** Fire every button-trigger node whose `name` param matches. */
|
||||||
@@ -435,12 +468,22 @@ class LogicEngine {
|
|||||||
|
|
||||||
// ── Execution ───────────────────────────────────────────────────────────────
|
// ── 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.
|
// A trigger activated: walk its outgoing 'out' wires.
|
||||||
private activate(triggerId: string): void {
|
private activate(triggerId: string): void {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const last = this.lastFire.get(triggerId);
|
const last = this.lastFire.get(triggerId);
|
||||||
const dt = last === undefined ? 0 : (now - last) / 1000;
|
const dt = last === undefined ? 0 : (now - last) / 1000;
|
||||||
this.lastFire.set(triggerId, now);
|
this.lastFire.set(triggerId, now);
|
||||||
|
this.markActive(triggerId);
|
||||||
const ctx: RunCtx = {
|
const ctx: RunCtx = {
|
||||||
firedTrigger: triggerId,
|
firedTrigger: triggerId,
|
||||||
steps: { n: 0 },
|
steps: { n: 0 },
|
||||||
@@ -462,15 +505,20 @@ class LogicEngine {
|
|||||||
if (ctx.steps.n++ > MAX_STEPS) return;
|
if (ctx.steps.n++ > MAX_STEPS) return;
|
||||||
const node = this.byId.get(nodeId);
|
const node = this.byId.get(nodeId);
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
|
this.markActive(node.id);
|
||||||
|
|
||||||
switch (node.kind) {
|
switch (node.kind) {
|
||||||
case 'gate.and':
|
case 'gate.and': {
|
||||||
if (this.gateSatisfied(node.id, ctx.firedTrigger)) await this.follow(node.id, 'out', ctx);
|
const ok = this.gateSatisfied(node.id, ctx.firedTrigger);
|
||||||
|
this.markActive(node.id, ok);
|
||||||
|
if (ok) await this.follow(node.id, 'out', ctx);
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
case 'flow.if': {
|
case 'flow.if': {
|
||||||
const branch = evalBool(node.params.cond ?? '', ctx.resolve) ? 'then' : 'else';
|
const pass = evalBool(node.params.cond ?? '', ctx.resolve);
|
||||||
await this.follow(node.id, branch, ctx);
|
this.markActive(node.id, pass);
|
||||||
|
await this.follow(node.id, pass ? 'then' : 'else', ctx);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -493,14 +541,15 @@ class LogicEngine {
|
|||||||
case 'action.write': {
|
case 'action.write': {
|
||||||
const ref = parseRef(node.params.target ?? '');
|
const ref = parseRef(node.params.target ?? '');
|
||||||
const val = evalExpr(node.params.expr ?? '', ctx.resolve);
|
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);
|
await this.follow(node.id, 'out', ctx);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'action.config.apply': {
|
case 'action.config.apply': {
|
||||||
const id = this.resolveInstanceId(node);
|
const id = this.resolveInstanceId(node);
|
||||||
if (id) {
|
if (id && !this.dryRun) {
|
||||||
try {
|
try {
|
||||||
await fetch(`/api/v1/config/instances/${encodeURIComponent(id)}/apply`, { method: 'POST' });
|
await fetch(`/api/v1/config/instances/${encodeURIComponent(id)}/apply`, { method: 'POST' });
|
||||||
} catch {}
|
} catch {}
|
||||||
@@ -513,9 +562,9 @@ class LogicEngine {
|
|||||||
const id = this.resolveInstanceId(node);
|
const id = this.resolveInstanceId(node);
|
||||||
const key = (node.params.key ?? '').trim();
|
const key = (node.params.key ?? '').trim();
|
||||||
const ref = parseRef(node.params.target ?? '');
|
const ref = parseRef(node.params.target ?? '');
|
||||||
if (id && key && ref) {
|
if (id && key && ref && !this.dryRun) {
|
||||||
const val = await readConfigParam(id, key);
|
const val = await readConfigParam(id, key);
|
||||||
if (val !== null && !isNaN(val)) wsClient.write(ref, val);
|
if (val !== null && !isNaN(val)) this.dryWrite(ref, val);
|
||||||
}
|
}
|
||||||
await this.follow(node.id, 'out', ctx);
|
await this.follow(node.id, 'out', ctx);
|
||||||
return;
|
return;
|
||||||
@@ -525,7 +574,7 @@ class LogicEngine {
|
|||||||
const id = this.resolveInstanceId(node);
|
const id = this.resolveInstanceId(node);
|
||||||
const key = (node.params.key ?? '').trim();
|
const key = (node.params.key ?? '').trim();
|
||||||
const val = evalExpr(node.params.expr ?? '', ctx.resolve);
|
const val = evalExpr(node.params.expr ?? '', ctx.resolve);
|
||||||
if (id && key && !isNaN(val)) {
|
if (id && key && !isNaN(val) && !this.dryRun) {
|
||||||
await writeConfigParam(id, key, val);
|
await writeConfigParam(id, key, val);
|
||||||
}
|
}
|
||||||
await this.follow(node.id, 'out', ctx);
|
await this.follow(node.id, 'out', ctx);
|
||||||
@@ -537,9 +586,9 @@ class LogicEngine {
|
|||||||
const name = (node.params.name ?? '').trim() || 'auto';
|
const name = (node.params.name ?? '').trim() || 'auto';
|
||||||
const fromId = (node.params.from ?? '').trim();
|
const fromId = (node.params.from ?? '').trim();
|
||||||
const target = parseRef(node.params.target ?? '');
|
const target = parseRef(node.params.target ?? '');
|
||||||
if (setId) {
|
if (setId && !this.dryRun) {
|
||||||
const newId = await createConfigInstance(setId, name, fromId);
|
const newId = await createConfigInstance(setId, name, fromId);
|
||||||
if (newId && target) wsClient.write(target, newId);
|
if (newId && target) this.dryWrite(target, newId);
|
||||||
}
|
}
|
||||||
await this.follow(node.id, 'out', ctx);
|
await this.follow(node.id, 'out', ctx);
|
||||||
return;
|
return;
|
||||||
@@ -549,9 +598,9 @@ class LogicEngine {
|
|||||||
const setId = (node.params.set ?? '').trim();
|
const setId = (node.params.set ?? '').trim();
|
||||||
const name = (node.params.name ?? '').trim();
|
const name = (node.params.name ?? '').trim();
|
||||||
const target = parseRef(node.params.target ?? '');
|
const target = parseRef(node.params.target ?? '');
|
||||||
if (setId) {
|
if (setId && !this.dryRun) {
|
||||||
const newId = await snapshotConfigSet(setId, name);
|
const newId = await snapshotConfigSet(setId, name);
|
||||||
if (newId && target) wsClient.write(target, newId);
|
if (newId && target) this.dryWrite(target, newId);
|
||||||
}
|
}
|
||||||
await this.follow(node.id, 'out', ctx);
|
await this.follow(node.id, 'out', ctx);
|
||||||
return;
|
return;
|
||||||
@@ -565,6 +614,7 @@ class LogicEngine {
|
|||||||
case 'action.accumulate': {
|
case 'action.accumulate': {
|
||||||
const name = (node.params.array ?? '').trim();
|
const name = (node.params.array ?? '').trim();
|
||||||
const val = evalExpr(node.params.expr ?? '', ctx.resolve);
|
const val = evalExpr(node.params.expr ?? '', ctx.resolve);
|
||||||
|
this.markActive(node.id, val);
|
||||||
if (name && !isNaN(val)) {
|
if (name && !isNaN(val)) {
|
||||||
const arr = this.arrays.get(name) ?? this.arrays.set(name, []).get(name)!;
|
const arr = this.arrays.get(name) ?? this.arrays.set(name, []).get(name)!;
|
||||||
arr.push({ t: Date.now(), v: val });
|
arr.push({ t: Date.now(), v: val });
|
||||||
@@ -574,7 +624,7 @@ class LogicEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'action.export': {
|
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);
|
await this.follow(node.id, 'out', ctx);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -589,6 +639,7 @@ class LogicEngine {
|
|||||||
case 'action.log': {
|
case 'action.log': {
|
||||||
const val = evalExpr(node.params.expr ?? '', ctx.resolve);
|
const val = evalExpr(node.params.expr ?? '', ctx.resolve);
|
||||||
const label = (node.params.label ?? '').trim();
|
const label = (node.params.label ?? '').trim();
|
||||||
|
this.markActive(node.id, val);
|
||||||
console.log(`[logic]${label ? ' ' + label + ':' : ''}`, val);
|
console.log(`[logic]${label ? ' ' + label + ':' : ''}`, val);
|
||||||
await this.follow(node.id, 'out', ctx);
|
await this.follow(node.id, 'out', ctx);
|
||||||
return;
|
return;
|
||||||
@@ -596,7 +647,7 @@ class LogicEngine {
|
|||||||
|
|
||||||
case 'action.widget': {
|
case 'action.widget': {
|
||||||
const wid = (node.params.widget ?? '').trim();
|
const wid = (node.params.widget ?? '').trim();
|
||||||
if (wid) {
|
if (wid && !this.dryRun) {
|
||||||
switch (node.params.op ?? '') {
|
switch (node.params.op ?? '') {
|
||||||
case 'enable': setWidgetDisabled(wid, false); break;
|
case 'enable': setWidgetDisabled(wid, false); break;
|
||||||
case 'disable': setWidgetDisabled(wid, true); break;
|
case 'disable': setWidgetDisabled(wid, true); break;
|
||||||
@@ -613,6 +664,7 @@ class LogicEngine {
|
|||||||
|
|
||||||
case 'action.dialog.info':
|
case 'action.dialog.info':
|
||||||
case 'action.dialog.error': {
|
case 'action.dialog.error': {
|
||||||
|
if (this.dryRun) { await this.follow(node.id, 'out', ctx); return; }
|
||||||
const specs = this.dialogFieldSpecs(node);
|
const specs = this.dialogFieldSpecs(node);
|
||||||
await this.showDialog({
|
await this.showDialog({
|
||||||
kind: node.kind === 'action.dialog.error' ? 'error' : 'info',
|
kind: node.kind === 'action.dialog.error' ? 'error' : 'info',
|
||||||
@@ -625,6 +677,7 @@ class LogicEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'action.dialog.setpoint': {
|
case 'action.dialog.setpoint': {
|
||||||
|
if (this.dryRun) { await this.follow(node.id, 'out', ctx); return; }
|
||||||
const specs = this.dialogFieldSpecs(node);
|
const specs = this.dialogFieldSpecs(node);
|
||||||
const result = await this.showDialog({
|
const result = await this.showDialog({
|
||||||
kind: 'setpoint',
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -39,6 +39,25 @@ export function opOutputType(op: string, inputs: SynthType[]): { type: SynthType
|
|||||||
return { type: 'scalar' };
|
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
|
// inferNodeTypes walks the DAG in dependency order and assigns each node an
|
||||||
// output type. Source node types come from `sourceType` (resolved from upstream
|
// output type. Source node types come from `sourceType` (resolved from upstream
|
||||||
// signal metadata); unresolved sources default to 'unknown'. `errors` collects
|
// signal metadata); unresolved sources default to 'unknown'. `errors` collects
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import type { NodeGroup } from './nodeGroups';
|
||||||
|
export type { NodeGroup };
|
||||||
|
|
||||||
// Core signal reference: identifies a signal by datasource + name
|
// Core signal reference: identifies a signal by datasource + name
|
||||||
export interface SignalRef {
|
export interface SignalRef {
|
||||||
ds: string;
|
ds: string;
|
||||||
@@ -181,6 +184,7 @@ export interface LogicWire {
|
|||||||
export interface LogicGraph {
|
export interface LogicGraph {
|
||||||
nodes: LogicNode[];
|
nodes: LogicNode[];
|
||||||
wires: LogicWire[];
|
wires: LogicWire[];
|
||||||
|
groups?: NodeGroup[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// A complete HMI interface definition
|
// A complete HMI interface definition
|
||||||
@@ -220,6 +224,10 @@ export interface Me {
|
|||||||
// Whether the user may view the audit log. False only when an audit-readers
|
// Whether the user may view the audit log. False only when an audit-readers
|
||||||
// allowlist is configured and excludes them, or auditing is disabled.
|
// allowlist is configured and excludes them, or auditing is disabled.
|
||||||
canViewAudit: boolean;
|
canViewAudit: boolean;
|
||||||
|
// Whether the user may use the admin pane (manage users/groups/access and view
|
||||||
|
// server statistics). False only when an admins allowlist is configured and
|
||||||
|
// excludes them.
|
||||||
|
canAdmin: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Effective permission a user holds on a single panel or folder.
|
// Effective permission a user holds on a single panel or folder.
|
||||||
@@ -262,6 +270,8 @@ export interface InterfaceListItem {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
version: number;
|
version: number;
|
||||||
|
// 'plot' for split-layout plot panels; absent/'panel' for free-form panels.
|
||||||
|
kind?: 'panel' | 'plot';
|
||||||
owner?: string;
|
owner?: string;
|
||||||
folder?: string;
|
folder?: string;
|
||||||
order?: number;
|
order?: number;
|
||||||
@@ -298,6 +308,7 @@ export interface SynthGraphNode {
|
|||||||
export interface SynthGraph {
|
export interface SynthGraph {
|
||||||
nodes: SynthGraphNode[];
|
nodes: SynthGraphNode[];
|
||||||
output: string;
|
output: string;
|
||||||
|
groups?: NodeGroup[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SignalDef {
|
export interface SignalDef {
|
||||||
|
|||||||
@@ -12,6 +12,25 @@ interface SubscriberEntry {
|
|||||||
onMeta: (m: SignalMeta) => void;
|
onMeta: (m: SignalMeta) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A control-logic node-execution event pushed by the server while a debug
|
||||||
|
// session is active (live observation or dry-run simulate).
|
||||||
|
export interface DebugNodeEvent {
|
||||||
|
graphId: string;
|
||||||
|
nodeId: string;
|
||||||
|
value: number;
|
||||||
|
hasValue: boolean;
|
||||||
|
ts: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A control-logic graph payload sent for dry-run simulation.
|
||||||
|
export interface DebugGraph {
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
nodes: any[];
|
||||||
|
wires: any[];
|
||||||
|
groups?: any[];
|
||||||
|
}
|
||||||
|
|
||||||
// ── WsClient ─────────────────────────────────────────────────────────────────
|
// ── WsClient ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class WsClient {
|
class WsClient {
|
||||||
@@ -35,6 +54,8 @@ class WsClient {
|
|||||||
private subscribers = new Map<string, Set<SubscriberEntry>>();
|
private subscribers = new Map<string, Set<SubscriberEntry>>();
|
||||||
// pending history request callbacks keyed by "ds\0name"
|
// pending history request callbacks keyed by "ds\0name"
|
||||||
private histCallbacks = new Map<string, Array<(pts: HistoryPoint[]) => void>>();
|
private histCallbacks = new Map<string, Array<(pts: HistoryPoint[]) => void>>();
|
||||||
|
// single control-logic debug listener (the editor currently watching)
|
||||||
|
private debugListener: ((ev: DebugNodeEvent) => void) | null = null;
|
||||||
|
|
||||||
connect(url: string): void {
|
connect(url: string): void {
|
||||||
this.url = url;
|
this.url = url;
|
||||||
@@ -174,6 +195,17 @@ class WsClient {
|
|||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 'debugNode': {
|
||||||
|
// Server-side control-logic node execution (live or simulate).
|
||||||
|
this.debugListener?.({
|
||||||
|
graphId: String(msg.graphId ?? ''),
|
||||||
|
nodeId: String(msg.nodeId ?? ''),
|
||||||
|
value: typeof msg.value === 'number' ? msg.value : 0,
|
||||||
|
hasValue: !!msg.hasValue,
|
||||||
|
ts: typeof msg.ts === 'number' ? msg.ts : Date.now(),
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
case 'error':
|
case 'error':
|
||||||
// Resolve any pending history callbacks with empty data on error
|
// Resolve any pending history callbacks with empty data on error
|
||||||
if (key && this.histCallbacks.has(key)) {
|
if (key && this.histCallbacks.has(key)) {
|
||||||
@@ -289,6 +321,39 @@ class WsClient {
|
|||||||
sendDialogResponse(id: string, value: number | null): void {
|
sendDialogResponse(id: string, value: number | null): void {
|
||||||
this._send({ type: 'dialogResponse', id, value });
|
this._send({ type: 'dialogResponse', id, value });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Control-logic debug ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Register the listener that receives node-execution events. Returns a
|
||||||
|
* cleanup that clears it (only one editor watches at a time). */
|
||||||
|
onDebugNode(cb: (ev: DebugNodeEvent) => void): () => void {
|
||||||
|
this.debugListener = cb;
|
||||||
|
return () => {
|
||||||
|
if (this.debugListener === cb) this.debugListener = null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Observe the running, enabled control-logic graph identified by graphId. */
|
||||||
|
debugSubscribeLive(graphId: string): void {
|
||||||
|
this._send({ type: 'debugSubscribe', mode: 'live', graphId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dry-run an unsaved control-logic graph in a server sandbox (no real
|
||||||
|
* writes); node events come back as debugNode messages. */
|
||||||
|
debugSubscribeSimulate(graph: DebugGraph): void {
|
||||||
|
this._send({ type: 'debugSubscribe', mode: 'simulate', graph });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop the current debug session (tears down any simulate sandbox). */
|
||||||
|
debugUnsubscribe(): void {
|
||||||
|
this._send({ type: 'debugUnsubscribe' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Force a trigger node of the current debug session (live or simulate) to
|
||||||
|
* fire now. nodeId must be a trigger node in the graph being debugged. */
|
||||||
|
debugFireTrigger(nodeId: string): void {
|
||||||
|
this._send({ type: 'fireTrigger', nodeId });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Singleton instance
|
// Singleton instance
|
||||||
|
|||||||
+26
-2
@@ -27,6 +27,14 @@ function serializeLogic(graph: LogicGraph, lines: string[]): void {
|
|||||||
? ` port="${xmlEsc(wire.fromPort)}"` : '';
|
? ` port="${xmlEsc(wire.fromPort)}"` : '';
|
||||||
lines.push(` <wire from="${xmlEsc(wire.from)}"${portAttr} to="${xmlEsc(wire.to)}"/>`);
|
lines.push(` <wire from="${xmlEsc(wire.from)}"${portAttr} to="${xmlEsc(wire.to)}"/>`);
|
||||||
}
|
}
|
||||||
|
for (const group of graph.groups ?? []) {
|
||||||
|
const collAttr = group.collapsed ? ` collapsed="1"` : '';
|
||||||
|
lines.push(` <group id="${xmlEsc(group.id)}" label="${xmlEsc(group.label)}"${collAttr}>`);
|
||||||
|
for (const member of group.members) {
|
||||||
|
lines.push(` <member id="${xmlEsc(member)}"/>`);
|
||||||
|
}
|
||||||
|
lines.push(` </group>`);
|
||||||
|
}
|
||||||
lines.push(` </logic>`);
|
lines.push(` </logic>`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,9 +127,25 @@ function parseLayout(el: Element): PlotLayout {
|
|||||||
function parseLogic(logicEl: Element): LogicGraph {
|
function parseLogic(logicEl: Element): LogicGraph {
|
||||||
const nodes: LogicGraph['nodes'] = [];
|
const nodes: LogicGraph['nodes'] = [];
|
||||||
const wires: LogicGraph['wires'] = [];
|
const wires: LogicGraph['wires'] = [];
|
||||||
|
const groups: NonNullable<LogicGraph['groups']> = [];
|
||||||
|
|
||||||
for (const el of Array.from(logicEl.children)) {
|
for (const el of Array.from(logicEl.children)) {
|
||||||
if (el.tagName === 'node') {
|
if (el.tagName === 'group') {
|
||||||
|
const id = el.getAttribute('id') ?? '';
|
||||||
|
if (!id) continue;
|
||||||
|
const members: string[] = [];
|
||||||
|
for (const child of Array.from(el.children)) {
|
||||||
|
if (child.tagName !== 'member') continue;
|
||||||
|
const mid = child.getAttribute('id');
|
||||||
|
if (mid) members.push(mid);
|
||||||
|
}
|
||||||
|
groups.push({
|
||||||
|
id,
|
||||||
|
label: el.getAttribute('label') ?? '',
|
||||||
|
members,
|
||||||
|
collapsed: el.getAttribute('collapsed') === '1',
|
||||||
|
});
|
||||||
|
} else if (el.tagName === 'node') {
|
||||||
const id = el.getAttribute('id') ?? '';
|
const id = el.getAttribute('id') ?? '';
|
||||||
if (!id) continue;
|
if (!id) continue;
|
||||||
const params: Record<string, string> = {};
|
const params: Record<string, string> = {};
|
||||||
@@ -146,7 +170,7 @@ function parseLogic(logicEl: Element): LogicGraph {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { nodes, wires };
|
return { nodes, wires, ...(groups.length ? { groups } : {}) };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseInterface(xml: string): Interface {
|
export function parseInterface(xml: string): Interface {
|
||||||
|
|||||||
+720
-3
@@ -197,6 +197,27 @@ body {
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Bottom status bar (connection chip + panel context) ─────────────────── */
|
||||||
|
.statusbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: 1.6rem;
|
||||||
|
padding: 0 0.75rem;
|
||||||
|
background: #141925;
|
||||||
|
border-top: 1px solid #2d3748;
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: 1rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
.statusbar-left,
|
||||||
|
.statusbar-right { display: flex; align-items: center; gap: 0.6rem; min-width: 0; }
|
||||||
|
.statusbar-iface { color: #cbd5e1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.statusbar-muted { color: #64748b; }
|
||||||
|
.statusbar-hist { color: #fbbf24; }
|
||||||
|
.statusbar .status-chip { padding: 0.05rem 0.5rem; font-size: 0.7rem; }
|
||||||
|
|
||||||
/* ── Panel resize handle ─────────────────────────────────────────────────── */
|
/* ── Panel resize handle ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.panel-resize-handle {
|
.panel-resize-handle {
|
||||||
@@ -386,7 +407,9 @@ body {
|
|||||||
.canvas-view-bare .led-widget,
|
.canvas-view-bare .led-widget,
|
||||||
.canvas-view-bare .multiled-widget,
|
.canvas-view-bare .multiled-widget,
|
||||||
.canvas-view-bare .setvalue,
|
.canvas-view-bare .setvalue,
|
||||||
.canvas-view-bare .plot-widget {
|
.canvas-view-bare .toggle-widget,
|
||||||
|
.canvas-view-bare .plot-widget,
|
||||||
|
.canvas-view-bare .table-widget {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
@@ -435,6 +458,178 @@ body {
|
|||||||
font-family: ui-monospace, monospace;
|
font-family: ui-monospace, monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Container pane (decorative grouping frame) ────────────────────────── */
|
||||||
|
|
||||||
|
.container-pane {
|
||||||
|
position: absolute;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: rgba(30, 37, 53, 0.45);
|
||||||
|
border: 1px solid #3d4f6e;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container-pane-nobg {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container-pane-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-bottom: 1px solid #3d4f6e;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container-pane-label {
|
||||||
|
color: #cbd5e1;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container-collapse-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #94a3b8;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container-collapse-btn:hover {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tab bar sits above the edit-mode widget overlay (z-index 10) so its tabs
|
||||||
|
stay clickable in the editor; the rest of the pane is still draggable. */
|
||||||
|
.container-tab-bar {
|
||||||
|
position: relative;
|
||||||
|
z-index: 11;
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 2px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 4px;
|
||||||
|
border-bottom: 1px solid #3d4f6e;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container-tab {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
color: #94a3b8;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
padding: 0 10px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container-tab:hover {
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container-tab-active {
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Table widget ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.table-widget {
|
||||||
|
position: absolute;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #1a1f2e;
|
||||||
|
border: 1px solid #2d3748;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tw-title {
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #cbd5e1;
|
||||||
|
border-bottom: 1px solid #2d3748;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tw-scroll {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tw-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tw-table th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: #232a3b;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: left;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-bottom: 1px solid #2d3748;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tw-table td {
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-bottom: 1px solid #232a3b;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tw-table tbody tr:hover {
|
||||||
|
background: #1e2535;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tw-name {
|
||||||
|
color: #cbd5e1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tw-value {
|
||||||
|
font-family: ui-monospace, Consolas, monospace;
|
||||||
|
color: #e2e8f0;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tw-unit { color: #64748b; }
|
||||||
|
.tw-time { color: #64748b; font-variant-numeric: tabular-nums; }
|
||||||
|
.tw-quality { text-align: center; width: 1px; }
|
||||||
|
|
||||||
|
.tw-empty {
|
||||||
|
padding: 10px 8px;
|
||||||
|
color: #475569;
|
||||||
|
font-style: italic;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Properties: checkbox list ─────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.prop-check {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: #cbd5e1;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Context Menu ──────────────────────────────────────────────────────── */
|
/* ── Context Menu ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.ctx-menu {
|
.ctx-menu {
|
||||||
@@ -945,6 +1140,74 @@ body {
|
|||||||
}
|
}
|
||||||
.sv-btn:disabled:hover { background: #2563eb; }
|
.sv-btn:disabled:hover { background: #2563eb; }
|
||||||
|
|
||||||
|
/* ── Toggle switch widget ──────────────────────────────────────────────── */
|
||||||
|
.toggle-widget {
|
||||||
|
position: absolute;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 8px;
|
||||||
|
background: #1a1f2e;
|
||||||
|
border: 1px solid #2d3748;
|
||||||
|
border-radius: 6px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: system-ui, sans-serif;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.toggle-label {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.toggle-switch {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
margin-left: auto;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.toggle-track {
|
||||||
|
position: relative;
|
||||||
|
width: 34px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: #3d4f6e;
|
||||||
|
transition: background 0.15s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.toggle-knob {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #e2e8f0;
|
||||||
|
transition: transform 0.15s;
|
||||||
|
}
|
||||||
|
.toggle-on .toggle-track { background: #22c55e; }
|
||||||
|
.toggle-on .toggle-knob { transform: translateX(16px); }
|
||||||
|
.toggle-unknown .toggle-track { background: #4b5563; }
|
||||||
|
.toggle-state {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #94a3b8;
|
||||||
|
min-width: 1.6rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.toggle-on .toggle-state { color: #86efac; }
|
||||||
|
.toggle-switch:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Button widget ─────────────────────────────────────────────────────── */
|
/* ── Button widget ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.button-widget {
|
.button-widget {
|
||||||
@@ -1066,6 +1329,85 @@ body {
|
|||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Plot hover toolbar (pause / window / clear / screenshot) ─────────────── */
|
||||||
|
.plot-toolbar {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
right: 4px;
|
||||||
|
z-index: 12;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 2px 3px;
|
||||||
|
background: rgba(15, 17, 23, 0.85);
|
||||||
|
border: 1px solid #2d3748;
|
||||||
|
border-radius: 5px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.12s;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.plot-widget:hover .plot-toolbar { opacity: 1; pointer-events: auto; }
|
||||||
|
.plot-tb-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #cbd5e1;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 2px 5px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.plot-tb-btn:hover { background: #2d3748; }
|
||||||
|
.plot-tb-active { background: #2563eb; color: #fff; }
|
||||||
|
.plot-tb-active:hover { background: #1d4ed8; }
|
||||||
|
.plot-tb-select {
|
||||||
|
background: #0f1117;
|
||||||
|
border: 1px solid #3d4f6e;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
padding: 1px 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.plot-tb-select:focus { outline: none; border-color: #4a9eff; }
|
||||||
|
|
||||||
|
/* ── Plot measurement readout (cursor A/B + Δt + per-series Δ) ─────────────── */
|
||||||
|
.measure-readout {
|
||||||
|
position: absolute;
|
||||||
|
top: 30px;
|
||||||
|
left: 4px;
|
||||||
|
z-index: 12;
|
||||||
|
max-width: 70%;
|
||||||
|
padding: 4px 6px;
|
||||||
|
background: rgba(15, 17, 23, 0.9);
|
||||||
|
border: 1px solid #2d3748;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
color: #cbd5e1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.measure-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: space-between;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.measure-head {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #e2e8f0;
|
||||||
|
border-bottom: 1px solid #2d3748;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
}
|
||||||
|
.measure-name {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 9rem;
|
||||||
|
}
|
||||||
|
.measure-vals { color: #e2e8f0; }
|
||||||
|
.measure-delta { color: #94a3b8; }
|
||||||
|
|
||||||
/* uPlot dark overrides */
|
/* uPlot dark overrides */
|
||||||
.chart-area .u-wrap { background: transparent; }
|
.chart-area .u-wrap { background: transparent; }
|
||||||
.chart-area .u-legend { color: #94a3b8; font-size: 0.75rem; }
|
.chart-area .u-legend { color: #94a3b8; font-size: 0.75rem; }
|
||||||
@@ -1239,10 +1581,22 @@ body {
|
|||||||
|
|
||||||
.flow-palette-toolbar {
|
.flow-palette-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.35rem;
|
gap: 0.25rem;
|
||||||
margin-bottom: 0.15rem;
|
margin-bottom: 0.15rem;
|
||||||
}
|
}
|
||||||
.flow-palette-toolbar .toolbar-btn { flex: 1; }
|
/* Equal-width buttons that may shrink below their content; the wide horizontal
|
||||||
|
padding of .toolbar-btn would otherwise overflow the fixed-width palette and
|
||||||
|
clip the last (fit-zoom) button. The slightly smaller font keeps the labels
|
||||||
|
readable once the buttons are squeezed. */
|
||||||
|
.flow-palette-toolbar .toolbar-btn { flex: 1 1 0; min-width: 0; padding-left: 0; padding-right: 0; font-size: 0.75rem; }
|
||||||
|
/* The zoom-value button shows "100%" and needs more room than the single-glyph
|
||||||
|
icon buttons next to it, otherwise the percentage overflows / clips. */
|
||||||
|
.flow-palette-toolbar .toolbar-btn.flow-zoom-pct { flex: 1.9 1 0; font-variant-numeric: tabular-nums; }
|
||||||
|
/* Live/debug toggle — green when active to signal the graph is being evaluated. */
|
||||||
|
.toolbar-btn.flow-debug-on { background: #047857; color: #ecfdf5; }
|
||||||
|
.toolbar-btn.flow-debug-on:hover { background: #059669; }
|
||||||
|
/* Live/Simulate mode sub-toggle shows a short word, so give it room to read. */
|
||||||
|
.flow-palette-toolbar .toolbar-btn.flow-debug-mode { flex: 1.6 1 0; }
|
||||||
|
|
||||||
.flow-palette-title {
|
.flow-palette-title {
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
@@ -1290,6 +1644,13 @@ body {
|
|||||||
height: 87.5rem;
|
height: 87.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Zoom layer: scaled via CSS transform; the parent .flow-canvas-inner is sized
|
||||||
|
to the scaled dimensions so the scroll container reports the right area. */
|
||||||
|
.flow-canvas-zoom {
|
||||||
|
position: relative;
|
||||||
|
transform-origin: 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
.flow-wires {
|
.flow-wires {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0; left: 0;
|
top: 0; left: 0;
|
||||||
@@ -1330,9 +1691,104 @@ body {
|
|||||||
.flow-node-flow { border-top: 3px solid #10b981; }
|
.flow-node-flow { border-top: 3px solid #10b981; }
|
||||||
.flow-node-action { border-top: 3px solid #3b82f6; }
|
.flow-node-action { border-top: 3px solid #3b82f6; }
|
||||||
.flow-node-selected { border-color: #3b82f6; box-shadow: 0 0 0 2px #3b82f680; }
|
.flow-node-selected { border-color: #3b82f6; box-shadow: 0 0 0 2px #3b82f680; }
|
||||||
|
.flow-node-multi { border-color: #38bdf8; box-shadow: 0 0 0 2px #38bdf855; }
|
||||||
.flow-node-error { border-color: #ef4444; box-shadow: 0 0 0 2px #ef444455; }
|
.flow-node-error { border-color: #ef4444; box-shadow: 0 0 0 2px #ef444455; }
|
||||||
.flow-node-error.flow-node-selected { box-shadow: 0 0 0 2px #ef4444aa; }
|
.flow-node-error.flow-node-selected { box-shadow: 0 0 0 2px #ef4444aa; }
|
||||||
|
|
||||||
|
/* Live/debug mode (shared by all three editors). A node that produced a value
|
||||||
|
this tick gets a soft green pulse; a node whose evaluation errored gets an
|
||||||
|
amber tint distinct from the red .flow-node-error (which means invalid wiring). */
|
||||||
|
.flow-node-active { border-color: #10b981; box-shadow: 0 0 0 2px #10b98155; animation: flow-node-pulse 1s ease-in-out infinite; }
|
||||||
|
.flow-node-debug-error { border-color: #f59e0b; box-shadow: 0 0 0 2px #f59e0b55; }
|
||||||
|
@keyframes flow-node-pulse {
|
||||||
|
0%, 100% { box-shadow: 0 0 0 2px #10b98140; }
|
||||||
|
50% { box-shadow: 0 0 0 3px #10b98180; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* In debug mode, trigger nodes can be double-clicked to force a flow run. */
|
||||||
|
.flow-node-firable { cursor: pointer; }
|
||||||
|
.flow-node-firable:hover { border-color: #38bdf8; box-shadow: 0 0 0 2px #38bdf855; }
|
||||||
|
|
||||||
|
/* On-node value chip, pinned to the node's top-right corner. */
|
||||||
|
.flow-node-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -0.6rem;
|
||||||
|
right: -0.3rem;
|
||||||
|
max-width: 5rem;
|
||||||
|
padding: 0.05rem 0.35rem;
|
||||||
|
border-radius: 0.6rem;
|
||||||
|
background: #0b3b2e;
|
||||||
|
border: 1px solid #10b981;
|
||||||
|
color: #d1fae5;
|
||||||
|
font-size: 0.62rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
line-height: 1.3;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
.flow-node-badge.approx { background: #3b2f0b; border-color: #f59e0b; color: #fde68a; font-style: italic; }
|
||||||
|
.flow-node-badge.error { background: #3b0b0b; border-color: #ef4444; color: #fecaca; }
|
||||||
|
|
||||||
|
/* Wire carrying live data this tick — animated dashes flowing source→target. */
|
||||||
|
.flow-wire-active { stroke: #10b981; stroke-dasharray: 6 4; animation: flow-wire-dash 0.6s linear infinite; }
|
||||||
|
@keyframes flow-wire-dash { to { stroke-dashoffset: -20; } }
|
||||||
|
|
||||||
|
/* Node groups — a labelled frame drawn around member nodes; collapses to a box.
|
||||||
|
An opened group (and its members) is lifted above loose nodes via z-index so
|
||||||
|
its header/border are never obscured by unrelated nodes drawn on top: the
|
||||||
|
frame sits at z-index 1 (above loose nodes), and its member nodes get
|
||||||
|
.flow-node-grouped at z-index 2 so they still draw above their own frame and
|
||||||
|
stay interactive. */
|
||||||
|
.flow-group-frame {
|
||||||
|
position: absolute;
|
||||||
|
border: 1.5px dashed #475569;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #94a3b80f;
|
||||||
|
cursor: move;
|
||||||
|
user-select: none;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.flow-group-box {
|
||||||
|
position: absolute;
|
||||||
|
border: 1.5px solid #475569;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #161b27;
|
||||||
|
box-shadow: 0 2px 6px #0006;
|
||||||
|
cursor: move;
|
||||||
|
user-select: none;
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.flow-node-grouped { z-index: 2; }
|
||||||
|
.flow-group-selected { border-color: #3b82f6; box-shadow: 0 0 0 2px #3b82f655; }
|
||||||
|
.flow-group-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
height: 1.65rem;
|
||||||
|
padding: 0 0.35rem;
|
||||||
|
}
|
||||||
|
.flow-group-caret {
|
||||||
|
background: none; border: none; color: #94a3b8;
|
||||||
|
cursor: pointer; font-size: 0.75rem; padding: 0 0.15rem; line-height: 1;
|
||||||
|
}
|
||||||
|
.flow-group-caret:hover { color: #e2e8f0; }
|
||||||
|
.flow-group-label {
|
||||||
|
flex: 1; min-width: 0;
|
||||||
|
background: transparent; border: none; outline: none;
|
||||||
|
color: #cbd5e1; font-size: 0.8rem; font-weight: 600;
|
||||||
|
}
|
||||||
|
.flow-group-label:focus { color: #fff; }
|
||||||
|
.flow-group-del {
|
||||||
|
background: none; border: none; color: #64748b;
|
||||||
|
cursor: pointer; font-size: 0.7rem; padding: 0 0.15rem; line-height: 1;
|
||||||
|
}
|
||||||
|
.flow-group-del:hover { color: #ef4444; }
|
||||||
|
.flow-group-count { padding: 0.1rem 0.6rem 0.35rem; font-size: 0.7rem; }
|
||||||
|
|
||||||
.flow-node-header {
|
.flow-node-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1370,6 +1826,14 @@ body {
|
|||||||
.flow-port-body { border-color: #10b981; }
|
.flow-port-body { border-color: #10b981; }
|
||||||
.flow-port-else { border-color: #ef4444; }
|
.flow-port-else { border-color: #ef4444; }
|
||||||
|
|
||||||
|
/* Synthetic-editor port data-type colouring (scoped to .synth-graph so the
|
||||||
|
Logic / ControlLogic editors that share .flow-port are unaffected). An 'any'
|
||||||
|
port (accepts either type) keeps the default slate; scalar is blue, array
|
||||||
|
purple — mirroring the wire palette so a port previews what it carries/accepts. */
|
||||||
|
.synth-graph .flow-port-type-scalar { border-color: #3b82f6; }
|
||||||
|
.synth-graph .flow-port-type-array { border-color: #a855f7; }
|
||||||
|
.synth-graph .flow-port-type-any { border-color: #64748b; }
|
||||||
|
|
||||||
.flow-port-label {
|
.flow-port-label {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 10px;
|
right: 10px;
|
||||||
@@ -2397,6 +2861,66 @@ body {
|
|||||||
margin: 0 4px;
|
margin: 0 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Disabled rule: dim the list row and tag it with an "off" badge. */
|
||||||
|
.cl-list-item.cfg-rule-disabled .cl-list-name { opacity: 0.55; }
|
||||||
|
.cfg-rule-off {
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: #f0a868;
|
||||||
|
border: 1px solid #6b4a2e;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 0 4px;
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Enabled toggle in the rule editor param row. */
|
||||||
|
.cfg-field-enabled { min-width: 11em; }
|
||||||
|
.cfg-rule-enable {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.cfg-rule-enable input { cursor: pointer; }
|
||||||
|
|
||||||
|
/* Live preview panel (run rule against a live snapshot). */
|
||||||
|
.cfg-rule-preview {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid #2a3550;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #111726;
|
||||||
|
}
|
||||||
|
.cfg-rule-preview-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.cfg-rule-preview-head .cl-mini-btn { margin-left: auto; }
|
||||||
|
.cfg-rule-preview-failed { margin: 4px 0; color: #f0a868; }
|
||||||
|
.cfg-rule-preview-cols {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.cfg-rule-preview-col { min-width: 0; }
|
||||||
|
.cfg-json {
|
||||||
|
margin: 2px 0 0;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: #0b0f1a;
|
||||||
|
border: 1px solid #1f2940;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.45;
|
||||||
|
max-height: 16em;
|
||||||
|
overflow: auto;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
|
||||||
/* wizard is already wide enough and resizable — no extra :has() rule needed */
|
/* wizard is already wide enough and resizable — no extra :has() rule needed */
|
||||||
|
|
||||||
/* ── Synthetic pipeline ─────────────────────────────────────────────────────── */
|
/* ── Synthetic pipeline ─────────────────────────────────────────────────────── */
|
||||||
@@ -2578,6 +3102,14 @@ body {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.iface-item-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-right: 0.4rem;
|
||||||
|
vertical-align: -2px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
.iface-item-actions {
|
.iface-item-actions {
|
||||||
display: none;
|
display: none;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
@@ -2713,6 +3245,10 @@ body {
|
|||||||
padding: 4px 0;
|
padding: 4px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Right-aligned variant (toolbar-right dropdowns expand leftward to stay
|
||||||
|
on-screen instead of overflowing the viewport edge). */
|
||||||
|
.toolbar-dropdown-menu-right { left: auto; right: 0; }
|
||||||
|
|
||||||
/* Align toolbar */
|
/* Align toolbar */
|
||||||
.align-toolbar {
|
.align-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -3903,15 +4439,21 @@ kbd {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
gap: 0.4rem;
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
border-bottom: 1px solid #2d3748;
|
border-bottom: 1px solid #2d3748;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
.cl-list-head .audit-filter { flex: 1 1 auto; min-width: 0; }
|
||||||
|
.cl-list-head .panel-btn { flex: 0 0 auto; }
|
||||||
|
|
||||||
.cl-list-empty { padding: 0.75rem; font-size: 0.8rem; }
|
.cl-list-empty { padding: 0.75rem; font-size: 0.8rem; }
|
||||||
|
|
||||||
|
.cl-list-filter { padding: 0.4rem 0.5rem; border-bottom: 1px solid #2d3748; }
|
||||||
|
.cl-list-filter .prop-select { width: 100%; }
|
||||||
|
|
||||||
.cl-list-item {
|
.cl-list-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -3962,6 +4504,181 @@ kbd {
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Admin pane ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.admin-empty {
|
||||||
|
padding: 1.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-editor {
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-editor-title {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-field-label {
|
||||||
|
margin-top: 0.6rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-input {
|
||||||
|
background: #0f1521;
|
||||||
|
border: 1px solid #2d3748;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #e2e8f0;
|
||||||
|
padding: 0.4rem 0.55rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-editor-actions {
|
||||||
|
margin-top: 1rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-banner {
|
||||||
|
margin: 0.5rem 1rem 0;
|
||||||
|
padding: 0.55rem 0.75rem;
|
||||||
|
border: 1px solid rgba(234, 179, 8, 0.35);
|
||||||
|
background: rgba(234, 179, 8, 0.1);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Effective/per-group role pill, coloured along the ladder. */
|
||||||
|
.admin-role-tag {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
padding: 0.05rem 0.4rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-role-viewer { background: rgba(148, 163, 184, 0.18); color: #cbd5e1; }
|
||||||
|
.admin-role-operator { background: rgba(34, 197, 94, 0.18); color: #4ade80; }
|
||||||
|
.admin-role-logiceditor { background: rgba(56, 189, 248, 0.18); color: #38bdf8; }
|
||||||
|
.admin-role-auditor { background: rgba(168, 85, 247, 0.18); color: #c084fc; }
|
||||||
|
.admin-role-admin { background: rgba(234, 88, 12, 0.2); color: #fb923c; }
|
||||||
|
|
||||||
|
/* Per-group role assignment table (users & group-member editors). */
|
||||||
|
.admin-role-table {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-role-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-role-group {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: #cbd5e1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-tree-mark {
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-role-select {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-width: 9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-member-add {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-member-add .admin-input {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-member-add .panel-btn {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-row-remove {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #94a3b8;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.2rem 0.35rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-row-remove:hover {
|
||||||
|
color: #f87171;
|
||||||
|
background: rgba(220, 38, 38, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stats {
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stats-table {
|
||||||
|
max-width: 32rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stat-key {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-weight: 600;
|
||||||
|
width: 14rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stat-val {
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stats-ds {
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-ds-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-ds-tag {
|
||||||
|
background: #1d2433;
|
||||||
|
border: 1px solid #2d3748;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
.cl-graph-bar {
|
.cl-graph-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import type { Widget } from '../lib/types';
|
||||||
|
import { tabLabels } from '../lib/containers';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
widget: Widget;
|
||||||
|
onContextMenu?: (e: MouseEvent) => void;
|
||||||
|
// View-mode collapse state: supplied (with a toggle) only by the live Canvas.
|
||||||
|
// In the editor these are undefined, so the pane always renders expanded.
|
||||||
|
collapsed?: boolean;
|
||||||
|
onToggleCollapse?: () => void;
|
||||||
|
// Tabbed-mode active tab + selector. Supplied by both Canvas (view) and
|
||||||
|
// EditCanvas (edit) so tabs can be switched in either mode.
|
||||||
|
activeTab?: number;
|
||||||
|
onSelectTab?: (i: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TITLE_H = 24;
|
||||||
|
|
||||||
|
// A purely decorative grouping frame placed behind other widgets on the
|
||||||
|
// free-form canvas (it does not re-parent them). Two variants:
|
||||||
|
// - 'pane': a titled / collapsible frame; collapsing in view mode hides the
|
||||||
|
// widgets geometrically inside it and shrinks the pane to its title bar.
|
||||||
|
// - 'tabs': a tab bar; only widgets assigned to the active tab are shown.
|
||||||
|
export default function Container({ widget, onContextMenu, collapsed, onToggleCollapse, activeTab, onSelectTab }: Props) {
|
||||||
|
const o = widget.options;
|
||||||
|
const variant = o['variant'] === 'tabs' ? 'tabs' : 'pane';
|
||||||
|
const showBg = o['bg'] !== 'false';
|
||||||
|
const accent = o['accent'] || '#3d4f6e';
|
||||||
|
|
||||||
|
if (variant === 'tabs') {
|
||||||
|
const tabs = tabLabels(widget);
|
||||||
|
const active = Math.min(Math.max(activeTab ?? 0, 0), tabs.length - 1);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class={`container-pane${showBg ? '' : ' container-pane-nobg'}`}
|
||||||
|
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;border-color:${accent};`}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
|
>
|
||||||
|
<div class="container-tab-bar" style={`border-color:${accent};`}>
|
||||||
|
{tabs.map((t, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
class={`container-tab${i === active ? ' container-tab-active' : ''}`}
|
||||||
|
style={i === active ? `border-bottom-color:${accent};` : ''}
|
||||||
|
onClick={(e) => { e.stopPropagation(); onSelectTab && onSelectTab(i); }}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
>{t}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = o['title'] ?? '';
|
||||||
|
const collapsible = o['collapsible'] === 'true';
|
||||||
|
// Collapse only applies in view mode (onToggleCollapse present) and when enabled.
|
||||||
|
const canCollapse = collapsible && !!onToggleCollapse;
|
||||||
|
const isCollapsed = canCollapse && !!collapsed;
|
||||||
|
const height = isCollapsed ? TITLE_H : widget.h;
|
||||||
|
const hasTitleBar = title !== '' || canCollapse;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class={`container-pane${showBg && !isCollapsed ? '' : ' container-pane-nobg'}`}
|
||||||
|
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${height}px;border-color:${accent};`}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
|
>
|
||||||
|
{hasTitleBar && (
|
||||||
|
<div class="container-pane-title" style={`border-color:${accent};`}>
|
||||||
|
{canCollapse && (
|
||||||
|
<button
|
||||||
|
class="container-collapse-btn"
|
||||||
|
title={isCollapsed ? 'Expand' : 'Collapse'}
|
||||||
|
onClick={(e) => { e.stopPropagation(); onToggleCollapse!(); }}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
>{isCollapsed ? '▸' : '▾'}</button>
|
||||||
|
)}
|
||||||
|
<span class="container-pane-label">{title}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { getWidgetCmdStore } from '../lib/widgetCommands';
|
|||||||
import { wsClient } from '../lib/ws';
|
import { wsClient } from '../lib/ws';
|
||||||
import { formatValue } from '../lib/format';
|
import { formatValue } from '../lib/format';
|
||||||
import { fftMag, resampleUniform } from '../lib/fft';
|
import { fftMag, resampleUniform } from '../lib/fft';
|
||||||
|
import { PLOT_SYNC_KEY, subscribeXRange, broadcastXRange } from '../lib/plotSync';
|
||||||
import type { Widget, SignalRef } from '../lib/types';
|
import type { Widget, SignalRef } from '../lib/types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -20,6 +21,19 @@ interface SeriesBuffer {
|
|||||||
values: number[];
|
values: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MeasureRow {
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
va: number | null;
|
||||||
|
vb: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MeasureReadout {
|
||||||
|
a: number; // cursor A time (seconds)
|
||||||
|
b: number | null; // cursor B time (seconds), once placed
|
||||||
|
rows: MeasureRow[];
|
||||||
|
}
|
||||||
|
|
||||||
const RING_MAX = 200_000;
|
const RING_MAX = 200_000;
|
||||||
const COLORS = ['#4a9eff', '#22c55e', '#f59e0b', '#ef4444', '#a78bfa', '#f472b6', '#34d399', '#fb923c'];
|
const COLORS = ['#4a9eff', '#22c55e', '#f59e0b', '#ef4444', '#a78bfa', '#f472b6', '#34d399', '#fb923c'];
|
||||||
|
|
||||||
@@ -47,6 +61,39 @@ function windowedSlice(buf: SeriesBuffer, windowSec: number) {
|
|||||||
return { ts: buf.timestamps.slice(start), vals: buf.values.slice(start) };
|
return { ts: buf.timestamps.slice(start), vals: buf.values.slice(start) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Compact display of a measured value (null → em-dash).
|
||||||
|
function fmtNum(v: number | null): string {
|
||||||
|
if (v == null || !isFinite(v)) return '—';
|
||||||
|
const a = Math.abs(v);
|
||||||
|
if (a !== 0 && (a < 1e-3 || a >= 1e6)) return v.toExponential(3);
|
||||||
|
return v.toFixed(Math.abs(v) >= 100 ? 1 : 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Human-readable Δt between two cursors (seconds), with rate where useful.
|
||||||
|
function formatDt(dt: number): string {
|
||||||
|
const a = Math.abs(dt);
|
||||||
|
if (a < 1) return `${(dt * 1000).toFixed(1)} ms`;
|
||||||
|
if (a < 60) return `${dt.toFixed(3)} s (${(1 / dt).toFixed(2)} Hz)`;
|
||||||
|
if (a < 3600) return `${(dt / 60).toFixed(2)} min`;
|
||||||
|
return `${(dt / 3600).toFixed(2)} h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step-hold value of a series at time t (seconds): the most recent sample whose
|
||||||
|
// timestamp is <= t. Used by the measurement cursors. Binary search over the
|
||||||
|
// (sorted) timestamp buffer.
|
||||||
|
function valueAt(buf: SeriesBuffer, t: number): number | null {
|
||||||
|
const ts = buf.timestamps;
|
||||||
|
if (ts.length === 0) return null;
|
||||||
|
let lo = 0, hi = ts.length - 1, res = -1;
|
||||||
|
while (lo <= hi) {
|
||||||
|
const mid = (lo + hi) >> 1;
|
||||||
|
if (ts[mid] <= t) { res = mid; lo = mid + 1; }
|
||||||
|
else hi = mid - 1;
|
||||||
|
}
|
||||||
|
if (res === -1) return null; // t is before the first sample
|
||||||
|
return buf.values[res];
|
||||||
|
}
|
||||||
|
|
||||||
function buildHistogram(allVals: number[][]): { labels: string[]; counts: number[][] } {
|
function buildHistogram(allVals: number[][]): { labels: string[]; counts: number[][] } {
|
||||||
const BUCKETS = 20;
|
const BUCKETS = 20;
|
||||||
let lo = Infinity, hi = -Infinity;
|
let lo = Infinity, hi = -Infinity;
|
||||||
@@ -67,6 +114,108 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
|
|||||||
const chartRef = useRef<HTMLDivElement>(null);
|
const chartRef = useRef<HTMLDivElement>(null);
|
||||||
const [histStatus, setHistStatus] = useState<'idle' | 'loading' | 'empty' | 'error'>('idle');
|
const [histStatus, setHistStatus] = useState<'idle' | 'loading' | 'empty' | 'error'>('idle');
|
||||||
|
|
||||||
|
// Live chart instances + sample buffers held in refs so the hover toolbar can
|
||||||
|
// pause/clear/screenshot without tearing down and recreating the chart. The
|
||||||
|
// buffers persist across effect re-runs (plot-type / window / legend changes),
|
||||||
|
// keyed by widget+signals+time-range so only a genuine data-source change
|
||||||
|
// resets them.
|
||||||
|
const uplotRef = useRef<uPlot | null>(null);
|
||||||
|
const echartRef = useRef<echarts.EChartsType | null>(null);
|
||||||
|
const buffersRef = useRef<{ key: string; bufs: SeriesBuffer[]; waves: number[][] } | null>(null);
|
||||||
|
// Toolbar state: local pause (OR'd with panel-logic pause) and a view-time
|
||||||
|
// rolling-window override (null = use the configured timeWindow option).
|
||||||
|
const [paused, setPaused] = useState(false);
|
||||||
|
const pausedRef = useRef(false);
|
||||||
|
useEffect(() => { pausedRef.current = paused; }, [paused]);
|
||||||
|
const [winOverride, setWinOverride] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// Cross-plot link: when on, this plot joins the shared cursor + x-range sync
|
||||||
|
// group (see lib/plotSync). Toggling re-inits the chart (cursor.sync is an
|
||||||
|
// init-time uPlot option); buffers persist via buffersRef.
|
||||||
|
const [linked, setLinked] = useState(true);
|
||||||
|
// Measurement cursors: marksRef holds two x-times (A/B) on the time axis; the
|
||||||
|
// readout panel shows Δt plus each series' value at A, B and ΔV. Toggling
|
||||||
|
// measure mode does NOT re-init the chart — the draw hook and click handler
|
||||||
|
// read live refs and a redraw() is enough.
|
||||||
|
const [measureOn, setMeasureOn] = useState(false);
|
||||||
|
const measureOnRef = useRef(false);
|
||||||
|
const marksRef = useRef<{ a: number | null; b: number | null }>({ a: null, b: null });
|
||||||
|
const [readout, setReadout] = useState<MeasureReadout | null>(null);
|
||||||
|
// Guards a feedback loop when applying a synced x-range back into uPlot.
|
||||||
|
const applyingSyncRef = useRef(false);
|
||||||
|
|
||||||
|
// Recompute the measurement readout from the current marks + buffers.
|
||||||
|
function refreshReadout() {
|
||||||
|
const { a, b } = marksRef.current;
|
||||||
|
const b0 = buffersRef.current?.bufs;
|
||||||
|
if (a == null || !b0) { setReadout(null); return; }
|
||||||
|
const rows: MeasureRow[] = widget.signals.map((s, i) => ({
|
||||||
|
name: s.name,
|
||||||
|
color: s.color ?? COLORS[i % COLORS.length],
|
||||||
|
va: b0[i] ? valueAt(b0[i], a) : null,
|
||||||
|
vb: b != null && b0[i] ? valueAt(b0[i], b) : null,
|
||||||
|
}));
|
||||||
|
setReadout({ a, b, rows });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Place / move measurement cursors on click while in measure mode.
|
||||||
|
function onMeasureClick() {
|
||||||
|
const u = uplotRef.current;
|
||||||
|
const left = u ? (u as any).cursor?.left : null;
|
||||||
|
if (!measureOnRef.current || !u || left == null || left < 0) return;
|
||||||
|
const val = (u as any).posToVal(left, 'x');
|
||||||
|
const m = marksRef.current;
|
||||||
|
// 1st click (or restart after both set) → A; 2nd click → B.
|
||||||
|
if (m.a == null || m.b != null) { m.a = val; m.b = null; }
|
||||||
|
else m.b = val;
|
||||||
|
refreshReadout();
|
||||||
|
(u as any).redraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleMeasure() {
|
||||||
|
const on = !measureOnRef.current;
|
||||||
|
measureOnRef.current = on;
|
||||||
|
setMeasureOn(on);
|
||||||
|
if (!on) { marksRef.current = { a: null, b: null }; setReadout(null); }
|
||||||
|
(uplotRef.current as any)?.redraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Toolbar handlers (operate on the live ref instances) ──────────────────
|
||||||
|
function clearPlot() {
|
||||||
|
const b = buffersRef.current;
|
||||||
|
if (!b) return;
|
||||||
|
b.bufs.forEach(buf => { buf.timestamps.length = 0; buf.values.length = 0; });
|
||||||
|
b.waves.forEach(w => { w.length = 0; });
|
||||||
|
uplotRef.current?.setData([[], ...b.bufs.map(() => [])] as uPlot.AlignedData);
|
||||||
|
}
|
||||||
|
|
||||||
|
function screenshot() {
|
||||||
|
let dataUrl: string | undefined;
|
||||||
|
const canvas = chartRef.current?.querySelector('canvas');
|
||||||
|
if (uplotRef.current && canvas) {
|
||||||
|
dataUrl = canvas.toDataURL('image/png');
|
||||||
|
} else if (echartRef.current) {
|
||||||
|
dataUrl = (echartRef.current as any).getDataURL({ pixelRatio: 2, backgroundColor: '#1a1f2e' });
|
||||||
|
}
|
||||||
|
if (!dataUrl) return;
|
||||||
|
const name = (widget.signals[0]?.name ?? 'plot').replace(/[^\w.-]+/g, '_');
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = dataUrl;
|
||||||
|
a.download = `${name}-${new Date().toISOString().replace(/[:.]/g, '-')}.png`;
|
||||||
|
a.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rolling-window presets (seconds); null = use the widget's configured window.
|
||||||
|
const WINDOW_PRESETS: { label: string; val: number | null }[] = [
|
||||||
|
{ label: 'Auto', val: null },
|
||||||
|
{ label: '15s', val: 15 },
|
||||||
|
{ label: '30s', val: 30 },
|
||||||
|
{ label: '1m', val: 60 },
|
||||||
|
{ label: '5m', val: 300 },
|
||||||
|
{ label: '15m', val: 900 },
|
||||||
|
{ label: '1h', val: 3600 },
|
||||||
|
];
|
||||||
|
|
||||||
// Stable primitive deps so the effect re-runs when plotType or signals change.
|
// Stable primitive deps so the effect re-runs when plotType or signals change.
|
||||||
const timeRangeKey = timeRange ? `${timeRange.start}|${timeRange.end}` : 'live';
|
const timeRangeKey = timeRange ? `${timeRange.start}|${timeRange.end}` : 'live';
|
||||||
const plotType = widget.options['plotType'] ?? 'timeseries';
|
const plotType = widget.options['plotType'] ?? 'timeseries';
|
||||||
@@ -77,15 +226,26 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
|
|||||||
if (!chartRef.current) return;
|
if (!chartRef.current) return;
|
||||||
|
|
||||||
const signals = widget.signals;
|
const signals = widget.signals;
|
||||||
const timeWindow = parseFloat(widget.options['timeWindow'] ?? '60');
|
const timeWindow = winOverride ?? parseFloat(widget.options['timeWindow'] ?? '60');
|
||||||
const yMin = widget.options['yMin'];
|
const yMin = widget.options['yMin'];
|
||||||
const yMax = widget.options['yMax'];
|
const yMax = widget.options['yMax'];
|
||||||
const legendPos = widget.options['legend'] ?? 'bottom';
|
const legendPos = widget.options['legend'] ?? 'bottom';
|
||||||
const showLegend = legendPos !== 'none';
|
const showLegend = legendPos !== 'none';
|
||||||
|
|
||||||
const buffers: SeriesBuffer[] = signals.map(() => ({ timestamps: [], values: [] }));
|
// Reuse persisted buffers across effect re-runs (plot-type / window / legend
|
||||||
|
// changes) so the toolbar and a window switch don't drop live history; reset
|
||||||
|
// only when the widget, its signals or the time-range change.
|
||||||
|
const bufKey = `${widget.id}|${signalsKey}|${timeRangeKey}`;
|
||||||
|
if (!buffersRef.current || buffersRef.current.key !== bufKey) {
|
||||||
|
buffersRef.current = {
|
||||||
|
key: bufKey,
|
||||||
|
bufs: signals.map(() => ({ timestamps: [], values: [] })),
|
||||||
|
waves: signals.map(() => []),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const buffers: SeriesBuffer[] = buffersRef.current.bufs;
|
||||||
// Latest waveform (array) value per signal — used only by plotType 'waveform'.
|
// Latest waveform (array) value per signal — used only by plotType 'waveform'.
|
||||||
const waveforms: number[][] = signals.map(() => []);
|
const waveforms: number[][] = buffersRef.current.waves;
|
||||||
const unsubs: (() => void)[] = [];
|
const unsubs: (() => void)[] = [];
|
||||||
const fmt = widget.options['format'] ?? '';
|
const fmt = widget.options['format'] ?? '';
|
||||||
|
|
||||||
@@ -139,6 +299,29 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
|
|||||||
] as uPlot.AlignedData;
|
] as uPlot.AlignedData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Draw the measurement cursor lines (A=amber, B=green) onto the uPlot canvas
|
||||||
|
// so they track the data on zoom / pan / resize. Runs in the 'draw' hook.
|
||||||
|
function drawMeasureMarks(u: uPlot) {
|
||||||
|
const { a, b } = marksRef.current;
|
||||||
|
if (a == null && b == null) return;
|
||||||
|
const ctx = (u as any).ctx as CanvasRenderingContext2D;
|
||||||
|
const bb = (u as any).bbox as { top: number; height: number };
|
||||||
|
ctx.save();
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.setLineDash([4, 3]);
|
||||||
|
const line = (val: number, color: string) => {
|
||||||
|
const x = (u as any).valToPos(val, 'x', true);
|
||||||
|
ctx.strokeStyle = color;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, bb.top);
|
||||||
|
ctx.lineTo(x, bb.top + bb.height);
|
||||||
|
ctx.stroke();
|
||||||
|
};
|
||||||
|
if (a != null) line(a, '#f59e0b');
|
||||||
|
if (b != null) line(b, '#22c55e');
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
function makeUplotOpts(): uPlot.Options {
|
function makeUplotOpts(): uPlot.Options {
|
||||||
const yAxis: uPlot.Axis = {
|
const yAxis: uPlot.Axis = {
|
||||||
stroke: '#94a3b8',
|
stroke: '#94a3b8',
|
||||||
@@ -194,6 +377,21 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
|
|||||||
},
|
},
|
||||||
y: { ...scaleY, range: scaleYRange },
|
y: { ...scaleY, range: scaleYRange },
|
||||||
},
|
},
|
||||||
|
// Cross-plot crosshair sync (x only) for all linked plots.
|
||||||
|
cursor: linked ? { sync: { key: PLOT_SYNC_KEY, scales: ['x', null] } } : {},
|
||||||
|
hooks: {
|
||||||
|
draw: [drawMeasureMarks],
|
||||||
|
// Broadcast zoom/pan to other linked plots (historical mode only — live
|
||||||
|
// mode auto-scrolls its own rolling window). Guard against the echo of
|
||||||
|
// an applied sync to avoid a feedback loop.
|
||||||
|
setScale: [(u: uPlot, key: string) => {
|
||||||
|
if (key !== 'x' || !linked || !timeRange || applyingSyncRef.current) return;
|
||||||
|
const sc = (u as any).scales.x as { min: number | null; max: number | null };
|
||||||
|
if (sc.min != null && sc.max != null) {
|
||||||
|
broadcastXRange(widget.id, { min: sc.min, max: sc.max });
|
||||||
|
}
|
||||||
|
}],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -373,6 +571,24 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
|
|||||||
if (plotType !== 'timeseries') {
|
if (plotType !== 'timeseries') {
|
||||||
echart = echarts.init(el);
|
echart = echarts.init(el);
|
||||||
echart.setOption(echartsOption());
|
echart.setOption(echartsOption());
|
||||||
|
echartRef.current = echart;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire a freshly-created uPlot into the refs + measurement click handler.
|
||||||
|
function mountUplot(u: uPlot) {
|
||||||
|
uplotRef.current = u;
|
||||||
|
(u as any).over.addEventListener('click', onMeasureClick);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply a synced x-range pushed from a peer plot (historical zoom/pan sync).
|
||||||
|
if (linked && plotType === 'timeseries') {
|
||||||
|
unsubs.push(subscribeXRange(widget.id, r => {
|
||||||
|
const u = uplotRef.current;
|
||||||
|
if (!u || !timeRange) return;
|
||||||
|
applyingSyncRef.current = true;
|
||||||
|
(u as any).setScale('x', { min: r.min, max: r.max });
|
||||||
|
applyingSyncRef.current = false;
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Historical mode (timeseries only) ──────────────────────────────────
|
// ── Historical mode (timeseries only) ──────────────────────────────────
|
||||||
@@ -403,20 +619,25 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
|
|||||||
setHistStatus(totalPoints === 0 ? 'empty' : 'idle');
|
setHistStatus(totalPoints === 0 ? 'empty' : 'idle');
|
||||||
// Create uPlot now — with real data — so the x-axis is correct from the start.
|
// Create uPlot now — with real data — so the x-axis is correct from the start.
|
||||||
uplot = new uPlot(makeUplotOpts(), uplotData(), el);
|
uplot = new uPlot(makeUplotOpts(), uplotData(), el);
|
||||||
|
mountUplot(uplot);
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
if (!cancelled) setHistStatus('error');
|
if (!cancelled) setHistStatus('error');
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
unsubs.forEach(u => u());
|
||||||
uplot?.destroy();
|
uplot?.destroy();
|
||||||
echart?.dispose();
|
echart?.dispose();
|
||||||
|
uplotRef.current = null;
|
||||||
|
echartRef.current = null;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Live mode: init uPlot immediately ──────────────────────────────────
|
// ── Live mode: init uPlot immediately ──────────────────────────────────
|
||||||
if (plotType === 'timeseries') {
|
if (plotType === 'timeseries') {
|
||||||
uplot = new uPlot(makeUplotOpts(), uplotData(), el);
|
uplot = new uPlot(makeUplotOpts(), uplotData(), el);
|
||||||
|
mountUplot(uplot);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Live streaming mode ────────────────────────────────────────────────
|
// ── Live streaming mode ────────────────────────────────────────────────
|
||||||
@@ -425,10 +646,10 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
|
|||||||
// Honour action.widget logic commands: `paused` suspends live ingestion and
|
// Honour action.widget logic commands: `paused` suspends live ingestion and
|
||||||
// a bumped `clearNonce` empties the buffers + redraws once.
|
// a bumped `clearNonce` empties the buffers + redraws once.
|
||||||
const cmdStore = getWidgetCmdStore(widget.id);
|
const cmdStore = getWidgetCmdStore(widget.id);
|
||||||
let paused = cmdStore.get().paused;
|
let cmdPaused = cmdStore.get().paused;
|
||||||
let lastClear = cmdStore.get().clearNonce;
|
let lastClear = cmdStore.get().clearNonce;
|
||||||
unsubs.push(cmdStore.subscribe(cmd => {
|
unsubs.push(cmdStore.subscribe(cmd => {
|
||||||
paused = cmd.paused;
|
cmdPaused = cmd.paused;
|
||||||
if (cmd.clearNonce !== lastClear) {
|
if (cmd.clearNonce !== lastClear) {
|
||||||
lastClear = cmd.clearNonce;
|
lastClear = cmd.clearNonce;
|
||||||
buffers.forEach(b => { b.timestamps.length = 0; b.values.length = 0; });
|
buffers.forEach(b => { b.timestamps.length = 0; b.values.length = 0; });
|
||||||
@@ -439,7 +660,7 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
|
|||||||
|
|
||||||
signals.forEach((sig: SignalRef & { color?: string }, i) => {
|
signals.forEach((sig: SignalRef & { color?: string }, i) => {
|
||||||
const unsub = getSignalStore(sig).subscribe(sv => {
|
const unsub = getSignalStore(sig).subscribe(sv => {
|
||||||
if (paused) return;
|
if (cmdPaused || pausedRef.current) return;
|
||||||
if (sv.value === null || sv.value === undefined || sv.ts === null) return;
|
if (sv.value === null || sv.value === undefined || sv.ts === null) return;
|
||||||
const ts = new Date(sv.ts).getTime() / 1000;
|
const ts = new Date(sv.ts).getTime() / 1000;
|
||||||
|
|
||||||
@@ -487,8 +708,10 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
|
|||||||
unsubs.forEach(u => u());
|
unsubs.forEach(u => u());
|
||||||
if (uplot) uplot.destroy();
|
if (uplot) uplot.destroy();
|
||||||
if (echart) echart.dispose();
|
if (echart) echart.dispose();
|
||||||
|
uplotRef.current = null;
|
||||||
|
echartRef.current = null;
|
||||||
};
|
};
|
||||||
}, [widget.id, timeRangeKey, plotType, signalsKey, widget.options['timeWindow'] ?? '60', widget.options['format'] ?? '', widget.options['yMin'] ?? '', widget.options['yMax'] ?? '', widget.options['legend'] ?? 'bottom']);
|
}, [widget.id, timeRangeKey, plotType, signalsKey, winOverride, linked, widget.options['timeWindow'] ?? '60', widget.options['format'] ?? '', widget.options['yMin'] ?? '', widget.options['yMax'] ?? '', widget.options['legend'] ?? 'bottom']);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -497,6 +720,61 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
|
|||||||
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
|
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
|
||||||
onContextMenu={onContextMenu}
|
onContextMenu={onContextMenu}
|
||||||
>
|
>
|
||||||
|
<div class="plot-toolbar" onMouseDown={(e) => e.stopPropagation()} onContextMenu={(e) => e.stopPropagation()}>
|
||||||
|
<button
|
||||||
|
class={`plot-tb-btn${paused ? ' plot-tb-active' : ''}`}
|
||||||
|
title={paused ? 'Resume live updates' : 'Pause live updates'}
|
||||||
|
onClick={() => setPaused(p => !p)}
|
||||||
|
>{paused ? '▶' : '⏸'}</button>
|
||||||
|
{!timeRange && (plotType === 'timeseries' || plotType === 'fft' || plotType === 'logic') && (
|
||||||
|
<select
|
||||||
|
class="plot-tb-select"
|
||||||
|
title="Rolling time window"
|
||||||
|
value={winOverride === null ? 'auto' : String(winOverride)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = (e.target as HTMLSelectElement).value;
|
||||||
|
setWinOverride(v === 'auto' ? null : parseFloat(v));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{WINDOW_PRESETS.map(p => (
|
||||||
|
<option key={p.label} value={p.val === null ? 'auto' : String(p.val)}>{p.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
{plotType === 'timeseries' && (
|
||||||
|
<button
|
||||||
|
class={`plot-tb-btn${linked ? ' plot-tb-active' : ''}`}
|
||||||
|
title={linked ? 'Linked: cursor / zoom synced with other plots — click to unlink' : 'Unlinked — click to sync cursor / zoom with other plots'}
|
||||||
|
onClick={() => setLinked(v => !v)}
|
||||||
|
>{linked ? '🔗' : '⛓️💥'}</button>
|
||||||
|
)}
|
||||||
|
{plotType === 'timeseries' && (
|
||||||
|
<button
|
||||||
|
class={`plot-tb-btn${measureOn ? ' plot-tb-active' : ''}`}
|
||||||
|
title="Measure: click to place cursor A then B; read Δt and per-signal Δ"
|
||||||
|
onClick={toggleMeasure}
|
||||||
|
>📏</button>
|
||||||
|
)}
|
||||||
|
<button class="plot-tb-btn" title="Clear buffered data" onClick={clearPlot}>🗑</button>
|
||||||
|
<button class="plot-tb-btn" title="Save screenshot (PNG)" onClick={screenshot}>📷</button>
|
||||||
|
</div>
|
||||||
|
{readout && plotType === 'timeseries' && (
|
||||||
|
<div class="measure-readout" onMouseDown={(e) => e.stopPropagation()}>
|
||||||
|
<div class="measure-row measure-head">
|
||||||
|
<span>Δt</span>
|
||||||
|
<span>{readout.b != null ? formatDt(readout.b - readout.a) : '—'}</span>
|
||||||
|
</div>
|
||||||
|
{readout.rows.map(r => (
|
||||||
|
<div class="measure-row" key={r.name}>
|
||||||
|
<span class="measure-name" style={`color:${r.color}`}>{r.name}</span>
|
||||||
|
<span class="measure-vals">
|
||||||
|
{fmtNum(r.va)}
|
||||||
|
{readout.b != null && <span class="measure-delta"> → {fmtNum(r.vb)} (Δ {fmtNum(r.va != null && r.vb != null ? r.vb - r.va : null)})</span>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div class="chart-area" ref={chartRef} style={`width:${widget.w}px;height:${widget.h}px;`} />
|
<div class="chart-area" ref={chartRef} style={`width:${widget.w}px;height:${widget.h}px;`} />
|
||||||
{histStatus === 'loading' && (
|
{histStatus === 'loading' && (
|
||||||
<div class="hist-overlay">Loading history…</div>
|
<div class="hist-overlay">Loading history…</div>
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { useState, useEffect } from 'preact/hooks';
|
||||||
|
import { getSignalStore, getMetaStore } from '../lib/stores';
|
||||||
|
import { formatValue } from '../lib/format';
|
||||||
|
import type { Widget, SignalRef, SignalValue, SignalMeta } from '../lib/types';
|
||||||
|
|
||||||
|
const DEFAULT_VALUE: SignalValue = { value: null, quality: 'unknown', ts: null };
|
||||||
|
|
||||||
|
const COLS = ['name', 'value', 'unit', 'quality', 'time'] as const;
|
||||||
|
type Col = typeof COLS[number];
|
||||||
|
|
||||||
|
const COL_LABELS: Record<Col, string> = {
|
||||||
|
name: 'Name', value: 'Value', unit: 'Unit', quality: 'Status', time: 'Time',
|
||||||
|
};
|
||||||
|
|
||||||
|
function qualityColor(q: string): string {
|
||||||
|
switch (q) {
|
||||||
|
case 'good': return '#4ade80';
|
||||||
|
case 'uncertain': return '#fbbf24';
|
||||||
|
case 'bad': return '#f87171';
|
||||||
|
default: return '#6b7280';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCol(c: string): c is Col {
|
||||||
|
return (COLS as readonly string[]).includes(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RowProps {
|
||||||
|
sig: SignalRef;
|
||||||
|
label: string;
|
||||||
|
cols: Col[];
|
||||||
|
fmt: string;
|
||||||
|
unitOverride: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One subscribed row. Each row owns its own value/meta subscription so the
|
||||||
|
// table can carry an arbitrary number of unrelated signals.
|
||||||
|
function TableRow({ sig, label, cols, fmt, unitOverride }: RowProps) {
|
||||||
|
const [sv, setSv] = useState<SignalValue>(DEFAULT_VALUE);
|
||||||
|
const [meta, setMeta] = useState<SignalMeta | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const uv = getSignalStore(sig).subscribe(setSv);
|
||||||
|
const um = getMetaStore(sig).subscribe(setMeta);
|
||||||
|
return () => { uv(); um(); };
|
||||||
|
}, [sig.ds, sig.name]);
|
||||||
|
|
||||||
|
function valStr(): string {
|
||||||
|
const v = sv.value;
|
||||||
|
if (v === null || v === undefined) return '---';
|
||||||
|
if (typeof v === 'number') return formatValue(v, fmt);
|
||||||
|
if (Array.isArray(v)) return `[${v.length}]`;
|
||||||
|
return String(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
const unit = unitOverride === 'none' ? '' : (unitOverride || meta?.unit || '');
|
||||||
|
const time = sv.ts ? new Date(sv.ts).toLocaleTimeString() : '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr>
|
||||||
|
{cols.map(c => {
|
||||||
|
switch (c) {
|
||||||
|
case 'name':
|
||||||
|
return <td key={c} class="tw-name" title={`${sig.ds}:${sig.name}`}>{label}</td>;
|
||||||
|
case 'value':
|
||||||
|
return <td key={c} class="tw-value">{valStr()}</td>;
|
||||||
|
case 'unit':
|
||||||
|
return <td key={c} class="tw-unit">{unit}</td>;
|
||||||
|
case 'quality':
|
||||||
|
return (
|
||||||
|
<td key={c} class="tw-quality">
|
||||||
|
<span class="quality-dot" style={`background:${qualityColor(sv.quality)};`} title={`Quality: ${sv.quality}`} />
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
case 'time':
|
||||||
|
return <td key={c} class="tw-time">{time}</td>;
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props { widget: Widget; onContextMenu?: (e: MouseEvent) => void; }
|
||||||
|
|
||||||
|
// A tabular multi-signal readout: each bound signal is one row, with a
|
||||||
|
// configurable set of columns (name / value / unit / status / time).
|
||||||
|
export default function TableWidget({ widget, onContextMenu }: Props) {
|
||||||
|
const o = widget.options;
|
||||||
|
const parsed = (o['columns'] ?? 'name,value,unit')
|
||||||
|
.split(',').map(s => s.trim()).filter(isCol);
|
||||||
|
const cols: Col[] = parsed.length ? parsed : ['name', 'value'];
|
||||||
|
const showHeader = o['header'] !== 'false';
|
||||||
|
const title = o['title'] ?? '';
|
||||||
|
const fmt = o['format'] ?? '';
|
||||||
|
const unitOverride = o['unit'] ?? '';
|
||||||
|
const labels = (o['labels'] ?? '').split(',');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class="table-widget"
|
||||||
|
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
|
>
|
||||||
|
{title && <div class="tw-title">{title}</div>}
|
||||||
|
<div class="tw-scroll">
|
||||||
|
<table class="tw-table">
|
||||||
|
{showHeader && (
|
||||||
|
<thead>
|
||||||
|
<tr>{cols.map(c => <th key={c} class={`tw-th-${c}`}>{COL_LABELS[c]}</th>)}</tr>
|
||||||
|
</thead>
|
||||||
|
)}
|
||||||
|
<tbody>
|
||||||
|
{widget.signals.length === 0 ? (
|
||||||
|
<tr><td class="tw-empty" colSpan={cols.length}>No signals — drop signals here</td></tr>
|
||||||
|
) : (
|
||||||
|
widget.signals.map((s, i) => (
|
||||||
|
<TableRow
|
||||||
|
key={`${s.ds}:${s.name}`}
|
||||||
|
sig={s}
|
||||||
|
label={labels[i]?.trim() || s.name}
|
||||||
|
cols={cols}
|
||||||
|
fmt={fmt}
|
||||||
|
unitOverride={unitOverride}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { useState, useEffect } from 'preact/hooks';
|
||||||
|
import { getSignalStore, getMetaStore } from '../lib/stores';
|
||||||
|
import { wsClient } from '../lib/ws';
|
||||||
|
import { useAuth, canWrite } from '../lib/auth';
|
||||||
|
import type { Widget, SignalValue, SignalMeta } from '../lib/types';
|
||||||
|
|
||||||
|
const DEFAULT_VALUE: SignalValue = { value: null, quality: 'unknown', ts: null };
|
||||||
|
|
||||||
|
interface Props { widget: Widget; onContextMenu?: (e: MouseEvent) => void; }
|
||||||
|
|
||||||
|
// Compare a live signal value against a configured on/off target. Numeric when
|
||||||
|
// both parse as finite numbers (tolerant compare), otherwise string equality.
|
||||||
|
function matches(value: unknown, target: string): boolean {
|
||||||
|
if (value === null || value === undefined) return false;
|
||||||
|
const vn = typeof value === 'number' ? value : parseFloat(String(value));
|
||||||
|
const tn = parseFloat(target);
|
||||||
|
if (Number.isFinite(vn) && Number.isFinite(tn)) return Math.abs(vn - tn) < 1e-9;
|
||||||
|
return String(value) === target;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Toggle({ widget, onContextMenu }: Props) {
|
||||||
|
const sigRef = widget.signals[0];
|
||||||
|
const me = useAuth();
|
||||||
|
const [sv, setSv] = useState<SignalValue>(DEFAULT_VALUE);
|
||||||
|
const [meta, setMeta] = useState<SignalMeta | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sigRef) return;
|
||||||
|
const unsubV = getSignalStore(sigRef).subscribe(setSv);
|
||||||
|
const unsubM = getMetaStore(sigRef).subscribe(setMeta);
|
||||||
|
return () => { unsubV(); unsubM(); };
|
||||||
|
}, [sigRef?.ds, sigRef?.name]);
|
||||||
|
|
||||||
|
const label = widget.options['label'] || sigRef?.name || '';
|
||||||
|
const onValue = widget.options['onValue'] ?? '1';
|
||||||
|
const offValue = widget.options['offValue'] ?? '0';
|
||||||
|
const onLabel = widget.options['onLabel'] || 'ON';
|
||||||
|
const offLabel = widget.options['offLabel'] || 'OFF';
|
||||||
|
const confirm = widget.options['confirm'] === 'true';
|
||||||
|
|
||||||
|
const isLocal = sigRef?.ds === 'local';
|
||||||
|
const writeAllowed = isLocal || (canWrite(me.level) && meta?.writable !== false);
|
||||||
|
|
||||||
|
// "On" when the value matches the configured on-target; unknown until a value
|
||||||
|
// arrives. A value matching neither target is treated as off.
|
||||||
|
const known = sv.value !== null && sv.value !== undefined;
|
||||||
|
const isOn = known && matches(sv.value, onValue);
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
if (!sigRef || !writeAllowed) return;
|
||||||
|
const target = isOn ? offValue : onValue;
|
||||||
|
const numeric = Number.isFinite(parseFloat(target)) && meta?.type !== 'string';
|
||||||
|
const write = () => wsClient.write(sigRef, numeric ? parseFloat(target) : target);
|
||||||
|
if (confirm) {
|
||||||
|
if (window.confirm(`Set ${label} to ${isOn ? offLabel : onLabel}?`)) write();
|
||||||
|
} else {
|
||||||
|
write();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class="toggle-widget"
|
||||||
|
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
|
>
|
||||||
|
{label && <span class="toggle-label">{label}</span>}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={`toggle-switch${isOn ? ' toggle-on' : ''}${!known ? ' toggle-unknown' : ''}`}
|
||||||
|
disabled={!writeAllowed}
|
||||||
|
onClick={toggle}
|
||||||
|
title={writeAllowed ? '' : 'You do not have permission to write this signal'}
|
||||||
|
>
|
||||||
|
<span class="toggle-track"><span class="toggle-knob" /></span>
|
||||||
|
<span class="toggle-state">{!known ? '—' : isOn ? onLabel : offLabel}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"id": "new-config-set-snapshot-2026-06-21-23-53-12",
|
||||||
|
"name": "New config set snapshot 2026-06-21 23:53:12",
|
||||||
|
"owner": "martino",
|
||||||
|
"setId": "new-config-set",
|
||||||
|
"values": {
|
||||||
|
"param1": 0,
|
||||||
|
"param2": 42.60427155337898,
|
||||||
|
"param3": "System nominal",
|
||||||
|
"param4": "Idle"
|
||||||
|
},
|
||||||
|
"version": 1
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"id": "new-rule",
|
||||||
|
"name": "New rule",
|
||||||
|
"owner": "martino",
|
||||||
|
"setId": "new-config-set",
|
||||||
|
"source": "// CUE rule: constrain or derive parameter values.\nparam1: number | *0 \nparam2: number\nif param1 != _|_ \u0026\u0026 param1 \u003c 10 {\n param2: number \u0026 \u003e 10\n} \nif param1 != _|_ \u0026\u0026 param1 \u003e= 10 {\n param2: number \u0026 \u003c 10\n}\n",
|
||||||
|
"version": 2
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"id": "new-rule",
|
||||||
|
"name": "New rule",
|
||||||
|
"owner": "martino",
|
||||||
|
"setId": "new-config-set",
|
||||||
|
"source": "// CUE rule: constrain or derive parameter values.\n",
|
||||||
|
"version": 1
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"id": "new-config-set-1782117410757",
|
||||||
|
"name": "New config set 2",
|
||||||
|
"owner": "martino",
|
||||||
|
"parameters": [],
|
||||||
|
"version": 2
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"id": "new-config-set-1782117410757",
|
||||||
|
"name": "New config set",
|
||||||
|
"owner": "martino",
|
||||||
|
"parameters": [],
|
||||||
|
"version": 1
|
||||||
|
}
|
||||||
@@ -42,5 +42,6 @@
|
|||||||
"type": "enum"
|
"type": "enum"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"version": 3
|
"tag": "restored from v3",
|
||||||
|
"version": 5
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"id": "new-config-set",
|
||||||
|
"name": "New config set",
|
||||||
|
"owner": "martino",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"ds": "epics",
|
||||||
|
"key": "param1",
|
||||||
|
"max": 1,
|
||||||
|
"min": 0,
|
||||||
|
"signal": "UOPI:INTERLOCK",
|
||||||
|
"type": "float64"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ds": "epics",
|
||||||
|
"key": "param2",
|
||||||
|
"max": 100,
|
||||||
|
"min": 0,
|
||||||
|
"signal": "UOPI:FLOW",
|
||||||
|
"type": "float64",
|
||||||
|
"unit": "L/min"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"default": "CIAO",
|
||||||
|
"ds": "epics",
|
||||||
|
"key": "param3",
|
||||||
|
"signal": "UOPI:MESSAGE",
|
||||||
|
"subgroup": "Casa",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ds": "epics",
|
||||||
|
"enumValues": [
|
||||||
|
"Idle",
|
||||||
|
"Running",
|
||||||
|
"Fault",
|
||||||
|
"Maintenance"
|
||||||
|
],
|
||||||
|
"key": "param4",
|
||||||
|
"signal": "UOPI:MODE",
|
||||||
|
"subgroup": "Casa",
|
||||||
|
"type": "enum"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"version": 3
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"id": "new-config-set",
|
||||||
|
"name": "New config set",
|
||||||
|
"owner": "martino",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"ds": "epics",
|
||||||
|
"key": "param1",
|
||||||
|
"max": 1,
|
||||||
|
"min": 0,
|
||||||
|
"signal": "UOPI:INTERLOCK",
|
||||||
|
"type": "float64"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ds": "epics",
|
||||||
|
"key": "param2",
|
||||||
|
"max": 100,
|
||||||
|
"min": 0,
|
||||||
|
"signal": "UOPI:FLOW",
|
||||||
|
"type": "float64",
|
||||||
|
"unit": "L/min"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"default": "CIAO",
|
||||||
|
"ds": "epics",
|
||||||
|
"key": "param3",
|
||||||
|
"signal": "UOPI:MESSAGE",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ds": "epics",
|
||||||
|
"enumValues": [
|
||||||
|
"Idle",
|
||||||
|
"Running",
|
||||||
|
"Fault",
|
||||||
|
"Maintenance"
|
||||||
|
],
|
||||||
|
"key": "param4",
|
||||||
|
"signal": "UOPI:MODE",
|
||||||
|
"type": "enum"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"version": 4
|
||||||
|
}
|
||||||
@@ -2,8 +2,61 @@
|
|||||||
{
|
{
|
||||||
"id": "cl-8c72c8bd37407d7f",
|
"id": "cl-8c72c8bd37407d7f",
|
||||||
"name": "New control logic",
|
"name": "New control logic",
|
||||||
"enabled": false,
|
"enabled": true,
|
||||||
"nodes": [],
|
"version": 5,
|
||||||
"wires": []
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "n_303sue0",
|
||||||
|
"kind": "trigger.cron",
|
||||||
|
"x": 117.24583129882814,
|
||||||
|
"y": 208.7500061035156,
|
||||||
|
"params": {
|
||||||
|
"spec": "2 11 * * 1-5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "n_u4u46iy",
|
||||||
|
"kind": "action.write",
|
||||||
|
"x": 357.2458312988281,
|
||||||
|
"y": 223.7500061035156,
|
||||||
|
"params": {
|
||||||
|
"expr": "1",
|
||||||
|
"target": "epics:UOPI:BEAM_ON"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "n_djkxc14",
|
||||||
|
"kind": "action.delay",
|
||||||
|
"x": 634.2458312988281,
|
||||||
|
"y": 233.7500061035156,
|
||||||
|
"params": {
|
||||||
|
"ms": "5000"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "n_aurpu41",
|
||||||
|
"kind": "action.write",
|
||||||
|
"x": 914.2458312988281,
|
||||||
|
"y": 195.75000610351563,
|
||||||
|
"params": {
|
||||||
|
"expr": "0",
|
||||||
|
"target": "epics:UOPI:BEAM_ON"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"wires": [
|
||||||
|
{
|
||||||
|
"from": "n_303sue0",
|
||||||
|
"to": "n_u4u46iy"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "n_u4u46iy",
|
||||||
|
"to": "n_djkxc14"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "n_djkxc14",
|
||||||
|
"to": "n_aurpu41"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"id": "cl-8c72c8bd37407d7f",
|
||||||
|
"name": "New control logic",
|
||||||
|
"enabled": false,
|
||||||
|
"version": 1,
|
||||||
|
"nodes": [],
|
||||||
|
"wires": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
{
|
||||||
|
"id": "cl-8c72c8bd37407d7f",
|
||||||
|
"name": "New control logic",
|
||||||
|
"enabled": false,
|
||||||
|
"version": 2,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "n_303sue0",
|
||||||
|
"kind": "trigger.cron",
|
||||||
|
"x": 118.24583129882814,
|
||||||
|
"y": 205.7500061035156,
|
||||||
|
"params": {
|
||||||
|
"spec": "0 11 * * 1-5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "n_u4u46iy",
|
||||||
|
"kind": "action.write",
|
||||||
|
"x": 357.2458312988281,
|
||||||
|
"y": 223.7500061035156,
|
||||||
|
"params": {
|
||||||
|
"expr": "1",
|
||||||
|
"target": "epics:UOPI:BEAM_ON"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "n_djkxc14",
|
||||||
|
"kind": "action.delay",
|
||||||
|
"x": 634.2458312988281,
|
||||||
|
"y": 233.7500061035156,
|
||||||
|
"params": {
|
||||||
|
"ms": "5000"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "n_aurpu41",
|
||||||
|
"kind": "action.write",
|
||||||
|
"x": 914.2458312988281,
|
||||||
|
"y": 195.75000610351563,
|
||||||
|
"params": {
|
||||||
|
"expr": "0",
|
||||||
|
"target": "epics:UOPI:BEAM_ON"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"wires": [
|
||||||
|
{
|
||||||
|
"from": "n_303sue0",
|
||||||
|
"to": "n_u4u46iy"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "n_u4u46iy",
|
||||||
|
"to": "n_djkxc14"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "n_djkxc14",
|
||||||
|
"to": "n_aurpu41"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
{
|
||||||
|
"id": "cl-8c72c8bd37407d7f",
|
||||||
|
"name": "New control logic",
|
||||||
|
"enabled": true,
|
||||||
|
"version": 3,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "n_303sue0",
|
||||||
|
"kind": "trigger.cron",
|
||||||
|
"x": 117.24583129882814,
|
||||||
|
"y": 208.7500061035156,
|
||||||
|
"params": {
|
||||||
|
"spec": "0 11:01 * * 1-5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "n_u4u46iy",
|
||||||
|
"kind": "action.write",
|
||||||
|
"x": 357.2458312988281,
|
||||||
|
"y": 223.7500061035156,
|
||||||
|
"params": {
|
||||||
|
"expr": "1",
|
||||||
|
"target": "epics:UOPI:BEAM_ON"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "n_djkxc14",
|
||||||
|
"kind": "action.delay",
|
||||||
|
"x": 634.2458312988281,
|
||||||
|
"y": 233.7500061035156,
|
||||||
|
"params": {
|
||||||
|
"ms": "5000"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "n_aurpu41",
|
||||||
|
"kind": "action.write",
|
||||||
|
"x": 914.2458312988281,
|
||||||
|
"y": 195.75000610351563,
|
||||||
|
"params": {
|
||||||
|
"expr": "0",
|
||||||
|
"target": "epics:UOPI:BEAM_ON"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"wires": [
|
||||||
|
{
|
||||||
|
"from": "n_303sue0",
|
||||||
|
"to": "n_u4u46iy"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "n_u4u46iy",
|
||||||
|
"to": "n_djkxc14"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "n_djkxc14",
|
||||||
|
"to": "n_aurpu41"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
{
|
||||||
|
"id": "cl-8c72c8bd37407d7f",
|
||||||
|
"name": "New control logic",
|
||||||
|
"enabled": true,
|
||||||
|
"version": 4,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "n_303sue0",
|
||||||
|
"kind": "trigger.cron",
|
||||||
|
"x": 117.24583129882814,
|
||||||
|
"y": 208.7500061035156,
|
||||||
|
"params": {
|
||||||
|
"spec": "2 11 * * *"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "n_u4u46iy",
|
||||||
|
"kind": "action.write",
|
||||||
|
"x": 357.2458312988281,
|
||||||
|
"y": 223.7500061035156,
|
||||||
|
"params": {
|
||||||
|
"expr": "1",
|
||||||
|
"target": "epics:UOPI:BEAM_ON"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "n_djkxc14",
|
||||||
|
"kind": "action.delay",
|
||||||
|
"x": 634.2458312988281,
|
||||||
|
"y": 233.7500061035156,
|
||||||
|
"params": {
|
||||||
|
"ms": "5000"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "n_aurpu41",
|
||||||
|
"kind": "action.write",
|
||||||
|
"x": 914.2458312988281,
|
||||||
|
"y": 195.75000610351563,
|
||||||
|
"params": {
|
||||||
|
"expr": "0",
|
||||||
|
"target": "epics:UOPI:BEAM_ON"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"wires": [
|
||||||
|
{
|
||||||
|
"from": "n_303sue0",
|
||||||
|
"to": "n_u4u46iy"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "n_u4u46iy",
|
||||||
|
"to": "n_djkxc14"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "n_djkxc14",
|
||||||
|
"to": "n_aurpu41"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
{
|
||||||
|
"name": "flow_temperature",
|
||||||
|
"ds": "",
|
||||||
|
"signal": "",
|
||||||
|
"inputs": null,
|
||||||
|
"pipeline": null,
|
||||||
|
"graph": {
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "g_ina0z66",
|
||||||
|
"kind": "output",
|
||||||
|
"inputs": [
|
||||||
|
"g_i8jh2kn"
|
||||||
|
],
|
||||||
|
"x": 759.75,
|
||||||
|
"y": 137.15
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "g_6iio15u",
|
||||||
|
"kind": "source",
|
||||||
|
"ds": "epics",
|
||||||
|
"signal": "UOPI:TEMP",
|
||||||
|
"x": 60.1,
|
||||||
|
"y": 60.099999999999994
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "g_i8jh2kn",
|
||||||
|
"kind": "op",
|
||||||
|
"op": "expr",
|
||||||
|
"params": {
|
||||||
|
"expr": "a * b",
|
||||||
|
"vars": [
|
||||||
|
"a",
|
||||||
|
"b"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"inputs": [
|
||||||
|
"g_6iio15u",
|
||||||
|
"g_v7p0lvm"
|
||||||
|
],
|
||||||
|
"x": 369.8666687011719,
|
||||||
|
"y": 131.59834045410156
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "g_v7p0lvm",
|
||||||
|
"kind": "source",
|
||||||
|
"ds": "epics",
|
||||||
|
"signal": "UOPI:FLOW",
|
||||||
|
"x": 56.099999999999994,
|
||||||
|
"y": 170.35000000000002
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"output": "g_ina0z66"
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"displayHigh": 1000
|
||||||
|
},
|
||||||
|
"visibility": "user",
|
||||||
|
"owner": "martino",
|
||||||
|
"version": 1
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user