From ac24011487ffbe8ef867c20d87461cd478c49956 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Mon, 22 Jun 2026 17:49:14 +0200 Subject: [PATCH] Implemented admin pane + user permission --- TODO.md | 68 +- cmd/uopi/main.go | 41 +- docs/FUNCTIONAL_SPEC.md | 6 + docs/TECHNICAL_SPEC.md | 111 ++- internal/access/access.go | 738 ++++++++++++++---- internal/access/access_test.go | 283 ++++++- internal/api/api.go | 263 ++++++- internal/api/api_test.go | 148 +++- internal/api/confmgr.go | 34 + internal/config/config.go | 55 +- internal/confmgr/cue.go | 16 +- internal/confmgr/store.go | 10 +- internal/confmgr/store_test.go | 20 + internal/controllogic/debug.go | 174 +++++ internal/controllogic/debug_test.go | 196 +++++ internal/controllogic/engine.go | 96 ++- internal/controllogic/model.go | 24 +- internal/datasource/synthetic/definition.go | 11 + internal/datasource/synthetic/graph.go | 19 +- internal/datasource/synthetic/synthetic.go | 59 ++ internal/dsp/types.go | 12 + internal/metrics/metrics.go | 24 + internal/server/debughub.go | 187 +++++ internal/server/server.go | 4 +- internal/server/ws.go | 47 ++ internal/storage/store.go | 5 +- uopi.example.toml | 42 +- web/src/AdminPane.tsx | 487 ++++++++++++ web/src/Canvas.tsx | 53 +- web/src/ConfigManager.tsx | 117 ++- web/src/ControlLogicEditor.tsx | 373 ++++++++- web/src/EditCanvas.tsx | 70 +- web/src/EditMode.tsx | 90 ++- web/src/InterfaceList.tsx | 33 +- web/src/LogicEditor.tsx | 263 ++++++- web/src/PropertiesPane.tsx | 164 +++- web/src/SyntheticGraphEditor.tsx | 282 ++++++- web/src/ViewMode.tsx | 10 +- web/src/WidgetTypePicker.tsx | 1 + web/src/lib/auth.ts | 3 +- web/src/lib/containers.ts | 56 ++ web/src/lib/flowDebug.ts | 91 +++ web/src/lib/flowZoom.ts | 96 +++ web/src/lib/logic.ts | 89 ++- web/src/lib/nodeGroups.ts | 71 ++ web/src/lib/types.ts | 11 + web/src/lib/ws.ts | 65 ++ web/src/lib/xml.ts | 28 +- web/src/styles.css | 542 ++++++++++++- web/src/widgets/Container.tsx | 84 ++ web/src/widgets/TableWidget.tsx | 133 ++++ workspace/data/audit.db | Bin 24576 -> 32768 bytes workspace/data/audit.db-shm | Bin 0 -> 32768 bytes workspace/data/audit.db-wal | 0 workspace/data/configs/rules/new-rule.json | 8 + workspace/data/configs/rules/new-rule.v1.json | 8 + .../sets/new-config-set-1782117410757.json | 7 + .../sets/new-config-set-1782117410757.v1.json | 7 + .../data/configs/sets/new-config-set.json | 3 +- .../data/configs/sets/new-config-set.v3.json | 46 ++ .../data/configs/sets/new-config-set.v4.json | 44 ++ workspace/data/controllogic.json | 59 +- .../cl-8c72c8bd37407d7f.v1.json | 8 + .../cl-8c72c8bd37407d7f.v2.json | 60 ++ .../cl-8c72c8bd37407d7f.v3.json | 60 ++ .../cl-8c72c8bd37407d7f.v4.json | 60 ++ .../flow_temperature-ae86ba34.v1.json | 61 ++ 67 files changed, 5925 insertions(+), 411 deletions(-) create mode 100644 internal/controllogic/debug.go create mode 100644 internal/controllogic/debug_test.go create mode 100644 internal/server/debughub.go create mode 100644 web/src/AdminPane.tsx create mode 100644 web/src/lib/containers.ts create mode 100644 web/src/lib/flowDebug.ts create mode 100644 web/src/lib/flowZoom.ts create mode 100644 web/src/lib/nodeGroups.ts create mode 100644 web/src/widgets/Container.tsx create mode 100644 web/src/widgets/TableWidget.tsx create mode 100644 workspace/data/audit.db-shm create mode 100644 workspace/data/audit.db-wal create mode 100644 workspace/data/configs/rules/new-rule.json create mode 100644 workspace/data/configs/rules/new-rule.v1.json create mode 100644 workspace/data/configs/sets/new-config-set-1782117410757.json create mode 100644 workspace/data/configs/sets/new-config-set-1782117410757.v1.json create mode 100644 workspace/data/configs/sets/new-config-set.v3.json create mode 100644 workspace/data/configs/sets/new-config-set.v4.json create mode 100644 workspace/data/controllogic_versions/cl-8c72c8bd37407d7f.v1.json create mode 100644 workspace/data/controllogic_versions/cl-8c72c8bd37407d7f.v2.json create mode 100644 workspace/data/controllogic_versions/cl-8c72c8bd37407d7f.v3.json create mode 100644 workspace/data/controllogic_versions/cl-8c72c8bd37407d7f.v4.json create mode 100644 workspace/data/synthetic_versions/flow_temperature-ae86ba34.v1.json diff --git a/TODO.md b/TODO.md index 3d6ab4b..94da58f 100644 --- a/TODO.md +++ b/TODO.md @@ -40,6 +40,11 @@ - [x] when validation fails the error is propagated to the user (live `/config/rules/check` panel while editing; save returns the structured violation) - [x] all actions tracked via history (versioned rules) + audit (`config.rule.create/update/delete/promote/fork`) - [ ] Improve UX: + - Config editor: + - [x] Instance should be filtered by config set (combo box) (`ConfigManager.tsx` InstancesManager: `setFilter` combo in the instance list head, shown when >1 set; empty-set hint) + - [x] Validation rules should also be filtered by config set (combo box) (`ConfigManager.tsx` RulesManager: `setFilter` combo mirroring instances, shown when >1 set; empty-set hint; `Meta.setId` surfaced via `List`) + - [x] Validation rules can be enabled / disabled β€” disabled rules are skipped when an instance is created/updated/applied (`Enabled *bool`, nil=enabled for legacy; `IsEnabled()`; `rulesForSet` skips disabled; UI checkbox + list `off` badge) + - [x] Rule editor preview button β€” runs the working (unsaved) source against a live signal snapshot of the set without persisting it, showing the input snapshot JSON and processed output JSON (`POST /config/rules/preview` β†’ `previewConfigRule`; `RulePreviewView`) - [ ] Synthetic editor: - [x] color code the node link by type - [x] edges color depends on type (e.g. float) including arrays (scalar = slate, array = purple) @@ -52,10 +57,12 @@ - [x] in view mode the widgets should have no border/bg but blend with background - [ ] add widgets such as toggle switch, table and other industrial hmi widgets - [x] toggle switch (`web/src/widgets/Toggle.tsx`; read+write bool control, configurable on/off value+label, confirm dialog) - - [ ] table widget + - [x] table widget (`web/src/widgets/TableWidget.tsx`; multi-signal value table, one row per bound signal, configurable columns name/value/unit/status/time, optional header + title, per-signal value format + row-label overrides; blends in view mode) - [ ] other industrial hmi widgets - - [ ] add container widgets: labelled/title pane, tab panes, collapsable panes etc - - [ ] plot pane: + - [x] widget panel exposes an editable Widget ID field (default = generated uuid) for easier logic interaction (`PropertiesPane.tsx` `IdInput`; `EditMode.renameWidgetId` propagates the rename to `action.widget` logic refs + plot-layout leaves; rejects empty/duplicate ids) + - [x] add container widgets: labelled/title pane, tab panes, collapsable panes (`web/src/widgets/Container.tsx`; decorative grouping frame rendered behind widgets, accent/bg options; `pane` variant = title + view-mode collapse; `tabs` variant = tab bar where each contained widget is assigned a tab via its "Tab" field and only the active tab shows; geometric membership via `web/src/lib/containers.ts`) + - [x] moving container widget should move the widgets on it β€” starting a move (drag or arrow nudge) on a container snapshots the widgets geometrically inside it (centre-inside, transitive through nested containers) and moves them by the same delta; membership is captured at move start so widgets never re-parent mid-move (`withContainedWidgets` in `web/src/lib/containers.ts`, used by `EditCanvas` drag-start + `EditMode` arrow nudge) + - [x] plot pane: - [x] add toolbar (hover toolbar in `PlotWidget`, `.plot-toolbar`) - [x] add time window selector to toolbar (Auto/15s/30s/1m/5m/15m/1h rolling-window override, live windowed plot types) - [x] plot x-axis synchronised (shared uPlot cursor crosshair across all linked timeseries plots + zoom/pan range sync in historical mode; per-plot πŸ”— link/unlink toolbar toggle; `web/src/lib/plotSync.ts`) @@ -66,11 +73,31 @@ - [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) -- [ ] Logic editor: - - add full support to local array values: dynamic, dynamic but capped max, fixed size etc: - - array functions should work with new local array -- [ ] Control loop: - - add full support to server side array values + - [x] panel-selection list: the whole row is clickable to open a panel (not just the name text); `onClick` moved to the `
  • `, action buttons `stopPropagation` (`InterfaceList.tsx`) + - [x] panel-selection list: plot vs panel entries shown with distinguishing monochrome inline-SVG icons (line-chart = plot / control-panel = HMI; `KindIcon`, currentColor); backend `InterfaceMeta.Kind` surfaced from the XML `kind` attr, `InterfaceListItem.kind` in the frontend type (preserved through the list normalizer) + - [ ] implement proper grouping strategy, in all selector tree the options should be filtered by user (private config), group (combo to select group if user in multiple groups), global (public config) + - [ ] panel tree by user / group / global + - [ ] signal tree by user / group / global + - [ ] config tree by user / group / global + - [ ] control sequence by user / group / global +- [ ] Node editors: + - [x] In all editors, implement node grouping and collapsing feature (with optional group label) (Shift+click multi-select β†’ G/⊞ to group; editable label; β–Ύ/β–Έ collapse to a compact box that reroutes crossing wires; Delete ungroups; shared `web/src/lib/nodeGroups.ts`; persisted in panel-logic XML + control-logic/synthetic JSON via Go `NodeGroup` structs β€” cosmetic editor metadata, ignored at eval) + - [x] opened group should be on top of other nodes (group frame lifted to z-index 1 above loose nodes; member nodes get `.flow-node-grouped` at z-index 2 so they stay above their own frame and clickable; applied in all three editors) + - [x] node counter should be better padded (`.flow-group-count` gained vertical padding so the "N nodes" line isn't cramped against the collapsed-box header) + - [x] In all editors: undo / redo + copy / paste with shortcuts (Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y undo-redo; Ctrl+C / Ctrl+V copy-paste of the selection, multi-select aware, internal wires preserved, ids remapped, pasted +offset; ref-based 50-deep history + clipboard scoped per editor; toolbar ↩/β†ͺ buttons). Panel `LogicEditor` already had this; added to `ControlLogicEditor` (undo/redo + copy/paste) and `SyntheticGraphEditor` (copy/paste β€” it already had undo/redo). Synthetic paste never copies the permanent output node + - [x] In all editors: implement zoom in / out, home zoom and fit zoom to help user navigate complex graph (shared `web/src/lib/flowZoom.ts` `useFlowZoom` hook; CSS `transform: scale()` on a `.flow-canvas-zoom` layer inside the scaled `.flow-canvas-inner`; toolbar row βˆ’/%/οΌ‹/β€’ in all three editors; `toCanvas` divides pointer offsets by zoom) + - [x] fix: the fit-zoom (β€’) button was clipped outside the fixed-width palette β€” toolbar buttons now shrink to fit (`min-width:0`, zero side padding in `.flow-palette-toolbar .toolbar-btn`) + - [x] fix: zoom value (e.g. 100%) is overflowing, reduce padding and increase the zoom value widget width (`flow-zoom-pct` class on the zoom-value button β†’ `flex:1.9` so it gets ~2Γ— the width of the single-glyph icon buttons; toolbar font dropped to 0.75rem, gap to 0.25rem, `tabular-nums` to stop width jitter; all three editors) + - [x] Live / debug mode in all three node editors β€” evaluate the graph online and badge each node's value at human speed (~0.5 s). Shared frontend `web/src/lib/flowDebug.ts` (badge/active classes, `useFlowDebug` poller) + CSS `.flow-node-active`/`.flow-node-badge`/`.flow-wire-active`; a green β—‰ toggle in each `.flow-palette-toolbar`. Per-editor backends: + - [ ] Syntetic signal editor: + - [x] Implement live / debug mode where value are evaluated online and visualized at human speed (e.g. 0.5s) β€” server-side stateless single-shot trace: `POST /api/v1/synthetic/trace` snapshots live source signals (broker `ReadNow`) and runs `evalSampleTrace` with fresh state, returning every node's value; stateful ops (moving_average/rms/lowpass/derivative/integrate/lua) badged `~approx` + - [ ] Logic editor: + - [x] Implement live / debug mode where value are evaluated online and visualized at human speed (e.g. 0.5s) β€” editor spins up a second client-side `LogicEngine` in new `dryRun` mode (real writes/config/dialogs suppressed) loaded with the edited graph; per-node values polled into the shared badges; the view-mode singleton is untouched + - add full suppor to local array values: dynamic, dynamic but capped max, fixed size etc: + - array functions should work with new local array + - [ ] Control loop: + - [x] Implement live / debug mode where value are evaluated online and visualized at human speed (e.g. 0.5s) β€” server pushes per-node events over the WS (`debugSubscribe`/`debugNode`) via a new `DebugObserver` on the engine (lock-free `atomic.Value`, gated by a per-graph watch set) + `internal/server/debughub.go` (drop-on-full fan-out). Two modes share one message shape: **Live** observes the running enabled graph; **Simulate** dry-runs the unsaved edits in a throwaway sandbox (`StartSimulate`, side effects suppressed). Live/Sim sub-toggle in the editor + - add full support to server side array values - [x] Implement git style versioning for: synthetic variable, panels, control logic: - [x] possibility to fork any version - [x] click to view the version @@ -81,10 +108,31 @@ - [x] active (the one currently view/edited) version has circle bigger then rest - [x] selected (the one that will be executed/showed by user) version has circle full, not active only border - [x] unsaved / new version appear with connection line dashed -- [ ] Implement admin pane: create / manage groups, set users permits, manage auditors etc +- [x] Implement admin pane: create / manage groups, set users permits, manage auditors etc + - [x] user manager + - [x] group manager + - [x] server statistics: load, conenctions, observed signals, average latency, other statistics - [ ] Implement new datasources: - [ ] Finalize alarm service - [ ] modbus tcp - - [ ] scpi tcp + - [ ] scpi tcp / VXI-11 protocol - [ ] udp? other? - [ ] **MAJOR** Implement proper distributed server side nodes to balance load and have redundancy (if a node is not available anymore all its clients migrate seamelessly to another) + - [ ] clients should be distributed to balance load\ + - same user should be (if possible) connect to only one service to simplify synch issue + - [ ] control sequences should be executed in only in one server instance but with backups ones to take over if the active service stop or die + - [ ] sync config between service + - [ ] manage conflict + - [ ] avoid incorrect + - [ ] ensure that the system can survive with up to only one instance alive + - [ ] advance admin panel: + - [ ] should have info about service topology and statistics of each server + - [ ] admin should deploy new instances via ssh to target machines directly from the admin panel + - [ ] for phisical connect datasource (e.g. modbus) pin the service to a specific machine or deploy specific datasource only service to a machine: user can setup backup instances (e.g. machine in the same sub-network that can be switched on in case primary fail) +- [ ] QA and CI + - [ ] coverage of service 90+% + - [ ] coverage of client 80+% + - [ ] add integrated tests with interface simulations + - [ ] update doc and keep user manual up to date + - [ ] add code example for lua scripts + - [ ] add tutorial diff --git a/cmd/uopi/main.go b/cmd/uopi/main.go index 3a0e8d0..44d0db2 100644 --- a/cmd/uopi/main.go +++ b/cmd/uopi/main.go @@ -152,18 +152,32 @@ func main() { } brk.Register(srvVars) - // Build the global access policy from config: every user is trusted with - // full write access unless downgraded by the blacklist; groups are named - // sets of users referenced by per-panel sharing. - blacklist := make(map[string]string, len(cfg.Server.Blacklist)) - for _, e := range cfg.Server.Blacklist { - blacklist[e.User] = e.Level - } - groups := make(map[string][]string, len(cfg.Groups)) + // Build the role-based access policy from config: each [[groups]] entry lists + // members by role (viewer/operator/logiceditor/auditor/admin) and an optional + // parent for nesting. Every user is an implicit viewer of the built-in public + // group; a config with no roles at all is fully open (everyone admin). + specs := make([]access.GroupSpec, 0, len(cfg.Groups)) for _, g := range cfg.Groups { - groups[g.Name] = g.Members + members := make(map[string]access.Role) + assign := func(users []string, role access.Role) { + for _, u := range users { + members[u] = role + } + } + assign(g.Viewers, access.RoleViewer) + assign(g.Operators, access.RoleOperator) + assign(g.LogicEditors, access.RoleLogic) + assign(g.Auditors, access.RoleAuditor) + assign(g.Admins, access.RoleAdmin) + specs = append(specs, access.GroupSpec{Name: g.Name, Parent: g.Parent, Members: members}) + } + policy := access.New(cfg.Server.DefaultUser, specs) + // Once enabled, runtime admin-pane changes persist to {storage_dir}/access.json, + // which (when present on a later startup) supersedes the TOML access config. + if err := policy.EnablePersistence(cfg.Server.StorageDir); err != nil { + log.Error("failed to load access store", "err", err) + os.Exit(1) } - policy := access.New(cfg.Server.DefaultUser, blacklist, groups, cfg.Server.LogicEditors, cfg.Audit.Readers) // Audit log: when enabled, every system-affecting action (user/automated // signal writes, interface and control-logic mutations) is recorded to SQLite. @@ -199,9 +213,14 @@ func main() { // to server variables. Installed before Reload so running graphs can emit. dialogs := server.NewDialogHub(brk, policy, recorder, log) ctrlEngine.SetNotifier(dialogs) + + // Debug hub: control-logic editors observe live graphs or dry-run unsaved + // edits; the engine pushes per-node execution events through it. + debugHub := server.NewDebugHub(ctrlEngine, log) + ctrlEngine.SetDebugObserver(debugHub) ctrlEngine.Reload() - srv := server.New(cfg.Server.Listen, webFS, brk, synthDS, store, 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 { fmt.Fprintf(os.Stderr, "server error: %v\n", err) diff --git a/docs/FUNCTIONAL_SPEC.md b/docs/FUNCTIONAL_SPEC.md index 5dcc78d..622524e 100644 --- a/docs/FUNCTIONAL_SPEC.md +++ b/docs/FUNCTIONAL_SPEC.md @@ -236,6 +236,9 @@ A signal defined by composing one or more input signals through a chain of proce processing node, set parameters, Create. - **Node-graph editor** β€” a visual editor that wires one or more inputs through a chain of DSP blocks, for multi-input pipelines. It compiles to the same inputs + pipeline model. + It supports undo/redo (Ctrl+Z / Ctrl+Shift+Z) and copy/paste (Ctrl+C / Ctrl+V) of the + selection; paste remaps ids and preserves internal wires (the permanent output node is + never duplicated). **Visibility scope:** each synthetic signal is scoped as *panel* (visible only to the panel that created it), *user*, or *global* (shared with everyone). @@ -303,6 +306,9 @@ managed through the REST API. config* makes a new instance for a set, optionally seeded from another instance. Both mutating nodes are audited. - Each graph can be enabled/disabled independently; saving reloads the engine live. +- The editor has its own undo/redo and copy/paste (Ctrl+Z / Ctrl+Shift+Z, Ctrl+C / Ctrl+V; + also ↩/β†ͺ toolbar buttons). Copy/paste is multi-select aware and preserves wires internal + to the selection. - Editing is gated by the logic-edit restriction (Β§2). --- diff --git a/docs/TECHNICAL_SPEC.md b/docs/TECHNICAL_SPEC.md index 15c251a..7df2766 100644 --- a/docs/TECHNICAL_SPEC.md +++ b/docs/TECHNICAL_SPEC.md @@ -164,6 +164,22 @@ Framing: JSON messages over a single persistent WebSocket connection per client. // Request historical data { "type": "history", "signal": "EPICS:PV1", "start": "2026-01-01T00:00:00Z", "end": "2026-01-02T00:00:00Z", "maxPoints": 5000 } + +// Start a control-logic live-debug session (one per client; replaces any prior). +// mode "live" β€” observe the running, enabled graph identified by graphId. +// mode "simulate" β€” dry-run the unsaved `graph` in a server sandbox (no real +// writes/config/dialogs); re-send on each edit to refresh it. +{ "type": "debugSubscribe", "mode": "live", "graphId": "g1" } +{ "type": "debugSubscribe", "mode": "simulate", "graph": { /* unsaved control-logic graph */ } } + +// Stop the current debug session (tears down any simulate sandbox). +{ "type": "debugUnsubscribe" } + +// Force a trigger node of the current debug session (live or simulate) to run +// now, as if it had fired. nodeId must be a trigger node of the watched graph; +// best-effort (dropped if the session is gone). Drives the editor's +// double-click-to-fire gesture on trigger nodes in debug mode. +{ "type": "fireTrigger", "nodeId": "t" } ``` **Server β†’ Client messages:** @@ -178,6 +194,11 @@ Framing: JSON messages over a single persistent WebSocket connection per client. // Historical data response { "type": "history", "signal": "EPICS:PV1", "points": [ { "ts": "...", "value": 1.2 }, ... ] } +// Control-logic node execution during a live-debug session. Emitted ~per node +// run for the watched graph (live) or sandbox (simulate); value is meaningful +// only when hasValue is true (e.g. an action.write's value, a flow.if branch 0/1). +{ "type": "debugNode", "graphId": "g1", "nodeId": "w", "value": 42, "hasValue": true, "ts": 1750000000000 } + // Error { "type": "error", "code": "NOT_FOUND", "message": "Signal not found" } ``` @@ -205,6 +226,7 @@ Base path: `/api/v1` | GET, PUT | `/groups` | Read or set group definitions | | GET, POST | `/synthetic` | List or create synthetic signal definitions | | GET, PUT, DELETE| `/synthetic/{name}` | Read, update, or delete a synthetic definition | +| POST | `/synthetic/trace` | Stateless single-shot trace of an unsaved graph for the live-debug view β€” every node's value; stateful ops flagged `approx` | | GET, POST | `/controllogic` | List or create server-side control-logic graphs | | GET, PUT, DELETE| `/controllogic/{id}` | Read, update, or delete a control-logic graph | | GET, POST | `/config/sets` | List or create configuration sets (schemas) | @@ -360,14 +382,48 @@ instance's `setId` in its `Meta`. ### 3.10 Access Control -Identity and global policy live in `internal/access` (`Policy`, `Level`). The user identity -is read per request from `server.trusted_user_header` (with a `default_user` fallback) and -stored on the request context. `accessMiddleware` gates mutating HTTP methods by the user's -global level. Per-panel ownership and ACL evaluation (with folder inheritance) live in -`internal/panelacl`, backed by the `acl.json` sidecar. `Policy.CanEditLogic(user)` enforces -the optional `server.logic_editors` allowlist over control-logic endpoints and over any -change to a panel's `` block; it is surfaced to the frontend through `/api/v1/me` -(`canEditLogic`) so the UI can hide logic-editing affordances. +Identity and global policy live in `internal/access` (`Policy`, `Role`, `Level`). The user +identity is read per request from `server.trusted_user_header` (with a `default_user` +fallback) and stored on the request context. Access is **role-based** through group +memberships: each `[[groups]]` block lists members by role along the cumulative ladder +`viewer < operator < logiceditor < auditor < admin`, and a user's **effective** global +capability is the highest role across all their memberships. Roles map to capabilities as: +operator+ β†’ write (`Level` is derived, `LevelWrite` for operator+, else `LevelRead`); +logiceditor+ β†’ `CanEditLogic`; auditor+ β†’ `CanViewAudit`; admin β†’ `CanAdmin`. +`accessMiddleware` gates mutating HTTP methods by the derived global level. Per-panel +ownership and ACL evaluation (with folder inheritance) live in `internal/panelacl`, backed +by the `acl.json` sidecar. + +A built-in **public** group is always present: every user (and anonymous) is an implicit +viewer member, so an identified caller is at least read-only. Groups may **nest** via a +`parent` pointer (a forest rooted at top-level groups); a member of a parent group inherits +its role on every descendant group too, unless overridden lower. Cycles are rejected +(offending parent dropped to root), and the public group is protected from rename, reparent, +and delete. As a bootstrap convenience, a `Policy` with **no roles assigned anywhere** is +treated as unconfigured β†’ fully open (everyone is admin), matching trusted-LAN/dev use; +assigning any role switches to strict mode where unlisted users are read-only viewers. + +The capability checks (`CanEditLogic`, `CanViewAudit`, `CanAdmin`) are surfaced to the +frontend through `/api/v1/me` (`canEditLogic`, `canViewAudit`, `canAdmin`) so the UI can +hide affordances. The `Policy` is seeded from the TOML config at startup but is +**runtime-mutable** through the admin pane. `Policy.EnablePersistence(storageDir)` points +it at an `access.json` sidecar; once any admin mutation is made, that file is written (tmp + +atomic rename) and, on a later startup, supersedes the TOML access config (which then only +bootstraps an empty install). All policy state is guarded by an `RWMutex` (reads share, +mutations are exclusive and persist), keeping the shared `*Policy` pointer wiring intact. + +The admin REST routes live under `/api/v1/admin/*` (all `requireAdmin`-gated): +`GET /admin/access` returns an `AccessSnapshot` (users with effective role + per-group +roles, groups with members and parents, the role ladder, and the configured flag); +`PUT /admin/users/{user}` replaces a user's full set of per-group roles (body +`{roles: {group: role}}`, creating missing groups); `POST|PUT|DELETE /admin/groups[/{name}]` +create (name + optional parent), rename + set parent and member roles, and delete groups; +and `GET /admin/stats` reports live server statistics (the `internal/metrics` counters via +`metrics.Snapshot`, the broker's observed-signal count and data-source list, Go runtime +stats, and the Linux `/proc/loadavg` load average). The frontend `AdminPane.tsx` (a modal +opened from the view-mode Tools dropdown when `canAdmin`) presents Users (per-group role +assignment with an effective-role badge), Groups (nesting + per-member roles), and +Server-stats tabs. ### 3.11 Configuration Manager @@ -565,15 +621,25 @@ storage_dir = "./interfaces" # Access control (all optional) trusted_user_header = "" # header carrying the proxy-authenticated user default_user = "" # identity when the header is absent (LAN/dev) -logic_editors = [] # users/groups allowed to edit panel & control logic -# [[server.blacklist]] # downgrade users: level = "readonly" | "noaccess" -# user = "guest" -# level = "readonly" - -# [[groups]] # named user sets for per-panel sharing -# name = "operators" -# members = ["alice", "bob"] +# Role-based access through group memberships. Roles (lowβ†’high): +# viewer < operator < logiceditor < auditor < admin +# Effective capability = highest role across all memberships. The built-in +# "public" group makes every user an implicit viewer. Groups may nest via +# "parent" (members inherit their role on descendants). No roles anywhere = open +# (everyone admin); once set, unlisted users are read-only viewers. +# [[groups]] +# name = "public" +# admins = ["alice"] # alice is a global admin +# [[groups]] +# name = "operations" +# operators = ["bob"] +# auditors = ["carol"] +# [[groups]] +# name = "engineers" +# parent = "operations" # bob inherits operator here +# logiceditors = ["dave"] +# viewers = ["erin"] [datasource.epics] enabled = true @@ -586,7 +652,7 @@ enabled = true ``` All settings can also be overridden with `UOPI_*` environment variables (e.g. -`UOPI_SERVER_LISTEN`, `UOPI_SERVER_LOGIC_EDITORS`, `UOPI_EPICS_CA_ADDR_LIST`). +`UOPI_SERVER_LISTEN`, `UOPI_EPICS_CA_ADDR_LIST`). --- @@ -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. - **Identity & access control:** the end-user identity is taken from a header set by a trusted authenticating reverse proxy (`trusted_user_header`), never from client-supplied - values β€” the proxy MUST strip any inbound copy of that header or it can be spoofed. A - global blacklist downgrades users (read-only/no-access); per-panel ACLs and the optional - `logic_editors` allowlist provide finer control. An unidentified caller (no header, no - `default_user`) is treated as a trusted-LAN user with full write access, preserving the - unproxied/SSH-tunnel deployment model. + values β€” the proxy MUST strip any inbound copy of that header or it can be spoofed. + Authorisation is role-based through group memberships (viewer/operator/logiceditor/ + auditor/admin), with the highest role across memberships deciding global capability; + per-panel ACLs provide finer per-panel control. When **no** roles are assigned anywhere + the deployment is fully open (everyone admin), preserving the unproxied/SSH-tunnel/dev + model; once any role is set, unlisted and anonymous callers are read-only viewers. --- diff --git a/internal/access/access.go b/internal/access/access.go index 1fd7c82..092e8f7 100644 --- a/internal/access/access.go +++ b/internal/access/access.go @@ -1,32 +1,114 @@ -// Package access implements uopi's global user-access policy: every user is -// trusted (full write) by default, while a configured blacklist can downgrade -// specific users to read-only or no access. It also resolves the per-request -// user identity and the userβ†’group memberships defined in config. +// Package access implements uopi's role-based access policy. Access is granted +// through group memberships: every user belongs implicitly to the built-in +// "public" group (granting the baseline viewer role), and may additionally be +// assigned a higher role in any number of named groups. Roles form a cumulative +// ladder β€” viewer < operator < logic-editor < auditor < admin β€” where each level +// includes all powers below it. A user's effective capability is the highest +// role they hold across all their groups. // -// This is the foundation layer (Phase 1). Per-panel ownership/ACL evaluation is -// layered on top of this in a later phase. +// Groups can be nested: a member of a parent group holds that role on every +// descendant group too (unless the descendant assigns them a different role), +// so an admin of an organisation group administers its sub-teams. +// +// As a bootstrap convenience, a policy with no role assignments at all is treated +// as fully open (everyone is admin), matching an unconfigured/dev deployment. +// Assigning any role switches to strict mode where unlisted users are viewers. +// +// The policy is seeded from the TOML config at startup but is runtime-mutable +// through the admin pane: once EnablePersistence is called, every mutation is +// written to a JSON sidecar ({storageDir}/access.json) which, when present on a +// later startup, becomes the source of truth (the TOML config is then only a +// bootstrap seed). All access is guarded by an RWMutex and safe for concurrent +// use; the *Policy pointer is stable so existing shared-pointer wiring is +// preserved. package access import ( "context" + "encoding/json" + "errors" + "os" + "path/filepath" "sort" "strings" + "sync" ) -// Level is a global access level. Higher levels include lower ones. +// PublicGroup is the built-in group every user implicitly belongs to. It cannot +// be renamed or deleted and is always a top-level (parentless) group. +const PublicGroup = "public" + +// Role is a cumulative access level granted by a group membership. Higher roles +// include every power of the lower ones. +type Role int + +const ( + // RoleViewer permits reads only (no signal writes). It is the baseline every + // user receives via the public group. + RoleViewer Role = iota + // RoleOperator adds signal writes (full HMI interaction). + RoleOperator + // RoleLogic adds editing panel and server-side control logic. + RoleLogic + // RoleAuditor adds viewing the audit log. + RoleAuditor + // RoleAdmin adds managing users, groups and access, and viewing server stats. + RoleAdmin +) + +// RoleNames lists the role tokens from lowest to highest, for the admin UI. +var RoleNames = []string{"viewer", "operator", "logiceditor", "auditor", "admin"} + +// String renders the role using the tokens accepted by ParseRole. +func (r Role) String() string { + switch r { + case RoleOperator: + return "operator" + case RoleLogic: + return "logiceditor" + case RoleAuditor: + return "auditor" + case RoleAdmin: + return "admin" + default: + return "viewer" + } +} + +// ParseRole maps a config/JSON token to a Role. Unknown values fall back to the +// least-privileged viewer. +func ParseRole(s string) Role { + switch strings.ToLower(strings.TrimSpace(s)) { + case "operator", "write", "operate", "rw": + return RoleOperator + case "logiceditor", "logic", "logic_editor", "editor": + return RoleLogic + case "auditor", "audit": + return RoleAuditor + case "admin", "administrator": + return RoleAdmin + default: + return RoleViewer + } +} + +// Level is a coarse global access level retained for the rest of the codebase +// (WebSocket auth, middleware, per-panel ACL capping). It is derived from a +// user's effective role: viewer β†’ read-only, operator and above β†’ write. type Level int const ( - // LevelNone denies all access. + // LevelNone denies all access. Never produced by the role model, but kept so + // existing switch statements remain exhaustive. LevelNone Level = iota - // LevelRead permits reads only (no create/update/delete/share, no signal writes). + // LevelRead permits reads only. LevelRead - // LevelWrite permits full access. This is the default for any user not blacklisted. + // LevelWrite permits full write access. LevelWrite ) -// String renders the level using the same tokens accepted by ParseLevel and -// surfaced to the frontend via /api/v1/me. +// String renders the level using the tokens surfaced to the frontend via +// /api/v1/me. func (l Level) String() string { switch l { case LevelNone: @@ -38,88 +120,218 @@ func (l Level) String() string { } } -// ParseLevel maps a config string to a Level. Unknown values restrict to -// read-only, since the only reason to list a user is to limit them. -func ParseLevel(s string) Level { - switch strings.ToLower(strings.TrimSpace(s)) { - case "noaccess", "none", "no": - return LevelNone - case "readonly", "read", "ro": - return LevelRead - case "write", "readwrite", "rw", "full": +func roleToLevel(r Role) Level { + if r >= RoleOperator { return LevelWrite - default: - return LevelRead } + return LevelRead } -// Policy holds the resolved global access configuration. It is immutable after -// construction and safe for concurrent use. +// group holds a group's parent (for nesting) and explicit per-user role +// assignments. +type group struct { + parent string + members map[string]Role +} + +// Policy holds the resolved role-based access configuration. It is safe for +// concurrent use; reads take a shared lock and admin mutations take an exclusive +// lock and persist to disk. type Policy struct { - defaultUser string - blacklist map[string]Level // user β†’ downgraded level - userGroups map[string][]string // user β†’ groups they belong to - groupNames []string // all configured group names (sorted) - logicEditors map[string]bool // users + group names allowed to edit logic - auditReaders map[string]bool // users + group names allowed to view the audit log + mu sync.RWMutex + path string // access.json path; "" disables persistence + defaultUser string // immutable after construction/load + groups map[string]*group } -// New builds a Policy. blacklist maps a username to a config level string; -// groups maps a group name to its member usernames. logicEditors optionally -// restricts who may edit panel/control logic (usernames or group names); empty -// means no restriction. -func New(defaultUser string, blacklist map[string]string, groups map[string][]string, logicEditors, auditReaders []string) *Policy { +// GroupSpec seeds one group at construction time: its name, optional parent, and +// explicit userβ†’role assignments. +type GroupSpec struct { + Name string + Parent string + Members map[string]Role +} + +// New builds a Policy from group specs. The built-in public group is always +// created. A spec listing the same user in multiple roles keeps the last one; +// callers should pass the highest intended role. +func New(defaultUser string, specs []GroupSpec) *Policy { p := &Policy{ - defaultUser: strings.TrimSpace(defaultUser), - blacklist: make(map[string]Level), - userGroups: make(map[string][]string), - logicEditors: make(map[string]bool), - auditReaders: make(map[string]bool), + defaultUser: strings.TrimSpace(defaultUser), + groups: make(map[string]*group), } - for _, e := range logicEditors { - e = strings.TrimSpace(e) - if e != "" { - p.logicEditors[e] = true - } - } - for _, e := range auditReaders { - e = strings.TrimSpace(e) - if e != "" { - p.auditReaders[e] = true - } - } - for user, lvl := range blacklist { - u := strings.TrimSpace(user) - if u == "" { + for _, s := range specs { + name := strings.TrimSpace(s.Name) + if name == "" { continue } - p.blacklist[u] = ParseLevel(lvl) - } - for g, members := range groups { - g = strings.TrimSpace(g) - if g == "" { - continue - } - p.groupNames = append(p.groupNames, g) - for _, m := range members { - m = strings.TrimSpace(m) - if m == "" { - continue + g := p.ensureGroupLocked(name) + g.parent = strings.TrimSpace(s.Parent) + for u, r := range s.Members { + if u = strings.TrimSpace(u); u != "" { + g.members[u] = r } - p.userGroups[m] = append(p.userGroups[m], g) } } - sort.Strings(p.groupNames) + p.ensureGroupLocked(PublicGroup).parent = "" + p.normalizeParentsLocked() return p } -// GroupNames returns a copy of every configured user-group name, sorted. -func (p *Policy) GroupNames() []string { - out := make([]string, len(p.groupNames)) - copy(out, p.groupNames) - return out +// ensureGroupLocked returns the named group, creating an empty one if needed. +// The caller must hold p.mu for writing (or be in construction). +func (p *Policy) ensureGroupLocked(name string) *group { + g, ok := p.groups[name] + if !ok { + g = &group{members: make(map[string]Role)} + p.groups[name] = g + } + return g } +// normalizeParentsLocked drops parent references to missing groups or that would +// form a cycle, and forces the public group to be a root. +func (p *Policy) normalizeParentsLocked() { + for name, g := range p.groups { + if name == PublicGroup { + g.parent = "" + continue + } + if g.parent == "" { + continue + } + if _, ok := p.groups[g.parent]; !ok || p.hasCycleLocked(name) { + g.parent = "" + } + } +} + +// hasCycleLocked reports whether following parent links from start loops back. +func (p *Policy) hasCycleLocked(start string) bool { + seen := make(map[string]bool) + for cur := start; cur != ""; { + if seen[cur] { + return true + } + seen[cur] = true + g, ok := p.groups[cur] + if !ok { + return false + } + cur = g.parent + } + return false +} + +// ── effective role ───────────────────────────────────────────────────────── + +// configuredLocked reports whether any explicit role is assigned anywhere. An +// unconfigured policy is treated as fully open (bootstrap-safe). +func (p *Policy) configuredLocked() bool { + for _, g := range p.groups { + if len(g.members) > 0 { + return true + } + } + return false +} + +// effectiveRoleLocked returns a user's highest role across all groups. Unlisted +// users (and anonymous callers) get the viewer baseline once the policy is +// configured; an unconfigured policy grants everyone admin. +func (p *Policy) effectiveRoleLocked(user string) Role { + if !p.configuredLocked() { + return RoleAdmin + } + best := RoleViewer + if user = strings.TrimSpace(user); user == "" { + return best + } + for _, g := range p.groups { + if r, ok := g.members[user]; ok && r > best { + best = r + } + } + return best +} + +// ── persistence ──────────────────────────────────────────────────────────── + +// persisted is the on-disk schema for access.json. +type persisted struct { + DefaultUser string `json:"defaultUser"` + Groups map[string]persistedGroup `json:"groups"` +} + +type persistedGroup struct { + Parent string `json:"parent"` + Members map[string]string `json:"members"` // user β†’ role token +} + +// EnablePersistence points the policy at {storageDir}/access.json. If the file +// exists its contents replace the TOML-seeded state (the file is the source of +// truth once written); otherwise the current state is kept and the file is only +// created on the first mutation. +func (p *Policy) EnablePersistence(storageDir string) error { + p.mu.Lock() + defer p.mu.Unlock() + p.path = filepath.Join(storageDir, "access.json") + data, err := os.ReadFile(p.path) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + var ps persisted + if err := json.Unmarshal(data, &ps); err != nil { + return err + } + p.defaultUser = strings.TrimSpace(ps.DefaultUser) + p.groups = make(map[string]*group) + for name, pg := range ps.Groups { + if name = strings.TrimSpace(name); name == "" { + continue + } + g := p.ensureGroupLocked(name) + g.parent = strings.TrimSpace(pg.Parent) + for u, token := range pg.Members { + if u = strings.TrimSpace(u); u != "" { + g.members[u] = ParseRole(token) + } + } + } + p.ensureGroupLocked(PublicGroup).parent = "" + p.normalizeParentsLocked() + return nil +} + +// saveLocked atomically persists the current state when persistence is enabled. +func (p *Policy) saveLocked() error { + if p.path == "" { + return nil + } + ps := persisted{DefaultUser: p.defaultUser, Groups: make(map[string]persistedGroup, len(p.groups))} + for name, g := range p.groups { + pg := persistedGroup{Parent: g.parent, Members: make(map[string]string, len(g.members))} + for u, r := range g.members { + pg.Members[u] = r.String() + } + ps.Groups[name] = pg + } + data, err := json.MarshalIndent(ps, "", " ") + if err != nil { + return err + } + tmp := p.path + ".tmp" + if err := os.WriteFile(tmp, data, 0o644); err != nil { + return err + } + return os.Rename(tmp, p.path) +} + +// ── reads ────────────────────────────────────────────────────────────────── + // ResolveUser trims the proxy-provided header value and falls back to the // configured default_user when it is empty (e.g. unproxied/dev deployments). func (p *Policy) ResolveUser(headerValue string) string { @@ -130,81 +342,335 @@ func (p *Policy) ResolveUser(headerValue string) string { return u } -// Level returns the global access level for a user. Users that are neither -// blacklisted nor anonymous get full write access. +// Level returns the coarse global access level for a user. func (p *Policy) Level(user string) Level { - user = strings.TrimSpace(user) - if user == "" { - // No identity at all (no proxy header, no default_user): trusted LAN. - return LevelWrite - } - if lvl, ok := p.blacklist[user]; ok { - return lvl - } - return LevelWrite + p.mu.RLock() + defer p.mu.RUnlock() + return roleToLevel(p.effectiveRoleLocked(user)) } -// LogicRestricted reports whether a logic-editor allowlist is configured. When -// false, any write-capable user may edit panel/control logic. -func (p *Policy) LogicRestricted() bool { - return len(p.logicEditors) > 0 +// EffectiveRole returns the highest role a user holds across all groups. +func (p *Policy) EffectiveRole(user string) Role { + p.mu.RLock() + defer p.mu.RUnlock() + return p.effectiveRoleLocked(user) } -// CanEditLogic reports whether a user may add or edit panel logic and -// server-side control logic. When no allowlist is configured everyone with -// write access qualifies; otherwise the user (or one of their groups) must be -// listed. Anonymous/trusted-LAN callers (user=="") are always permitted. +// CanEditLogic reports whether a user may add or edit panel and control logic +// (logic-editor role or higher). func (p *Policy) CanEditLogic(user string) bool { - user = strings.TrimSpace(user) - if user == "" { - return true - } - if !p.LogicRestricted() { - return true - } - if p.logicEditors[user] { - return true - } - for _, g := range p.userGroups[user] { - if p.logicEditors[g] { - return true - } - } - return false + p.mu.RLock() + defer p.mu.RUnlock() + return p.effectiveRoleLocked(user) >= RoleLogic } -// CanViewAudit reports whether a user may view the audit log. When no reader -// allowlist is configured everyone with read access qualifies; otherwise the -// user (or one of their groups) must be listed. Anonymous/trusted-LAN callers -// (user=="") are always permitted. +// CanViewAudit reports whether a user may view the audit log (auditor or admin). func (p *Policy) CanViewAudit(user string) bool { - user = strings.TrimSpace(user) - if user == "" { - return true - } - if len(p.auditReaders) == 0 { - return true - } - if p.auditReaders[user] { - return true - } - for _, g := range p.userGroups[user] { - if p.auditReaders[g] { - return true - } - } - return false + p.mu.RLock() + defer p.mu.RUnlock() + return p.effectiveRoleLocked(user) >= RoleAuditor } -// GroupsOf returns a copy of the groups a user belongs to. -func (p *Policy) GroupsOf(user string) []string { - src := p.userGroups[strings.TrimSpace(user)] - out := make([]string, len(src)) - copy(out, src) +// CanAdmin reports whether a user may use the admin pane (admin role). +func (p *Policy) CanAdmin(user string) bool { + p.mu.RLock() + defer p.mu.RUnlock() + return p.effectiveRoleLocked(user) >= RoleAdmin +} + +// GroupNames returns a copy of every group name, sorted. +func (p *Policy) GroupNames() []string { + p.mu.RLock() + defer p.mu.RUnlock() + return p.groupNamesLocked() +} + +func (p *Policy) groupNamesLocked() []string { + out := make([]string, 0, len(p.groups)) + for n := range p.groups { + out = append(out, n) + } + sort.Strings(out) return out } -// ── request-scoped user identity ──────────────────────────────────────────── +// GroupsOf returns the groups a user effectively belongs to: every group they +// are an explicit member of, plus that group's descendants (membership flows +// parentβ†’child). The implicit public-group viewer baseline is not included. +// Used by per-panel/folder sharing rules. +func (p *Policy) GroupsOf(user string) []string { + p.mu.RLock() + defer p.mu.RUnlock() + if user = strings.TrimSpace(user); user == "" { + return nil + } + set := make(map[string]bool) + for name, g := range p.groups { + if _, ok := g.members[user]; ok { + set[name] = true + p.addDescendantsLocked(name, set) + } + } + out := make([]string, 0, len(set)) + for n := range set { + out = append(out, n) + } + sort.Strings(out) + return out +} + +// addDescendantsLocked adds every transitive child of parent into set. +func (p *Policy) addDescendantsLocked(parent string, set map[string]bool) { + for name, g := range p.groups { + if g.parent == parent && !set[name] { + set[name] = true + p.addDescendantsLocked(name, set) + } + } +} + +// ── admin snapshot ───────────────────────────────────────────────────────── + +// MemberInfo is one user's role within a group. +type MemberInfo struct { + User string `json:"user"` + Role string `json:"role"` +} + +// GroupInfo describes one group for the admin pane. +type GroupInfo struct { + Name string `json:"name"` + Parent string `json:"parent"` + Members []MemberInfo `json:"members"` +} + +// UserInfo describes one user's memberships and resulting effective role. +type UserInfo struct { + Name string `json:"name"` + EffectiveRole string `json:"effectiveRole"` + Roles map[string]string `json:"roles"` // group β†’ role token +} + +// AccessSnapshot is the full mutable access state, rendered for the admin pane. +type AccessSnapshot struct { + DefaultUser string `json:"defaultUser"` + PublicGroup string `json:"publicGroup"` + Roles []string `json:"roles"` // role ladder, lowβ†’high + Configured bool `json:"configured"` + Users []UserInfo `json:"users"` + Groups []GroupInfo `json:"groups"` +} + +// Snapshot returns a copy of the full access configuration for the admin pane. +func (p *Policy) Snapshot() AccessSnapshot { + p.mu.RLock() + defer p.mu.RUnlock() + + snap := AccessSnapshot{ + DefaultUser: p.defaultUser, + PublicGroup: PublicGroup, + Roles: append([]string(nil), RoleNames...), + Configured: p.configuredLocked(), + } + + userSet := make(map[string]bool) + for _, name := range p.groupNamesLocked() { + g := p.groups[name] + gi := GroupInfo{Name: name, Parent: g.parent} + users := make([]string, 0, len(g.members)) + for u := range g.members { + users = append(users, u) + userSet[u] = true + } + sort.Strings(users) + for _, u := range users { + gi.Members = append(gi.Members, MemberInfo{User: u, Role: g.members[u].String()}) + } + snap.Groups = append(snap.Groups, gi) + } + + users := make([]string, 0, len(userSet)) + for u := range userSet { + users = append(users, u) + } + sort.Strings(users) + for _, u := range users { + ui := UserInfo{Name: u, EffectiveRole: p.effectiveRoleLocked(u).String(), Roles: make(map[string]string)} + for name, g := range p.groups { + if r, ok := g.members[u]; ok { + ui.Roles[name] = r.String() + } + } + snap.Users = append(snap.Users, ui) + } + return snap +} + +// ── mutations (admin pane) ───────────────────────────────────────────────── + +// ErrNotFound is returned when a named group does not exist. +var ErrNotFound = errors.New("group not found") + +// SetMemberRole assigns a user a role within an existing group. +func (p *Policy) SetMemberRole(groupName, user string, role Role) error { + groupName, user = strings.TrimSpace(groupName), strings.TrimSpace(user) + if groupName == "" { + return errors.New("empty group name") + } + if user == "" { + return errors.New("empty user") + } + p.mu.Lock() + defer p.mu.Unlock() + g, ok := p.groups[groupName] + if !ok { + return ErrNotFound + } + g.members[user] = role + return p.saveLocked() +} + +// RemoveMember drops a user's explicit role in a group. +func (p *Policy) RemoveMember(groupName, user string) error { + groupName, user = strings.TrimSpace(groupName), strings.TrimSpace(user) + p.mu.Lock() + defer p.mu.Unlock() + g, ok := p.groups[groupName] + if !ok { + return ErrNotFound + } + delete(g.members, user) + return p.saveLocked() +} + +// SetUserRoles replaces a user's full set of memberships: the user is placed in +// exactly the listed groups with the given roles and removed from all others. +// Listed groups that do not exist are created. +func (p *Policy) SetUserRoles(user string, roles map[string]Role) error { + user = strings.TrimSpace(user) + if user == "" { + return errors.New("empty user") + } + p.mu.Lock() + defer p.mu.Unlock() + want := make(map[string]Role, len(roles)) + for g, r := range roles { + if g = strings.TrimSpace(g); g != "" { + want[g] = r + p.ensureGroupLocked(g) + } + } + for name, g := range p.groups { + if r, ok := want[name]; ok { + g.members[user] = r + } else { + delete(g.members, user) + } + } + p.normalizeParentsLocked() + return p.saveLocked() +} + +// CreateGroup adds an empty group with an optional parent if it does not exist. +func (p *Policy) CreateGroup(name, parent string) error { + name, parent = strings.TrimSpace(name), strings.TrimSpace(parent) + if name == "" { + return errors.New("empty group name") + } + p.mu.Lock() + defer p.mu.Unlock() + if _, ok := p.groups[name]; ok { + return nil + } + g := p.ensureGroupLocked(name) + g.parent = parent + p.normalizeParentsLocked() + return p.saveLocked() +} + +// SetGroup replaces an existing group's parent and members. The public group's +// parent is always forced to root. +func (p *Policy) SetGroup(name, parent string, members map[string]Role) error { + name, parent = strings.TrimSpace(name), strings.TrimSpace(parent) + if name == "" { + return errors.New("empty group name") + } + p.mu.Lock() + defer p.mu.Unlock() + g, ok := p.groups[name] + if !ok { + return ErrNotFound + } + if name != PublicGroup { + g.parent = parent + } + g.members = make(map[string]Role, len(members)) + for u, r := range members { + if u = strings.TrimSpace(u); u != "" { + g.members[u] = r + } + } + p.normalizeParentsLocked() + return p.saveLocked() +} + +// RenameGroup renames a group, re-pointing any children at the new name. The +// public group cannot be renamed. +func (p *Policy) RenameGroup(oldName, newName string) error { + oldName, newName = strings.TrimSpace(oldName), strings.TrimSpace(newName) + if oldName == "" || newName == "" { + return errors.New("empty group name") + } + if oldName == PublicGroup { + return errors.New("cannot rename the public group") + } + p.mu.Lock() + defer p.mu.Unlock() + g, ok := p.groups[oldName] + if !ok { + return ErrNotFound + } + if oldName == newName { + return nil + } + if _, exists := p.groups[newName]; exists { + return errors.New("group already exists: " + newName) + } + delete(p.groups, oldName) + p.groups[newName] = g + for _, other := range p.groups { + if other.parent == oldName { + other.parent = newName + } + } + return p.saveLocked() +} + +// DeleteGroup removes a group, reparenting its children to the deleted group's +// parent. The public group cannot be deleted; member users keep other groups. +func (p *Policy) DeleteGroup(name string) error { + name = strings.TrimSpace(name) + if name == PublicGroup { + return errors.New("cannot delete the public group") + } + p.mu.Lock() + defer p.mu.Unlock() + g, ok := p.groups[name] + if !ok { + return ErrNotFound + } + parent := g.parent + delete(p.groups, name) + for _, other := range p.groups { + if other.parent == name { + other.parent = parent + } + } + p.normalizeParentsLocked() + return p.saveLocked() +} + +// ── request-scoped user identity ─────────────────────────────────────────── type ctxKey struct{} diff --git a/internal/access/access_test.go b/internal/access/access_test.go index 48deb67..9fff516 100644 --- a/internal/access/access_test.go +++ b/internal/access/access_test.go @@ -1,35 +1,266 @@ package access -import "testing" +import ( + "os" + "path/filepath" + "sync" + "testing" +) -func TestCanEditLogic(t *testing.T) { - groups := map[string][]string{"ops": {"carol"}} +// spec is a small helper to build a GroupSpec. +func spec(name, parent string, members map[string]Role) GroupSpec { + return GroupSpec{Name: name, Parent: parent, Members: members} +} - // No allowlist configured: everyone with write access may edit logic. - open := New("", nil, groups, nil, nil) +func TestUnconfiguredIsOpen(t *testing.T) { + // No roles assigned anywhere β†’ fully open (everyone is admin), matching a + // fresh/dev deployment. + p := New("", nil) for _, u := range []string{"", "alice", "carol"} { - if !open.CanEditLogic(u) { - t.Errorf("unrestricted: CanEditLogic(%q) = false, want true", u) + if !p.CanAdmin(u) { + t.Errorf("unconfigured: CanAdmin(%q) = false, want true", u) } - } - if open.LogicRestricted() { - t.Error("LogicRestricted() = true with no allowlist") - } - - // Allowlist by username and by group name. - p := New("", nil, groups, []string{"alice", "ops"}, nil) - if !p.LogicRestricted() { - t.Error("LogicRestricted() = false with allowlist set") - } - cases := map[string]bool{ - "": true, // anonymous / trusted LAN - "alice": true, // listed user - "carol": true, // member of listed group "ops" - "bob": false, // not listed - } - for u, want := range cases { - if got := p.CanEditLogic(u); got != want { - t.Errorf("CanEditLogic(%q) = %v, want %v", u, got, want) + if p.Level(u) != LevelWrite { + t.Errorf("unconfigured: Level(%q) = %v, want write", u, p.Level(u)) } } } + +func TestRoleLadder(t *testing.T) { + p := New("", []GroupSpec{ + spec("public", "", map[string]Role{"adm": RoleAdmin}), + spec("ops", "", map[string]Role{ + "viewer": RoleViewer, + "op": RoleOperator, + "logic": RoleLogic, + "aud": RoleAuditor, + }), + }) + + // Configured now β†’ unlisted users (and anonymous) are viewers (read-only). + if p.Level("") != LevelRead { + t.Errorf("anonymous Level = %v, want read", p.Level("")) + } + if p.Level("nobody") != LevelRead { + t.Errorf("unlisted Level = %v, want read", p.Level("nobody")) + } + + cases := []struct { + user string + level Level + editLogic bool + audit bool + admin bool + }{ + {"viewer", LevelRead, false, false, false}, + {"op", LevelWrite, false, false, false}, + {"logic", LevelWrite, true, false, false}, + {"aud", LevelWrite, true, true, false}, + {"adm", LevelWrite, true, true, true}, + } + for _, c := range cases { + if p.Level(c.user) != c.level { + t.Errorf("%s: Level = %v, want %v", c.user, p.Level(c.user), c.level) + } + if p.CanEditLogic(c.user) != c.editLogic { + t.Errorf("%s: CanEditLogic = %v, want %v", c.user, p.CanEditLogic(c.user), c.editLogic) + } + if p.CanViewAudit(c.user) != c.audit { + t.Errorf("%s: CanViewAudit = %v, want %v", c.user, p.CanViewAudit(c.user), c.audit) + } + if p.CanAdmin(c.user) != c.admin { + t.Errorf("%s: CanAdmin = %v, want %v", c.user, p.CanAdmin(c.user), c.admin) + } + } +} + +func TestEffectiveRoleIsMaxAcrossGroups(t *testing.T) { + p := New("", []GroupSpec{ + spec("public", "", map[string]Role{"x": RoleViewer}), + spec("a", "", map[string]Role{"x": RoleOperator}), + spec("b", "", map[string]Role{"x": RoleAuditor}), + }) + if got := p.EffectiveRole("x"); got != RoleAuditor { + t.Errorf("EffectiveRole(x) = %v, want auditor (max across groups)", got) + } +} + +func TestNestingInheritsMembership(t *testing.T) { + // org β†’ team-a β†’ squad-1. A member of org is effectively in the descendants + // too (membership flows parentβ†’child), surfaced via GroupsOf for panel ACLs. + p := New("", []GroupSpec{ + spec("org", "", map[string]Role{"boss": RoleAdmin}), + spec("team-a", "org", nil), + spec("squad-1", "team-a", nil), + }) + groups := p.GroupsOf("boss") + for _, want := range []string{"org", "team-a", "squad-1"} { + if !containsStr(groups, want) { + t.Errorf("GroupsOf(boss) = %v, missing %q", groups, want) + } + } +} + +func TestMutationsRoundTrip(t *testing.T) { + p := New("", []GroupSpec{ + spec("public", "", map[string]Role{"root": RoleAdmin}), + spec("ops", "", map[string]Role{"carol": RoleOperator}), + }) + + // Set a user's full membership set: dave operator in ops, admin in eng (new). + if err := p.SetUserRoles("dave", map[string]Role{"ops": RoleOperator, "eng": RoleAdmin}); err != nil { + t.Fatal(err) + } + if g := p.GroupsOf("dave"); !containsStr(g, "ops") || !containsStr(g, "eng") { + t.Errorf("GroupsOf(dave) = %v, want ops+eng", g) + } + if !p.CanAdmin("dave") { // admin in eng + t.Error("CanAdmin(dave) = false after admin role in eng") + } + + // Replacing membership removes dave from ops. + if err := p.SetUserRoles("dave", map[string]Role{"eng": RoleAdmin}); err != nil { + t.Fatal(err) + } + if containsStr(p.GroupsOf("dave"), "ops") { + t.Error("dave still in ops after membership replaced") + } + + // Rename carries dave's admin grant from eng β†’ engineering. + if err := p.RenameGroup("eng", "engineering"); err != nil { + t.Fatal(err) + } + if !p.CanAdmin("dave") { + t.Error("CanAdmin(dave) = false after eng renamed to engineering") + } + if !containsStr(p.GroupNames(), "engineering") || containsStr(p.GroupNames(), "eng") { + t.Errorf("GroupNames after rename = %v", p.GroupNames()) + } + + // Delete drops the group; dave loses admin but root (in public) keeps it. + if err := p.DeleteGroup("engineering"); err != nil { + t.Fatal(err) + } + if p.CanAdmin("dave") { + t.Error("CanAdmin(dave) = true after engineering deleted") + } + if !p.CanAdmin("root") { + t.Error("CanAdmin(root) = false; root grant in public should survive") + } + if err := p.DeleteGroup("nope"); err != ErrNotFound { + t.Errorf("DeleteGroup(nope) = %v, want ErrNotFound", err) + } +} + +func TestPublicGroupProtected(t *testing.T) { + p := New("", []GroupSpec{spec("public", "", map[string]Role{"a": RoleAdmin})}) + if err := p.DeleteGroup(PublicGroup); err == nil { + t.Error("DeleteGroup(public) = nil, want error") + } + if err := p.RenameGroup(PublicGroup, "other"); err == nil { + t.Error("RenameGroup(public) = nil, want error") + } + if !containsStr(p.GroupNames(), PublicGroup) { + t.Error("public group missing after protected mutations") + } +} + +func TestParentCycleRejected(t *testing.T) { + p := New("", []GroupSpec{ + spec("a", "", map[string]Role{"x": RoleViewer}), + spec("b", "a", nil), + }) + // Making a's parent b would create a cycle aβ†’bβ†’a; it must be dropped to root. + if err := p.SetGroup("a", "b", map[string]Role{"x": RoleViewer}); err != nil { + t.Fatal(err) + } + snap := p.Snapshot() + for _, g := range snap.Groups { + if g.Name == "a" && g.Parent != "" { + t.Errorf("group a parent = %q, want root (cycle should be dropped)", g.Parent) + } + } +} + +func TestPersistenceRoundTrip(t *testing.T) { + dir := t.TempDir() + + p := New("admin", []GroupSpec{ + spec("public", "", map[string]Role{"alice": RoleLogic}), + spec("ops", "", map[string]Role{"carol": RoleAdmin}), + }) + if err := p.EnablePersistence(dir); err != nil { + t.Fatal(err) + } + // A mutation triggers the first write of access.json. + if err := p.SetUserRoles("dave", map[string]Role{"ops": RoleOperator}); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(dir, "access.json")); err != nil { + t.Fatalf("access.json not written: %v", err) + } + + // A fresh policy loading the same dir should see the persisted state, not the + // (different) seed it was constructed with. + p2 := New("seed", []GroupSpec{spec("public", "", map[string]Role{"seeduser": RoleViewer})}) + if err := p2.EnablePersistence(dir); err != nil { + t.Fatal(err) + } + if p2.ResolveUser("") != "admin" { + t.Errorf("default user = %q, want admin", p2.ResolveUser("")) + } + if !p2.CanEditLogic("alice") || p2.CanEditLogic("zed") { + t.Error("logic-editor role not persisted") + } + if !p2.CanAdmin("carol") { + t.Error("admin role not persisted") + } + if !containsStr(p2.GroupsOf("dave"), "ops") { + t.Error("dave membership not persisted") + } + if p2.EffectiveRole("seeduser") != RoleViewer || p2.Level("seeduser") != LevelRead { + // seeduser came from the discarded seed; should be an unlisted viewer now. + t.Error("persisted state did not supersede the seed") + } +} + +func TestConcurrentAccess(t *testing.T) { + p := New("", []GroupSpec{ + spec("public", "", map[string]Role{"root": RoleAdmin}), + spec("ops", "", map[string]Role{"carol": RoleOperator}), + }) + if err := p.EnablePersistence(t.TempDir()); err != nil { + t.Fatal(err) + } + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Add(2) + go func() { + defer wg.Done() + for j := 0; j < 100; j++ { + p.CanAdmin("carol") + p.Level("carol") + p.GroupsOf("carol") + p.Snapshot() + } + }() + go func() { + defer wg.Done() + for j := 0; j < 100; j++ { + p.SetMemberRole("ops", "carol", RoleAuditor) + p.SetUserRoles("carol", map[string]Role{"ops": RoleOperator, "eng": RoleAdmin}) + p.RemoveMember("ops", "carol") + } + }() + } + wg.Wait() +} + +func containsStr(xs []string, v string) bool { + for _, x := range xs { + if x == v { + return true + } + } + return false +} diff --git a/internal/api/api.go b/internal/api/api.go index 4028b00..65902bf 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -2,6 +2,7 @@ package api import ( + "context" "crypto/rand" "encoding/hex" "encoding/json" @@ -12,6 +13,8 @@ import ( "net" "net/http" "net/url" + "os" + "runtime" "strconv" "strings" "time" @@ -23,6 +26,7 @@ import ( "github.com/uopi/uopi/internal/controllogic" "github.com/uopi/uopi/internal/datasource" "github.com/uopi/uopi/internal/datasource/synthetic" + "github.com/uopi/uopi/internal/metrics" "github.com/uopi/uopi/internal/panelacl" "github.com/uopi/uopi/internal/storage" ) @@ -97,6 +101,14 @@ func (h *Handler) Register(mux *http.ServeMux, prefix string) { mux.HandleFunc("DELETE "+prefix+"/folders/{id}", h.deleteFolder) // User groups defined in config (read-only; for the sharing UI) mux.HandleFunc("GET "+prefix+"/usergroups", h.listUserGroups) + // Admin pane: runtime access management + server statistics. Every route is + // gated by CanAdmin (the admins allowlist). + mux.HandleFunc("GET "+prefix+"/admin/access", h.getAdminAccess) + mux.HandleFunc("PUT "+prefix+"/admin/users/{user}", h.putAdminUser) + mux.HandleFunc("POST "+prefix+"/admin/groups", h.createAdminGroup) + mux.HandleFunc("PUT "+prefix+"/admin/groups/{name}", h.updateAdminGroup) + mux.HandleFunc("DELETE "+prefix+"/admin/groups/{name}", h.deleteAdminGroup) + mux.HandleFunc("GET "+prefix+"/admin/stats", h.getAdminStats) // Signal group tree mux.HandleFunc("GET "+prefix+"/groups", h.getGroups) mux.HandleFunc("PUT "+prefix+"/groups", h.putGroups) @@ -106,6 +118,8 @@ func (h *Handler) Register(mux *http.ServeMux, prefix string) { mux.HandleFunc("GET "+prefix+"/synthetic/{name}", h.getSynthetic) mux.HandleFunc("PUT "+prefix+"/synthetic/{name}", h.updateSynthetic) mux.HandleFunc("DELETE "+prefix+"/synthetic/{name}", h.deleteSynthetic) + // Single-shot debug trace of an unsaved synthetic graph (live editor overlay). + mux.HandleFunc("POST "+prefix+"/synthetic/trace", h.traceSynthetic) // Synthetic signal git-style versioning (list / view / promote / fork). mux.HandleFunc("GET "+prefix+"/synthetic/{name}/versions", h.listSyntheticVersions) mux.HandleFunc("GET "+prefix+"/synthetic/{name}/versions/{version}", h.getSyntheticVersion) @@ -156,6 +170,7 @@ func (h *Handler) Register(mux *http.ServeMux, prefix string) { mux.HandleFunc("GET "+prefix+"/config/rules", h.listConfigRules) mux.HandleFunc("POST "+prefix+"/config/rules", h.createConfigRule) mux.HandleFunc("POST "+prefix+"/config/rules/check", h.checkConfigRule) + mux.HandleFunc("POST "+prefix+"/config/rules/preview", h.previewConfigRule) mux.HandleFunc("GET "+prefix+"/config/rules/{id}", h.getConfigRule) mux.HandleFunc("PUT "+prefix+"/config/rules/{id}", h.updateConfigRule) mux.HandleFunc("DELETE "+prefix+"/config/rules/{id}", h.deleteConfigRule) @@ -183,6 +198,7 @@ func (h *Handler) getMe(w http.ResponseWriter, r *http.Request) { "groups": groups, "canEditLogic": h.policy.CanEditLogic(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 } -// panelPerm returns the caller's effective permission on a panel. A request -// with no resolved identity (no proxy header and no default_user) is a trusted -// LAN deployment with no per-user enforcement, so it gets full write. +// panelPerm returns the caller's effective permission on a panel. A request with +// no resolved identity (no proxy header and no default_user) has no per-user ACL, +// so it is governed purely by the global level: full write on an unconfigured +// (open) policy, read-only once roles are configured. func (h *Handler) panelPerm(r *http.Request, id string) panelacl.Perm { user := caller(r) if user == "" { - return panelacl.PermWrite + return h.capByGlobal("", panelacl.PermWrite) } return h.capByGlobal(user, h.acl.PanelPerm(id, user, h.policy.GroupsOf(user))) } // folderPerm returns the caller's effective permission on a folder. As with -// panelPerm, an unidentified caller is trusted with full write. +// panelPerm, an unidentified caller is governed by the global level (full write +// on an unconfigured/open policy, read-only once roles are configured). func (h *Handler) folderPerm(r *http.Request, id string) panelacl.Perm { user := caller(r) if user == "" { - return panelacl.PermWrite + return h.capByGlobal("", panelacl.PermWrite) } return h.capByGlobal(user, h.acl.FolderPerm(id, user, h.policy.GroupsOf(user))) } @@ -1114,6 +1132,209 @@ func (h *Handler) listUserGroups(w http.ResponseWriter, _ *http.Request) { jsonOK(w, names) } +// ── /admin ────────────────────────────────────────────────────────────────── + +// requireAdmin writes a 403 and returns false unless the caller may use the +// admin pane (CanAdmin). Anonymous/trusted-LAN callers and, when no admins +// allowlist is configured, everyone, are permitted. +func (h *Handler) requireAdmin(w http.ResponseWriter, r *http.Request) bool { + if h.policy.CanAdmin(caller(r)) { + return true + } + jsonError(w, http.StatusForbidden, "you are not permitted to administer this server") + return false +} + +// getAdminAccess returns the full mutable access configuration (users, groups, +// allowlists) for the admin pane. +func (h *Handler) getAdminAccess(w http.ResponseWriter, r *http.Request) { + if !h.requireAdmin(w, r) { + return + } + jsonOK(w, h.policy.Snapshot()) +} + +type adminUserReq struct { + // Roles maps each group the user should belong to β†’ their role token. The + // user is removed from any group not listed. Unknown groups are created. + Roles map[string]string `json:"roles"` +} + +// putAdminUser replaces a user's full set of (group β†’ role) memberships. +func (h *Handler) putAdminUser(w http.ResponseWriter, r *http.Request) { + if !h.requireAdmin(w, r) { + return + } + user := strings.TrimSpace(r.PathValue("user")) + if user == "" { + jsonError(w, http.StatusBadRequest, "empty user") + return + } + var req adminUserReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return + } + roles := make(map[string]access.Role, len(req.Roles)) + for g, token := range req.Roles { + if g = strings.TrimSpace(g); g != "" { + roles[g] = access.ParseRole(token) + } + } + if err := h.policy.SetUserRoles(user, roles); err != nil { + jsonError(w, http.StatusInternalServerError, err.Error()) + return + } + h.recordMutation(r, "admin.user", user) + jsonOK(w, h.policy.Snapshot()) +} + +type adminGroupReq struct { + Name string `json:"name"` // for update: the (possibly new) group name + Parent string `json:"parent"` // parent group for nesting ("" = root) + Members map[string]string `json:"members"` +} + +// createAdminGroup creates an empty group with an optional parent. +func (h *Handler) createAdminGroup(w http.ResponseWriter, r *http.Request) { + if !h.requireAdmin(w, r) { + return + } + var req adminGroupReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return + } + if strings.TrimSpace(req.Name) == "" { + jsonError(w, http.StatusBadRequest, "empty group name") + return + } + if err := h.policy.CreateGroup(req.Name, req.Parent); err != nil { + jsonError(w, http.StatusBadRequest, err.Error()) + return + } + h.recordMutation(r, "admin.group.create", req.Name) + w.WriteHeader(http.StatusCreated) + jsonOK(w, h.policy.Snapshot()) +} + +// updateAdminGroup renames a group (when the body name differs from the path) +// and replaces its parent and member roles. +func (h *Handler) updateAdminGroup(w http.ResponseWriter, r *http.Request) { + if !h.requireAdmin(w, r) { + return + } + old := strings.TrimSpace(r.PathValue("name")) + var req adminGroupReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return + } + name := strings.TrimSpace(req.Name) + if name == "" { + name = old + } + if name != old { + if err := h.policy.RenameGroup(old, name); err != nil { + if errors.Is(err, access.ErrNotFound) { + jsonError(w, http.StatusNotFound, "group not found: "+old) + return + } + jsonError(w, http.StatusBadRequest, err.Error()) + return + } + } + members := make(map[string]access.Role, len(req.Members)) + for u, token := range req.Members { + if u = strings.TrimSpace(u); u != "" { + members[u] = access.ParseRole(token) + } + } + if err := h.policy.SetGroup(name, req.Parent, members); err != nil { + if errors.Is(err, access.ErrNotFound) { + jsonError(w, http.StatusNotFound, "group not found: "+name) + return + } + jsonError(w, http.StatusBadRequest, err.Error()) + return + } + h.recordMutation(r, "admin.group.update", name) + jsonOK(w, h.policy.Snapshot()) +} + +// deleteAdminGroup removes a group (members keep their other groups; children +// are reparented). The built-in public group cannot be deleted. +func (h *Handler) deleteAdminGroup(w http.ResponseWriter, r *http.Request) { + if !h.requireAdmin(w, r) { + return + } + name := strings.TrimSpace(r.PathValue("name")) + if err := h.policy.DeleteGroup(name); err != nil { + if errors.Is(err, access.ErrNotFound) { + jsonError(w, http.StatusNotFound, "group not found: "+name) + return + } + jsonError(w, http.StatusBadRequest, err.Error()) + return + } + h.recordMutation(r, "admin.group.delete", name) + jsonOK(w, h.policy.Snapshot()) +} + +// getAdminStats reports live server statistics: the in-process metrics counters, +// observed signals and data sources from the broker, Go runtime stats, and the +// system load average on Linux. +func (h *Handler) getAdminStats(w http.ResponseWriter, r *http.Request) { + if !h.requireAdmin(w, r) { + return + } + m := metrics.Snapshot() + var ms runtime.MemStats + runtime.ReadMemStats(&ms) + sources := h.broker.DataSources() + dsNames := make([]string, len(sources)) + for i, ds := range sources { + dsNames[i] = ds.Name() + } + jsonOK(w, map[string]any{ + "uptimeSeconds": m.UptimeSeconds, + "wsConnections": m.WsConnections, + "observedSignals": h.broker.ActiveSubscriptions(), + "msgIn": m.MsgIn, + "msgOut": m.MsgOut, + "writeOps": m.WriteOps, + "historyReqs": m.HistoryReqs, + "goroutines": runtime.NumGoroutine(), + "memAllocBytes": ms.Alloc, + "memSysBytes": ms.Sys, + "heapInuseBytes": ms.HeapInuse, + "loadAvg": readLoadAvg(), + "dataSources": dsNames, + }) +} + +// readLoadAvg returns the 1/5/15-minute system load averages from +// /proc/loadavg, or nil on non-Linux systems or any read/parse error. +func readLoadAvg() []float64 { + data, err := os.ReadFile("/proc/loadavg") + if err != nil { + return nil + } + fields := strings.Fields(string(data)) + if len(fields) < 3 { + return nil + } + out := make([]float64, 3) + for i := 0; i < 3; i++ { + v, err := strconv.ParseFloat(fields[i], 64) + if err != nil { + return nil + } + out[i] = v + } + return out +} + // genID returns a short unique id with the given prefix (e.g. "fld-1a2b3c4d"). func genID(prefix string) string { var b [8]byte @@ -1221,6 +1442,36 @@ func (h *Handler) deleteSynthetic(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } +// traceSynthetic evaluates an unsaved synthetic graph once against the current +// live value of each source signal and returns every node's computed value. It +// persists nothing; it powers the editor's live/debug overlay. +func (h *Handler) traceSynthetic(w http.ResponseWriter, r *http.Request) { + if h.synthetic == nil { + jsonError(w, http.StatusServiceUnavailable, "synthetic data source not enabled") + return + } + var def synthetic.SignalDef + if err := json.NewDecoder(r.Body).Decode(&def); err != nil { + jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + read := func(ds, name string) (any, error) { + v, err := h.broker.ReadNow(ctx, broker.SignalRef{DS: ds, Name: name}) + if err != nil { + return nil, err + } + return v.Data, nil + } + res, err := h.synthetic.Trace(def, read) + if err != nil { + jsonError(w, http.StatusBadRequest, err.Error()) + return + } + jsonOK(w, res) +} + func (h *Handler) listSyntheticVersions(w http.ResponseWriter, r *http.Request) { if h.synthetic == nil { jsonError(w, http.StatusServiceUnavailable, "synthetic data source not enabled") diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 7c045ec..63a628b 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -61,7 +61,7 @@ func setup(t *testing.T) (*httptest.Server, func()) { clEngine := controllogic.NewEngine(ctx, brk, clStore, cfgStore, audit.Nop(), log) 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) 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) ───────────────────────────────────── var _ = os.DevNull diff --git a/internal/api/confmgr.go b/internal/api/confmgr.go index ee59fb6..f82027c 100644 --- a/internal/api/confmgr.go +++ b/internal/api/confmgr.go @@ -765,3 +765,37 @@ func (h *Handler) checkConfigRule(w http.ResponseWriter, r *http.Request) { } 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}) +} diff --git a/internal/config/config.go b/internal/config/config.go index 19b9bab..80c492a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -19,28 +19,27 @@ type Config struct { // AuditConfig controls the audit trail. When enabled, every user and automated // action that could affect the controlled system (signal writes, control-logic -// changes) is recorded to a SQLite database for later review by audit staff. +// changes) is recorded to a SQLite database for later review by audit staff. Who +// may *view* the log is governed by the auditor role (see GroupDef). type AuditConfig struct { Enabled bool `toml:"enabled"` DBPath string `toml:"db_path"` // SQLite file; default {storage_dir}/audit.db - // 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 { - Name string `toml:"name"` - Members []string `toml:"members"` -} - -// BlacklistEntry downgrades a specific user's global access level. Levels: -// "readonly" (no writes) or "noaccess" (denied). Users not listed are trusted -// with full access. -type BlacklistEntry struct { - User string `toml:"user"` - Level string `toml:"level"` + Name string `toml:"name"` + Parent string `toml:"parent"` + Viewers []string `toml:"viewers"` + Operators []string `toml:"operators"` + LogicEditors []string `toml:"logiceditors"` + Auditors []string `toml:"auditors"` + Admins []string `toml:"admins"` } type ServerConfig struct { @@ -58,18 +57,14 @@ type ServerConfig struct { // DefaultUser is the identity used when the trusted user header is absent or // empty (e.g. unproxied/dev/LAN deployments). Empty leaves the user anonymous. + // + // Access levels (write/readonly), logic-edit, audit-view and admin rights are + // all granted via group roles (see GroupDef / [[groups]]). A config with no + // group roles at all is treated as fully open (everyone is admin), so an + // unconfigured deployment behaves like trusted LAN. Once the admin pane writes + // {storage_dir}/access.json, that file β€” not this config β€” is the source of + // truth for access. DefaultUser string `toml:"default_user"` - - // Blacklist downgrades specific users' global access level. Everyone not - // listed is trusted with full write access. - Blacklist []BlacklistEntry `toml:"blacklist"` - - // LogicEditors optionally restricts who may add or edit panel logic (the - // 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 { @@ -151,18 +146,12 @@ func applyEnv(cfg *Config) { if v := env("UOPI_SERVER_DEFAULT_USER"); v != "" { cfg.Server.DefaultUser = v } - if v := env("UOPI_SERVER_LOGIC_EDITORS"); v != "" { - cfg.Server.LogicEditors = strings.Fields(v) - } if v := env("UOPI_AUDIT_ENABLED"); v != "" { cfg.Audit.Enabled = (v == "true" || v == "YES") } if v := env("UOPI_AUDIT_DB_PATH"); v != "" { cfg.Audit.DBPath = v } - if v := env("UOPI_AUDIT_READERS"); v != "" { - cfg.Audit.Readers = strings.Fields(v) - } if v := env("UOPI_EPICS_CA_ADDR_LIST"); v != "" { cfg.Datasource.EPICS.CAAddrList = v } diff --git a/internal/confmgr/cue.go b/internal/confmgr/cue.go index 99e72d5..d0c3e25 100644 --- a/internal/confmgr/cue.go +++ b/internal/confmgr/cue.go @@ -24,12 +24,20 @@ type ConfigRule struct { Name string `json:"name"` SetID string `json:"setId"` Description string `json:"description,omitempty"` - Version int `json:"version"` - Tag string `json:"tag,omitempty"` - Owner string `json:"owner,omitempty"` - Source string `json:"source"` + // Enabled gates whether the rule runs when instances of the bound set are + // saved/applied. A nil pointer (legacy rules created before the flag) is + // treated as enabled, so existing rules keep their behaviour. + Enabled *bool `json:"enabled,omitempty"` + Version int `json:"version"` + Tag string `json:"tag,omitempty"` + Owner string `json:"owner,omitempty"` + Source string `json:"source"` } +// IsEnabled reports whether the rule participates in instance evaluation. A nil +// Enabled flag (legacy rules) is treated as enabled. +func (r ConfigRule) IsEnabled() bool { return r.Enabled == nil || *r.Enabled } + // RuleViolation is a single constraint failure from evaluating a rule. type RuleViolation struct { Rule string `json:"rule,omitempty"` // rule ID that produced it (aggregate runs) diff --git a/internal/confmgr/store.go b/internal/confmgr/store.go index 0956b5e..6f790b2 100644 --- a/internal/confmgr/store.go +++ b/internal/confmgr/store.go @@ -45,6 +45,10 @@ type Meta struct { // empty for sets. Lets clients group/filter instances by set without a GET // per instance. SetID string `json:"setId,omitempty"` + // Enabled is only populated for rules: nil for sets/instances and for legacy + // rules without the flag. Lets the rule list show enabled/disabled status + // without a GET per rule. + Enabled *bool `json:"enabled,omitempty"` } // VersionMeta describes a single persisted revision. @@ -85,6 +89,7 @@ type header struct { Version int `json:"version"` Tag string `json:"tag"` SetID string `json:"setId"` + Enabled *bool `json:"enabled"` } func validateID(id string) error { @@ -149,7 +154,7 @@ func (s *Store) List(k Kind) ([]Meta, error) { if err != nil { 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 } @@ -587,6 +592,9 @@ func (s *Store) rulesForSet(setID string) ([]ConfigRule, error) { if err != nil { continue } + if !rule.IsEnabled() { + continue + } out = append(out, rule) } return out, nil diff --git a/internal/confmgr/store_test.go b/internal/confmgr/store_test.go index b7480b7..80c9d67 100644 --- a/internal/confmgr/store_test.go +++ b/internal/confmgr/store_test.go @@ -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 { for err != nil { if re, ok := err.(*RuleError); ok { diff --git a/internal/controllogic/debug.go b/internal/controllogic/debug.go new file mode 100644 index 0000000..3a11715 --- /dev/null +++ b/internal/controllogic/debug.go @@ -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() + }) + } +} diff --git a/internal/controllogic/debug_test.go b/internal/controllogic/debug_test.go new file mode 100644 index 0000000..c73f0bd --- /dev/null +++ b/internal/controllogic/debug_test.go @@ -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) + } +} diff --git a/internal/controllogic/engine.go b/internal/controllogic/engine.go index bf719ef..1cfb4d4 100644 --- a/internal/controllogic/engine.go +++ b/internal/controllogic/engine.go @@ -60,6 +60,20 @@ type Engine struct { // holds while it waits for those same goroutines to drain). notifier atomic.Value // notifierBox + // debugObs receives per-node execution events for the live debug view, and + // debugWatch is the set of graph ids with at least one live subscriber. Both + // are read lock-free by flow goroutines (same trick as notifier); emitDebug + // short-circuits cheaply when the firing graph has no watcher. + debugObs atomic.Value // debugObsBox + debugWatch atomic.Value // map[string]bool (immutable snapshot) + + // fireChs maps a debug route id (live graph id or simulate sandbox id) to its + // compiled graph's manual-fire channel, so the debug UI can force a trigger to + // run. Entries are added/removed as generations (and simulate sandboxes) come + // and go; FireTrigger does a non-blocking send so a torn-down graph drops. + fireMu sync.Mutex + fireChs map[string]chan string + // Shared live signal cache for the current generation (key "ds\0name"). liveMu sync.RWMutex live map[string]float64 @@ -88,9 +102,10 @@ func NewEngine(root context.Context, brk *broker.Broker, store *Store, cfg *conf store: store, cfg: cfg, audit: rec, - log: log, - root: root, - live: map[string]float64{}, + log: log, + root: root, + live: map[string]float64{}, + fireChs: map[string]chan string{}, } } @@ -187,6 +202,7 @@ func (e *Engine) Reload() { cg.engine = e cg.genCtx = genCtx cg.wg = wg + e.registerFire(cg.id, cg.fireCh) } // One shared updates channel feeds a single dispatch goroutine; every @@ -209,6 +225,9 @@ func (e *Engine) Reload() { for _, u := range unsubs { u() } + for _, cg := range compiled { + e.unregisterFire(cg.id, cg.fireCh) + } }() // Dispatch goroutine: keep the live cache fresh and drive level/edge triggers. @@ -268,6 +287,9 @@ func (e *Engine) write(cg *compiledGraph, target string, val float64) { cg.setLocal(name, val) return } + if cg.dryRun { + return // simulate: no real data-source write + } src, ok := e.broker.Source(ds) if !ok { e.log.Warn("control logic: write to unknown data source", "ds", ds, "signal", name) @@ -558,6 +580,14 @@ type compiledGraph struct { genCtx context.Context wg *sync.WaitGroup + // dryRun suppresses real side effects (data-source writes, config mutations, + // dialogs) β€” used by the debug "simulate" sandbox. Local-variable writes stay + // live so the flow still computes correctly. alwaysDebug forces emitDebug to + // fire regardless of debugWatch (the sandbox is its own subscriber). + dryRun bool + alwaysDebug bool + + id string name string byId map[string]Node out map[string][]wireOut @@ -567,6 +597,11 @@ type compiledGraph struct { watchers map[string][]string // signal key β†’ trigger node ids luaNodes map[string]*luaRuntime + // fireCh receives node ids of triggers the debug UI wants to fire manually. + // Drained by a generation goroutine (started in startTriggers) so the wg.Add + // in activate always happens on a tracked goroutine. + fireCh chan string + stateMu sync.Mutex levelState map[string]bool // current truth of level triggers (threshold/alarm) prevBool map[string]bool // edge detection for threshold/alarm @@ -578,6 +613,7 @@ type compiledGraph struct { func compile(g Graph) *compiledGraph { cg := &compiledGraph{ + id: g.ID, name: g.Name, byId: map[string]Node{}, out: map[string][]wireOut{}, @@ -591,6 +627,7 @@ func compile(g Graph) *compiledGraph { hasVal: map[string]bool{}, lastFire: map[string]int64{}, locals: map[string]float64{}, + fireCh: make(chan string, 16), } for _, n := range g.Nodes { cg.byId[n.ID] = n @@ -663,6 +700,24 @@ func (cg *compiledGraph) getLocal(name string) float64 { // startTriggers launches timer and cron trigger goroutines for the generation. func (cg *compiledGraph) startTriggers() { + // Manual-fire listener: drain fireCh on a tracked goroutine so activate's + // wg.Add never races the generation teardown's wg.Wait. Only fires nodes that + // are actual triggers in this graph. + cg.wg.Add(1) + go func() { + defer cg.wg.Done() + for { + select { + case id := <-cg.fireCh: + if n, ok := cg.byId[id]; ok && strings.HasPrefix(n.Kind, "trigger.") { + cg.activate(id) + } + case <-cg.genCtx.Done(): + return + } + } + }() + hasCron := false for _, n := range cg.byId { switch n.Kind { @@ -856,6 +911,8 @@ func (cg *compiledGraph) activate(triggerID string) { } } + cg.emitDebug(triggerID, 0, false) + cg.wg.Add(1) go func() { defer cg.wg.Done() @@ -888,6 +945,8 @@ func (cg *compiledGraph) run(nodeID string, ctx *runCtx) { default: } + cg.emitDebug(node.ID, 0, false) + switch node.Kind { case "gate.and": if cg.gateSatisfied(node.ID, ctx.fired) { @@ -896,9 +955,15 @@ func (cg *compiledGraph) run(nodeID string, ctx *runCtx) { case "flow.if": branch := "else" - if EvalBool(node.param("cond"), ctx.resolve) { + pass := EvalBool(node.param("cond"), ctx.resolve) + if pass { branch = "then" } + v := 0.0 + if pass { + v = 1 + } + cg.emitDebug(node.ID, v, true) cg.follow(node.ID, branch, ctx) case "flow.loop": @@ -922,11 +987,14 @@ func (cg *compiledGraph) run(nodeID string, ctx *runCtx) { case "action.write": val := EvalExpr(node.param("expr"), ctx.resolve) + cg.emitDebug(node.ID, val, true) cg.engine.write(cg, node.param("target"), val) cg.follow(node.ID, "out", ctx) case "action.config.apply": - cg.engine.applyConfig(cg, strings.TrimSpace(node.param("instance"))) + if !cg.dryRun { + cg.engine.applyConfig(cg, strings.TrimSpace(node.param("instance"))) + } cg.follow(node.ID, "out", ctx) case "action.config.read": @@ -938,15 +1006,22 @@ func (cg *compiledGraph) run(nodeID string, ctx *runCtx) { case "action.config.write": val := EvalExpr(node.param("expr"), ctx.resolve) - cg.engine.writeConfigParam(cg, strings.TrimSpace(node.param("instance")), strings.TrimSpace(node.param("key")), val) + cg.emitDebug(node.ID, val, true) + if !cg.dryRun { + cg.engine.writeConfigParam(cg, strings.TrimSpace(node.param("instance")), strings.TrimSpace(node.param("key")), val) + } cg.follow(node.ID, "out", ctx) case "action.config.create": - cg.engine.createConfigInstance(cg, strings.TrimSpace(node.param("set")), strings.TrimSpace(node.param("name")), strings.TrimSpace(node.param("from"))) + if !cg.dryRun { + cg.engine.createConfigInstance(cg, strings.TrimSpace(node.param("set")), strings.TrimSpace(node.param("name")), strings.TrimSpace(node.param("from"))) + } cg.follow(node.ID, "out", ctx) case "action.config.snapshot": - cg.engine.snapshotConfig(cg, strings.TrimSpace(node.param("set")), strings.TrimSpace(node.param("name"))) + if !cg.dryRun { + cg.engine.snapshotConfig(cg, strings.TrimSpace(node.param("set")), strings.TrimSpace(node.param("name"))) + } cg.follow(node.ID, "out", ctx) case "action.delay": @@ -967,6 +1042,7 @@ func (cg *compiledGraph) run(nodeID string, ctx *runCtx) { case "action.log": val := EvalExpr(node.param("expr"), ctx.resolve) + cg.emitDebug(node.ID, val, true) label := strings.TrimSpace(node.param("label")) cg.engine.log.Info("control logic log", "graph", cg.name, "label", label, "value", val) cg.follow(node.ID, "out", ctx) @@ -976,7 +1052,9 @@ func (cg *compiledGraph) run(nodeID string, ctx *runCtx) { cg.follow(node.ID, "out", ctx) case "action.dialog": - cg.engine.emitDialog(node) + if !cg.dryRun { + cg.engine.emitDialog(node) + } cg.follow(node.ID, "out", ctx) default: diff --git a/internal/controllogic/model.go b/internal/controllogic/model.go index e7565f0..0b21854 100644 --- a/internal/controllogic/model.go +++ b/internal/controllogic/model.go @@ -50,15 +50,25 @@ type Wire struct { To string `json:"to"` } +// NodeGroup is a cosmetic, editor-side grouping of nodes. The engine ignores it; +// it is stored and round-tripped so the editor keeps its visual organisation. +type NodeGroup struct { + ID string `json:"id"` + Label string `json:"label,omitempty"` + Members []string `json:"members"` + Collapsed bool `json:"collapsed,omitempty"` +} + // Graph is a named, independently-enableable control-logic flow. type Graph struct { - ID string `json:"id"` - Name string `json:"name"` - Enabled bool `json:"enabled"` - Version int `json:"version,omitempty"` // git-style revision; bumped on each Save - Tag string `json:"tag,omitempty"` // optional revision label (e.g. "restored from v3") - Nodes []Node `json:"nodes"` - Wires []Wire `json:"wires"` + ID string `json:"id"` + Name string `json:"name"` + Enabled bool `json:"enabled"` + Version int `json:"version,omitempty"` // git-style revision; bumped on each Save + Tag string `json:"tag,omitempty"` // optional revision label (e.g. "restored from v3") + Nodes []Node `json:"nodes"` + Wires []Wire `json:"wires"` + Groups []NodeGroup `json:"groups,omitempty"` } func (n Node) param(key string) string { diff --git a/internal/datasource/synthetic/definition.go b/internal/datasource/synthetic/definition.go index c6e59de..afa5019 100644 --- a/internal/datasource/synthetic/definition.go +++ b/internal/datasource/synthetic/definition.go @@ -49,6 +49,17 @@ type NodeDef struct { type Graph struct { Nodes []GraphNode `json:"nodes"` Output string `json:"output"` // id of the output node + // Groups is cosmetic editor metadata (node grouping/collapsing). It has no + // effect on evaluation; stored and round-tripped so the editor keeps shape. + Groups []NodeGroup `json:"groups,omitempty"` +} + +// NodeGroup is a cosmetic, editor-side grouping of graph nodes (no eval effect). +type NodeGroup struct { + ID string `json:"id"` + Label string `json:"label,omitempty"` + Members []string `json:"members"` + Collapsed bool `json:"collapsed,omitempty"` } // GraphNode is one node in a Graph. diff --git a/internal/datasource/synthetic/graph.go b/internal/datasource/synthetic/graph.go index 16a68eb..914ccfb 100644 --- a/internal/datasource/synthetic/graph.go +++ b/internal/datasource/synthetic/graph.go @@ -52,7 +52,20 @@ func (rg *runtimeGraph) sourceRefs() []broker.SignalRef { // - stateful ops (filters) and lua are scalar-only; an array input errors, // since their per-evaluation state cannot be split across array lanes. func (rg *runtimeGraph) evalSample(sourceVals map[string]dsp.Sample) (dsp.Sample, error) { - vals := make(map[string]dsp.Sample, len(rg.order)) + vals, err := rg.evalSampleTrace(sourceVals) + if err != nil { + return dsp.Sample{}, err + } + return vals[rg.outputID], nil +} + +// evalSampleTrace runs a full forward pass like evalSample but returns the value +// computed for *every* node (sources, ops and the output), keyed by node id. It +// is used by the editor's live/debug trace to show each node's current value. +// On an op error it returns the values computed so far together with the error, +// so partial results can still be displayed. +func (rg *runtimeGraph) evalSampleTrace(sourceVals map[string]dsp.Sample) (map[string]dsp.Sample, error) { + vals := make(map[string]dsp.Sample, len(rg.order)+len(sourceVals)) for id, v := range sourceVals { vals[id] = v } @@ -65,7 +78,7 @@ func (rg *runtimeGraph) evalSample(sourceVals map[string]dsp.Sample) (dsp.Sample } r, err := evalOp(n, in) 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 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 diff --git a/internal/datasource/synthetic/synthetic.go b/internal/datasource/synthetic/synthetic.go index d53e2dd..24a09f3 100644 --- a/internal/datasource/synthetic/synthetic.go +++ b/internal/datasource/synthetic/synthetic.go @@ -462,6 +462,65 @@ func outTypeOf(st *signalState) dsp.ValType { return st.rg.outType } +// TraceNode is a single node's computed value in a debug trace. +type TraceNode struct { + Value any `json:"value"` + Type string `json:"type"` // "scalar" | "array" + Approx bool `json:"approx,omitempty"` +} + +// TraceResult is the per-node outcome of a single-shot evaluation of an +// (unsaved) synthetic graph, used by the editor's live/debug overlay. +type TraceResult struct { + Nodes map[string]TraceNode `json:"nodes"` + Error string `json:"error,omitempty"` +} + +// Trace compiles def and evaluates it once with fresh state, returning the value +// computed for every node. Source node values are obtained via read(ds, name) +// (typically a broker ReadNow snapshot); a source that cannot be read defaults to +// scalar 0. Stateful ops are flagged Approx since their true running value +// depends on accumulated state this single-shot pass does not have. A per-node +// evaluation error is returned in Error with the partial values still populated. +func (s *Synthetic) Trace(def SignalDef, read func(ds, name string) (any, error)) (TraceResult, error) { + rg, err := compileGraph(def) + if err != nil { + return TraceResult{}, err + } + sourceVals := make(map[string]dsp.Sample, len(rg.sources)) + for _, src := range rg.sources { + raw, rerr := read(src.ref.DS, src.ref.Name) + if rerr != nil || raw == nil { + sourceVals[src.id] = dsp.Scalar(0) + continue + } + sourceVals[src.id] = toSample(raw) + } + vals, evalErr := rg.evalSampleTrace(sourceVals) + + opType := make(map[string]string, len(rg.order)) + for _, n := range rg.order { + if n.kind == "op" && n.op != nil { + opType[n.id] = n.op.Type() + } + } + res := TraceResult{Nodes: make(map[string]TraceNode, len(vals))} + for id, sample := range vals { + tn := TraceNode{Value: sample.AsAny(), Type: "scalar"} + if sample.IsArray { + tn.Type = "array" + } + if ot, ok := opType[id]; ok && dsp.IsStateful(ot) { + tn.Approx = true + } + res.Nodes[id] = tn + } + if evalErr != nil { + res.Error = evalErr.Error() + } + return res, nil +} + // toSample coerces a datasource.Value.Data into a dsp.Sample: arrays become // array Samples (waveforms), everything else a scalar Sample. func toSample(v any) dsp.Sample { diff --git a/internal/dsp/types.go b/internal/dsp/types.go index 6b42305..39a1429 100644 --- a/internal/dsp/types.go +++ b/internal/dsp/types.go @@ -22,8 +22,20 @@ var ( "moving_average": true, "rms": true, "lowpass": true, "derivative": true, "integrate": true, "lua": true, } + // statefulOps accumulate state across evaluations, so a single-shot trace + // (fresh state) only approximates their true running value. lua is included + // because a script may persist state in its state table. + statefulOps = map[string]bool{ + "moving_average": true, "rms": true, "lowpass": true, + "derivative": true, "integrate": true, "lua": true, + } ) +// IsStateful reports whether an op accumulates state across evaluations. The +// editor's live/debug trace flags such nodes as "approx" since it evaluates a +// single tick with fresh state. +func IsStateful(op string) bool { return statefulOps[op] } + // OpOutputType reports the output ValType of an op given its input types, and // an error if the inputs are definitely incompatible with the op. Inputs may be // ValUnknown (a source whose real type is not yet known at compile time); such diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index 91b3a15..1c3c779 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -38,6 +38,30 @@ func IncWrites() { writeOps.Add(1) } // IncHistoryReqs increments the history request counter. func IncHistoryReqs() { historyReqs.Add(1) } +// Stats is a point-in-time snapshot of the in-process counters, for rendering +// as JSON in the admin pane (the Prometheus Handler renders the same data as +// text). +type Stats struct { + UptimeSeconds float64 `json:"uptimeSeconds"` + WsConnections int64 `json:"wsConnections"` + MsgIn int64 `json:"msgIn"` + MsgOut int64 `json:"msgOut"` + WriteOps int64 `json:"writeOps"` + HistoryReqs int64 `json:"historyReqs"` +} + +// Snapshot returns the current values of all counters and gauges. +func Snapshot() Stats { + return Stats{ + UptimeSeconds: time.Since(startTime).Seconds(), + WsConnections: wsConns.Load(), + MsgIn: msgIn.Load(), + MsgOut: msgOut.Load(), + WriteOps: writeOps.Load(), + HistoryReqs: historyReqs.Load(), + } +} + // Handler returns an http.HandlerFunc that renders Prometheus-format metrics. // activeSubs is called each request to read the current number of unique signal // subscriptions from the broker; pass nil to omit the metric. diff --git a/internal/server/debughub.go b/internal/server/debughub.go new file mode 100644 index 0000000..1f1ffce --- /dev/null +++ b/internal/server/debughub.go @@ -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 +} diff --git a/internal/server/server.go b/internal/server/server.go index 77c1e3e..3426b1f 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -29,7 +29,7 @@ type Server struct { // New creates the HTTP server, registers all routes, and returns a ready-to-start Server. // synth may be nil if the synthetic data source is not enabled. -func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, 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 { rec = audit.Nop() } @@ -42,7 +42,7 @@ func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Syntheti }) // WebSocket endpoint - mux.Handle("/ws", &wsHandler{broker: brk, log: log, userHeader: trustedUserHeader, policy: policy, 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 mux.HandleFunc("/metrics", metrics.Handler(brk.ActiveSubscriptions)) diff --git a/internal/server/ws.go b/internal/server/ws.go index 7cf25cf..5b2d9a3 100644 --- a/internal/server/ws.go +++ b/internal/server/ws.go @@ -17,6 +17,7 @@ import ( "github.com/uopi/uopi/internal/access" "github.com/uopi/uopi/internal/audit" "github.com/uopi/uopi/internal/broker" + "github.com/uopi/uopi/internal/controllogic" "github.com/uopi/uopi/internal/datasource" "github.com/uopi/uopi/internal/metrics" ) @@ -37,6 +38,14 @@ type inMsg struct { // dialogResponse β€” id of the control-logic dialog being answered. ID string `json:"id,omitempty"` + // debugSubscribe β€” live observation / dry-run of a control-logic graph. + Mode string `json:"mode,omitempty"` // "live" | "simulate" + GraphID string `json:"graphId,omitempty"` // live: id of the running graph + Graph json.RawMessage `json:"graph,omitempty"` // simulate: the unsaved graph + + // fireTrigger β€” force a trigger node of the current debug session to run. + NodeID string `json:"nodeId,omitempty"` + // history Start time.Time `json:"start"` End time.Time `json:"end"` @@ -106,6 +115,8 @@ type wsHandler struct { audit audit.Recorder // dialogs fans control-logic dialogs to clients and routes responses. dialogs *DialogHub + // debug fans control-logic node-execution events to watching editors. + debug *DebugHub } func (h *wsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { @@ -149,6 +160,7 @@ func (h *wsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { policy: h.policy, audit: rec, dialogs: h.dialogs, + debug: h.debug, outCh: make(chan []byte, 512), updateCh: make(chan broker.Update, 1024), subs: make(map[broker.SignalRef]func()), @@ -160,6 +172,10 @@ func (h *wsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.dialogs.add(c) defer h.dialogs.remove(c) } + // Tear down any debug subscription (and its simulate sandbox) on disconnect. + if h.debug != nil { + defer h.debug.unsubscribe(c) + } var wg sync.WaitGroup wg.Add(3) @@ -189,6 +205,7 @@ type wsClient struct { policy *access.Policy // global access-level enforcement audit audit.Recorder // signal-write audit recorder (never nil) dialogs *DialogHub // control-logic dialog fan-out (nil if disabled) + debug *DebugHub // control-logic debug fan-out (nil if disabled) outCh chan []byte // serialised outgoing messages updateCh chan broker.Update // raw updates from the broker @@ -287,6 +304,16 @@ func (c *wsClient) handleMessage(ctx context.Context, data []byte) { c.handleHistory(ctx, msg) case "dialogResponse": c.handleDialogResponse(ctx, msg) + case "debugSubscribe": + c.handleDebugSubscribe(ctx, msg) + case "debugUnsubscribe": + if c.debug != nil { + c.debug.unsubscribe(c) + } + case "fireTrigger": + if c.debug != nil { + c.debug.fire(c, msg.NodeID) + } default: c.sendError(ctx, "UNKNOWN_TYPE", "unknown message type: "+msg.Type) } @@ -423,6 +450,26 @@ func (c *wsClient) handleDialogResponse(ctx context.Context, msg inMsg) { 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) { metrics.IncHistoryReqs() ds, ok := c.broker.Source(msg.DS) diff --git a/internal/storage/store.go b/internal/storage/store.go index b8a26ac..7f1e096 100644 --- a/internal/storage/store.go +++ b/internal/storage/store.go @@ -22,6 +22,8 @@ type InterfaceMeta struct { ID string `json:"id"` Name string `json:"name"` Version int `json:"version"` + // Kind is "plot" for split-layout plot panels, empty for free-form panels. + Kind string `json:"kind,omitempty"` } // VersionMeta describes a single persisted revision of an interface. @@ -134,6 +136,7 @@ type rootAttrs struct { Name string `xml:"name,attr"` Version int `xml:"version,attr"` Tag string `xml:"tag,attr"` + Kind string `xml:"kind,attr"` } func (s *Store) readMeta(id string) (InterfaceMeta, error) { @@ -145,7 +148,7 @@ func (s *Store) readMeta(id string) (InterfaceMeta, error) { if err := xml.Unmarshal(data, &root); err != nil { return InterfaceMeta{}, err } - return InterfaceMeta{ID: id, Name: root.Name, Version: root.Version}, nil + return InterfaceMeta{ID: id, Name: root.Name, Version: root.Version, Kind: root.Kind}, nil } // Get returns the raw XML bytes for the interface with the given ID. diff --git a/uopi.example.toml b/uopi.example.toml index 5cd739a..76926c9 100644 --- a/uopi.example.toml +++ b/uopi.example.toml @@ -11,23 +11,33 @@ trusted_user_header = "" # e.g. "X-Forwarded-User" # Identity used when the trusted header is absent/empty. Empty = anonymous. default_user = "" -# Every user is trusted with full write access by default. The blacklist -# downgrades specific users. Levels: "readonly" (view only, no writes) or -# "noaccess" (denied entirely). -# [[server.blacklist]] -# user = "guest" -# level = "readonly" - -# Named sets of users, referenced by per-panel sharing rules. +# Role-based access through group memberships. Each [[groups]] block lists +# members by role; a user's effective global capability is the highest role +# across all their memberships, along this ladder: +# viewer < operator < logiceditor < auditor < admin +# Capabilities: operator+ may write signals; logiceditor+ may edit panel and +# control logic; auditor+ may view the audit log; admin may open the admin pane. +# +# The built-in "public" group is implicit (every user is a viewer of it). A +# config with NO roles assigned anywhere is fully open (everyone is admin), for +# trusted-LAN/dev use; assigning any role switches to strict mode where unlisted +# users are read-only viewers. Once changed at runtime via the admin pane, +# {storage_dir}/access.json supersedes the groups below. +# +# Groups may nest via "parent": a member of a parent group inherits its role on +# every descendant group too (unless overridden lower). # [[groups]] -# name = "operators" -# members = ["alice", "bob"] - -# Optional: restrict who may add or edit panel logic (the block of -# interfaces) AND server-side control logic. Entries are usernames or group -# names. Empty/unset = no restriction (any writer may edit logic). Also settable -# via UOPI_SERVER_LOGIC_EDITORS (space-separated). -# logic_editors = ["operators", "alice"] +# name = "public" +# admins = ["alice"] # alice is a global admin +# [[groups]] +# name = "operations" +# operators = ["bob"] +# auditors = ["carol"] +# [[groups]] +# name = "engineers" +# parent = "operations" # bob inherits operator here +# logiceditors = ["dave"] +# viewers = ["erin"] [datasource.epics] enabled = true diff --git a/web/src/AdminPane.tsx b/web/src/AdminPane.tsx new file mode 100644 index 0000000..cf7094f --- /dev/null +++ b/web/src/AdminPane.tsx @@ -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; // 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(url: string, opts?: RequestInit): Promise { + 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 = { + 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(); + 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(null); + const [error, setError] = useState(null); + + const load = useCallback(async () => { + setError(null); + try { + setSnap(await apiJSON('/api/v1/admin/access')); + } catch (err) { setError(msg(err)); } + }, []); + useEffect(() => { load(); }, [load]); + + return ( +
    { if (e.target === e.currentTarget) onClose(); }}> +
    +
    + Admin + Manage role-based access through group memberships, and view live server statistics. +
    + +
    +
    + +
    + + + +
    + + {error &&
    {error}
    } + {snap && !snap.configured && tab !== 'stats' && ( +
    + 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. +
    + )} + + {tab === 'users' && snap && } + {tab === 'groups' && snap && } + {tab === 'stats' && } +
    +
    + ); +} + +interface ManagerProps { + snap: AccessSnapshot; + onSnap: (s: AccessSnapshot) => void; + onError: (e: string | null) => void; +} + +// ── Users ───────────────────────────────────────────────────────────────────── + +function UsersManager({ snap, onSnap, onError }: ManagerProps) { + const [selected, setSelected] = useState(null); + // Draft maps group β†’ role token for the selected user (only groups with a role). + const [draft, setDraft] = useState | 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( + `/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 ( +
    +
    +
    + setNewName((e.target as HTMLInputElement).value)} + onKeyDown={(e) => { if (e.key === 'Enter') addUser(); }} /> + +
    + {snap.users.length === 0 &&
    No users with explicit roles. Everyone is a viewer of the public group.
    } + {snap.users.map(u => ( +
    select(u.name)}> + {u.name} + {roleLabel(u.effectiveRole)} +
    + ))} +
    + +
    + {!draft &&
    Select a user, or add one, to assign roles per group.
    } + {draft && ( +
    +

    + {selected} + {effective && {roleLabel(effective)}} +

    +

    Effective access is the highest role across all groups. Every user is at least a viewer via the public group.

    + + +
    + {ordered.map(({ group, depth }) => ( +
    + + {depth > 0 && β”” } + {group.name} + {group.name === snap.publicGroup && (everyone)} + + +
    + ))} +
    + +
    + +
    +
    + )} +
    +
    + ); +} + +// ── Groups ────────────────────────────────────────────────────────────────── + +function descendantsOf(groups: GroupInfo[], name: string): Set { + const out = new Set(); + 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(null); + const [name, setName] = useState(''); + const [parent, setParent] = useState(''); + const [members, setMembers] = useState([]); + 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('/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 = {}; + 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( + `/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( + `/api/v1/admin/groups/${encodeURIComponent(selected)}`, { method: 'DELETE' }); + if (updated) onSnap(updated); + setSelected(null); + } catch (err) { onError(msg(err)); } + finally { setBusy(false); } + } + + return ( +
    +
    +
    + setNewGroup((e.target as HTMLInputElement).value)} + onKeyDown={(e) => { if (e.key === 'Enter') create(); }} /> + +
    + {ordered.map(({ group, depth }) => ( +
    select(group.name)}> + + {depth > 0 && β”” } + {group.name} + + {group.members.length} +
    + ))} +
    + +
    + {!selected &&
    Select a group, or create one, to manage its parent and member roles.
    } + {selected && ( +
    + + setName((e.target as HTMLInputElement).value)} /> + {isPublic &&

    The built-in public group cannot be renamed, reparented or deleted; every user is an implicit viewer member.

    } + + + + {!isPublic &&

    Members of a parent group inherit their role on this group too, unless overridden here.

    } + + +
    + setNewMember((e.target as HTMLInputElement).value)} + onKeyDown={(e) => { if (e.key === 'Enter') addMember(); }} /> + +
    + {members.length === 0 &&
    No explicit members.
    } +
    + {members.map(m => ( +
    + {m.user} + + +
    + ))} +
    + +
    + + {!isPublic && } +
    +
    + )} +
    +
    + ); +} + +// ── 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(null); + + useEffect(() => { + let live = true; + const load = async () => { + try { + const s = await apiJSON('/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
    Loading…
    ; + + 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 ( +
    +
    + + + {rows.map(([k, v]) => ( + + ))} + +
    {k}{v}
    +
    +

    Data sources ({stats.dataSources.length})

    +
    + {stats.dataSources.map(d => {d})} +
    +
    +
    +
    + ); +} diff --git a/web/src/Canvas.tsx b/web/src/Canvas.tsx index b409ee3..afdff8d 100644 --- a/web/src/Canvas.tsx +++ b/web/src/Canvas.tsx @@ -18,10 +18,13 @@ import PlotWidget from './widgets/PlotWidget'; import ImageWidget from './widgets/ImageWidget'; import LinkWidget from './widgets/LinkWidget'; import ConfigSelect from './widgets/ConfigSelect'; +import Container from './widgets/Container'; +import TableWidget from './widgets/TableWidget'; import ContextMenu from './ContextMenu'; import InfoPanel from './InfoPanel'; import SplitLayout from './SplitLayout'; import LogicDialogs from './LogicDialogs'; +import { centreInside, widgetTab, tabPanes } from './lib/containers'; const COMPONENTS: Record = { textview: TextView, @@ -38,8 +41,18 @@ const COMPONENTS: Record = { image: ImageWidget, link: LinkWidget, configselect: ConfigSelect, + container: Container, + table: TableWidget, }; +// Container panes are decorative frames placed behind other widgets; render them +// first so they paint underneath. +function renderOrder(widgets: Widget[]): Widget[] { + const containers = widgets.filter(w => w.type === 'container'); + const rest = widgets.filter(w => w.type !== 'container'); + return [...containers, ...rest]; +} + interface CtxState { visible: boolean; x: number; @@ -84,6 +97,26 @@ export default function Canvas({ iface, onNavigate, timeRange }: Props) { visible: false, x: 0, y: 0, signal: null, }); const [infoSignal, setInfoSignal] = useState(null); + // View-mode collapse state for collapsible container panes (ephemeral, by id). + const [collapsed, setCollapsed] = useState>(() => new Set()); + // View-mode active-tab state for tabbed container panes (ephemeral, by id). + const [activeTabs, setActiveTabs] = useState>(() => new Map()); + + function toggleCollapse(id: string) { + setCollapsed(prev => { + const next = new Set(prev); + next.has(id) ? next.delete(id) : next.add(id); + return next; + }); + } + + function selectTab(id: string, i: number) { + setActiveTabs(prev => { + const next = new Map(prev); + next.set(id, i); + return next; + }); + } // Instantiate this panel's local state variables from their initial values. useEffect(() => { @@ -157,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 (
    - {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 inner = Comp ? onCtxMenu(e, widget)} onNavigate={onNavigate} timeRange={timeRange} + collapsed={collapsed.has(widget.id)} + onToggleCollapse={() => toggleCollapse(widget.id)} + activeTab={activeTabs.get(widget.id) ?? 0} + onSelectTab={(i: number) => selectTab(widget.id, i)} /> : (
    ; } -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 // logic bound to a set, run when instances of that set are saved. @@ -55,6 +55,7 @@ interface ConfigRule { name: string; setId: string; description?: string; + enabled?: boolean; version: number; tag?: string; owner?: string; @@ -65,6 +66,14 @@ interface ConfigRule { interface RuleViolation { rule?: string; path?: string; message: string; } interface RuleResult { ok: boolean; violations?: RuleViolation[]; transformed?: Record; 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 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 }) { const [instances, setInstances] = useState([]); const [sets, setSets] = useState([]); + const [setFilter, setSetFilter] = useState(''); const [selectedId, setSelectedId] = useState(null); const hist = useUndo(); 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 visibleInstances = instances.filter(s => !setFilter || s.setId === setFilter); return (
    @@ -983,9 +994,19 @@ function InstancesManager({ registerGuard }: { registerGuard: (fn: CloseGuard |
    + {sets.length > 1 && ( +
    + +
    + )} {sets.length === 0 &&
    Create a config set first.
    } {sets.length > 0 && instances.length === 0 &&
    No instances yet.
    } - {instances.map(s => ( + {sets.length > 0 && instances.length > 0 && visibleInstances.length === 0 &&
    No instances in this set.
    } + {visibleInstances.map(s => (
    select(s.id)}> {s.name || '(unnamed)'} v{s.version} @@ -1446,6 +1467,8 @@ function RulesManager({ registerGuard }: { registerGuard: (fn: CloseGuard | null const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [check, setCheck] = useState(null); + const [setFilter, setSetFilter] = useState(''); + const [preview, setPreview] = useState(null); const [showNew, setShowNew] = useState(false); const [viewingVersion, setViewingVersion] = useState(null); const [verReload, setVerReload] = useState(0); @@ -1469,9 +1492,20 @@ function RulesManager({ registerGuard }: { registerGuard: (fn: CloseGuard | null return apiJSON(`/api/v1/config/sets/${encodeURIComponent(setId)}`); } + async function runPreview() { + if (!working) return; + setBusy(true); setError(null); + try { + const res = await apiJSON('/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) { if (dirty && !confirm('Discard unsaved changes?')) return; - setError(null); setCheck(null); + setError(null); setCheck(null); setPreview(null); try { const rule = await apiJSON(`/api/v1/config/rules/${encodeURIComponent(id)}`); const set = await loadSet(rule!.setId); @@ -1485,7 +1519,7 @@ function RulesManager({ registerGuard }: { registerGuard: (fn: CloseGuard | null async function createRule(name: string, setId: string) { setBusy(true); setError(null); 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('/api/v1/config/rules', jsonPost(body)); const set = await loadSet(created!.setId); await reload(); @@ -1573,6 +1607,7 @@ function RulesManager({ registerGuard }: { registerGuard: (fn: CloseGuard | null const completions = boundSet ? boundSet.parameters.flatMap(p => [p.key, `${p.ds}:${p.signal}`]).filter(Boolean) : []; + const visibleRules = rules.filter(s => !setFilter || s.setId === setFilter); return (
    @@ -1581,11 +1616,22 @@ function RulesManager({ registerGuard }: { registerGuard: (fn: CloseGuard | null Rules
    + {sets.length > 1 && ( +
    + +
    + )} {sets.length === 0 &&
    Create a config set first.
    } {sets.length > 0 && rules.length === 0 &&
    No rules yet.
    } - {rules.map(s => ( -
    select(s.id)}> + {sets.length > 0 && rules.length > 0 && visibleRules.length === 0 &&
    No rules in this set.
    } + {visibleRules.map(s => ( +
    select(s.id)}> {s.name || '(unnamed)'} + {!ruleEnabled(s) && off} {s.setId && {setName(s.setId)}} v{s.version} @@ -1605,6 +1651,7 @@ function RulesManager({ registerGuard }: { registerGuard: (fn: CloseGuard | null {dirty && unsaved} +
    @@ -1621,6 +1668,14 @@ function RulesManager({ registerGuard }: { registerGuard: (fn: CloseGuard | null patch({ description: (e.target as HTMLInputElement).value })} />
    +
    + + +
    CUE source
    @@ -1635,6 +1690,8 @@ function RulesManager({ registerGuard }: { registerGuard: (fn: CloseGuard | null + {preview && setPreview(null)} />} + void }) { + const input: Record = {}; + 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 ( +
    +
    + Live preview + {preview.snapshot.captured} captured Β· {preview.snapshot.failed} failed + +
    + {preview.result.compileError && ( +
    Compile error: {preview.result.compileError}
    + )} + {!preview.result.compileError && !preview.result.ok && ( +
    + {(preview.result.violations ?? []).length} violation(s): +
      + {(preview.result.violations ?? []).map((v, i) => ( +
    • {v.path ? {v.path} : null} {v.message}
    • + ))} +
    +
    + )} + {failed.length > 0 && ( +
    + Could not read: {failed.map(e => `${e.key} (${e.error})`).join(', ')} +
    + )} +
    +
    +
    Snapshot input
    +
    {JSON.stringify(input, null, 2)}
    +
    +
    +
    Processed output{preview.result.ok ? '' : ' (would be rejected)'}
    +
    {JSON.stringify(output, null, 2)}
    +
    +
    +
    + ); +} + function NewRuleDialog({ sets, busy, onCancel, onCreate }: { sets: Meta[]; busy: boolean; onCancel: () => void; onCreate: (name: string, setId: string) => void; }) { diff --git a/web/src/ControlLogicEditor.tsx b/web/src/ControlLogicEditor.tsx index e631067..dfd0fbe 100644 --- a/web/src/ControlLogicEditor.tsx +++ b/web/src/ControlLogicEditor.tsx @@ -4,6 +4,10 @@ import SignalPicker, { SignalOption } from './SignalPicker'; import LuaEditor from './LuaEditor'; import { checkExpr } from './lib/expr'; 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) ──────────────────────────── @@ -41,6 +45,7 @@ interface CLGraph { enabled: boolean; nodes: CLNode[]; wires: CLWire[]; + groups?: NodeGroup[]; } interface DataSource { name: string; } @@ -59,6 +64,8 @@ const REM = (() => { return parseFloat(getComputedStyle(document.documentElement).fontSize) || 16; })(); 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_GAP = 1.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. const BORDER = 1; 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; } @@ -354,7 +366,7 @@ export default function ControlLogicEditor({ onClose }: Props) {
    { 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 && (
    Version history
    @@ -405,24 +417,85 @@ export default function ControlLogicEditor({ onClose }: Props) { function FlowEditor({ graph, onChange }: { graph: CLGraph; - onChange: (g: { nodes: CLNode[]; wires: CLWire[] }) => void; + onChange: (g: { nodes: CLNode[]; wires: CLWire[]; groups?: NodeGroup[] }) => void; }) { const nodes = graph.nodes; const wires = graph.wires; + const groups = graph.groups ?? []; const [selectedNode, setSelectedNode] = useState(null); const [selectedWire, setSelectedWire] = useState(null); + const [selSet, setSelSet] = useState>(new Set()); + const [selectedGroup, setSelectedGroup] = useState(null); const [dataSources, setDataSources] = useState([]); const [dsSignals, setDsSignals] = useState>({}); const [configInstances, setConfigInstances] = useState<{ id: string; name: string }[]>([]); const [configSets, setConfigSets] = useState<{ id: string; name: string }[]>([]); const canvasRef = useRef(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; pushed: boolean } | null + >(null); const [pendingWire, setPendingWire] = useState<{ from: string; port: string; x: number; y: number } | null>(null); const pendingRef = useRef(pendingWire); pendingRef.current = pendingWire; + // 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({ nodes, wires, groups }); + graphRef.current = { nodes, wires, groups }; + const undoStack = useRef([]); + const redoStack = useRef([]); + 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(new Map()); + const debugStates = useRef>(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(() => { fetch('/api/v1/datasources') .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); }, [selectedNode, selected?.kind]); - function emit(next: { nodes: CLNode[]; wires: CLWire[] }) { onChange(next); } - function setNodes(next: CLNode[]) { emit({ nodes: next, wires }); } - function setWires(next: CLWire[]) { emit({ nodes, wires: next }); } + // ── History ──────────────────────────────────────────────────────────────── + // Push the current graph onto the undo stack (clearing redo). Call right + // 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) { const node: CLNode = { @@ -494,19 +604,49 @@ function FlowEditor({ graph, onChange }: { y: y ?? 40 + (nodes.length % 5) * 30, params: { ...entry.params }, }; - emit({ nodes: [...nodes, node], wires }); + emit({ nodes: [...nodes, node] }); setSelectedNode(node.id); setSelectedWire(null); + setSelSet(new Set([node.id])); + setSelectedGroup(null); } function patchParams(id: string, patch: Record) { setNodes(nodes.map(n => (n.id === id ? { ...n, params: { ...n.params, ...patch } } : n))); } - function moveNode(id: string, x: number, y: number) { - setNodes(nodes.map(n => (n.id === id ? { ...n, x, y } : n))); + function moveNodes(pos: Map, record = false) { + setNodes(nodes.map(n => (pos.has(n.id) ? { ...n, ...pos.get(n.id)! } : n)), record); } 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 (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) { if (from === to) return; @@ -518,26 +658,89 @@ function FlowEditor({ graph, onChange }: { 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()); + 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(); + 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 } { const el = canvasRef.current!; const rect = el.getBoundingClientRect(); - return { x: e.clientX - rect.left + el.scrollLeft, y: e.clientY - rect.top + el.scrollTop }; + const z = zoomRef.current; + return { x: (e.clientX - rect.left + el.scrollLeft) / z, y: (e.clientY - rect.top + el.scrollTop) / z }; } - function startNodeDrag(e: MouseEvent, node: CLNode) { - e.stopPropagation(); + function beginDrag(e: MouseEvent, ids: string[]) { const p = toCanvas(e); - dragNode.current = { id: node.id, dx: p.x - node.x, dy: p.y - node.y }; - setSelectedNode(node.id); - setSelectedWire(null); + const starts = new Map(); + for (const id of ids) { + const n = nodes.find(x => x.id === id); + if (n) starts.set(id, { x: n.x, y: n.y }); + } + dragNode.current = { ids: [...starts.keys()], ox: p.x, oy: p.y, starts, pushed: false }; window.addEventListener('mousemove', onNodeDragMove); window.addEventListener('mouseup', onNodeDragUp); } + function startNodeDrag(e: MouseEvent, node: 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) { const d = dragNode.current; if (!d) return; 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(); + for (const [id, s] of d.starts) pos.set(id, { x: Math.max(0, s.x + dx), y: Math.max(0, s.y + dy) }); + moveNodes(pos, record); } function onNodeDragUp() { dragNode.current = null; @@ -592,13 +795,20 @@ function FlowEditor({ graph, onChange }: { function onKey(e: KeyboardEvent) { const t = e.target as Element; 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 (selectedWire !== null) deleteWire(selectedWire); + if (selectedGroup !== null) ungroup(selectedGroup); + else if (selectedWire !== null) deleteWire(selectedWire); else if (selectedNode) deleteNode(selectedNode); } window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); - }, [selectedNode, selectedWire, nodes, wires]); + }, [selectedNode, selectedWire, selectedGroup, selSet, nodes, wires, groups]); const byId = new Map(nodes.map(n => [n.id, n])); @@ -607,9 +817,68 @@ function FlowEditor({ graph, onChange }: { 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(); + const boxByGroup = new Map(); + 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 (
    +
    + + + + + {debug && ( + + )} +
    +
    + + + + +
    Triggers
    {PALETTE.filter(e => category(e.kind) === 'trigger').map(paletteBtn)}
    Logic
    @@ -623,17 +892,60 @@ function FlowEditor({ graph, onChange }: {
    { setSelectedNode(null); setSelectedWire(null); }} + onMouseDown={() => { setSelectedNode(null); setSelectedWire(null); setSelectedGroup(null); setSelSet(new Set()); }} onDragOver={onCanvasDragOver} onDrop={onCanvasDrop}> -
    +
    +
    + {groupGeom.map(({ g, bounds, box }) => { + if (!bounds) return null; + const sel = selectedGroup === g.id; + if (g.collapsed && box) { + return ( +
    { e.stopPropagation(); setSelectedGroup(g.id); setSelectedNode(null); setSelectedWire(null); beginDrag(e, g.members); }}> +
    + + e.stopPropagation()} + onInput={(e) => renameGroup(g.id, (e.target as HTMLInputElement).value)} /> +
    +
    {g.members.length} node{g.members.length === 1 ? '' : 's'}
    +
    + ); + } + return ( +
    { e.stopPropagation(); setSelectedGroup(g.id); setSelectedNode(null); setSelectedWire(null); beginDrag(e, g.members); }}> +
    + + e.stopPropagation()} + onInput={(e) => renameGroup(g.id, (e.target as HTMLInputElement).value)} /> + +
    +
    + ); + })} + {wires.map((w, idx) => { const a = byId.get(w.from); const b = byId.get(w.to); if (!a || !b) return null; - const p1 = outAnchor(a, w.fromPort ?? 'out'); - const p2 = inAnchor(b); + if (wireHidden(w.from, w.to)) return null; + const p1 = resolveOut(a, w.fromPort ?? 'out'); + const p2 = resolveIn(b); return ( - {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 (
    1 ? ' flow-node-multi' : ''}${dbg ? ' ' + nodeDebugClass(dbg) : ''}${firable ? ' flow-node-firable' : ''}${grouped}`} style={`left:${node.x}px; top:${node.y}px; width:${NODE_W}px; min-height:${nodeHeight(node.kind)}px;`} + title={firable ? 'Double-click to force this trigger to fire' : undefined} onMouseDown={(e) => startNodeDrag(e, node)} + onDblClick={() => { if (firable) wsClient.debugFireTrigger(node.id); }} onMouseUp={() => { if (pendingRef.current) finishWire(node); }}> + {dbg && {formatBadge(dbg)}}
    {KIND_LABEL[node.kind]}
    - ))} + ); + })} +
    diff --git a/web/src/EditCanvas.tsx b/web/src/EditCanvas.tsx index bb9b43c..d53d78d 100644 --- a/web/src/EditCanvas.tsx +++ b/web/src/EditCanvas.tsx @@ -14,15 +14,28 @@ import Button from './widgets/Button'; import PlotWidget from './widgets/PlotWidget'; import ImageWidget from './widgets/ImageWidget'; import LinkWidget from './widgets/LinkWidget'; +import Container from './widgets/Container'; +import TableWidget from './widgets/TableWidget'; import WidgetTypePicker from './WidgetTypePicker'; +import { centreInside, widgetTab, tabPanes, withContainedWidgets } from './lib/containers'; const COMPONENTS: Record = { textview: TextView, textlabel: TextLabel, gauge: Gauge, barh: BarH, barv: BarV, led: Led, multiled: MultiLed, 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 = { textview: [200, 50], textlabel: [150, 36], @@ -38,10 +51,12 @@ export const DEFAULT_SIZES: Record = { image: [200, 150], link: [140, 50], configselect: [220, 50], + container: [320, 220], + table: [280, 160], }; // Widget types that support multiple signals (signal can be appended) -const MULTI_SIGNAL_TYPES = new Set(['plot', 'multiled']); +const MULTI_SIGNAL_TYPES = new Set(['plot', 'multiled', 'table']); // Widget types that have exactly one signal (signal can be replaced) const SINGLE_SIGNAL_TYPES = new Set(['textview', 'gauge', 'barh', 'barv', 'led', 'setvalue', 'toggle', 'button']); @@ -134,6 +149,17 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna const [signalDropMenu, setSignalDropMenu] = useState(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>(() => new Map()); + function selectTab(id: string, i: number) { + setActiveTabs(prev => { + const next = new Map(prev); + next.set(id, i); + return next; + }); + } + useEffect(() => { function onMouseMove(e: MouseEvent) { const grid = snapGridRef.current; @@ -250,8 +276,10 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna const { cx, cy } = getCanvasPos(e); - // Check if drop lands on an existing widget - const hit = iface.widgets.find(w => + // Check if drop lands on an existing widget. Ignore container panes so a + // signal dropped onto a widget that overlaps a pane still targets the widget + // (and a drop on bare pane area creates a new widget there). + const hit = iface.widgets.filter(w => w.type !== 'container').find(w => cx >= w.x && cx <= w.x + w.w && cy >= w.y && cy <= w.y + w.h ); @@ -305,6 +333,12 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna signals: [picker.signal], options: {}, }; + // If dropped inside a tabbed container, assign it to the active tab so it + // shows up alongside that tab's other content. + if (type !== 'container') { + const host = tPanes.find(c => centreInside(newWidget, c)); + if (host) newWidget.options.tab = String(activeTabs.get(host.id) ?? 0); + } onChange([...iface.widgets, newWidget]); onSelect([newWidget.id]); setPicker(p => ({ ...p, visible: false })); @@ -327,7 +361,11 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna if (ctrl && isSelected) return; - const movers = nextIds.includes(widget.id) ? nextIds : [widget.id]; + // Dragging a container also drags the widgets sitting on it (snapshotted now). + const movers = withContainedWidgets( + nextIds.includes(widget.id) ? nextIds : [widget.id], + iface.widgets, + ); dragRef.current = { startMouseX: e.clientX, startMouseY: e.clientY, @@ -357,6 +395,15 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna onSelect(selectedIds.filter(s => s !== id)); } + // A widget is hidden in the editor when it sits inside a tabbed container but + // belongs to a tab other than the one currently being edited. + const tPanes = tabPanes(iface.widgets); + function tabHidden(widget: Widget): boolean { + return tPanes.some(c => + c.id !== widget.id && centreInside(widget, c) && widgetTab(widget) !== (activeTabs.get(c.id) ?? 0), + ); + } + return (
    - {(iface.widgets || []).map(widget => { + {renderOrder(iface.widgets || []).map(widget => { + if (tabHidden(widget)) return null; const Comp = COMPONENTS[widget.type]; return Comp - ? + ? selectTab(widget.id, i)} + /> : (
    @@ -379,7 +432,8 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna ); })} - {(iface.widgets || []).map(widget => { + {renderOrder(iface.widgets || []).map(widget => { + if (tabHidden(widget)) return null; const isSelected = selectedIds.includes(widget.id); return ( diff --git a/web/src/EditMode.tsx b/web/src/EditMode.tsx index 1646282..3e81783 100644 --- a/web/src/EditMode.tsx +++ b/web/src/EditMode.tsx @@ -11,6 +11,7 @@ import PropertiesPane from './PropertiesPane'; import HelpModal from './HelpModal'; import ZoomControl from './ZoomControl'; import { useAuth, canWrite } from './lib/auth'; +import { withContainedWidgets } from './lib/containers'; import { VersionTree, DiffViewer } from './VersionHistory'; interface Props { @@ -22,6 +23,31 @@ function blankInterface(): Interface { return { id: '', name: 'New Interface', version: 1, w: 1200, h: 800, widgets: [] }; } +// Rename a widget id everywhere it is referenced so logic actions and plot +// layouts keep pointing at the same widget after the user customizes its id. +function renameInLayout(l: PlotLayout, oldId: string, newId: string): PlotLayout { + if (l.type === 'leaf') return l.widget === oldId ? { ...l, widget: newId } : l; + return { ...l, a: renameInLayout(l.a, oldId, newId), b: renameInLayout(l.b, oldId, newId) }; +} + +function renameWidgetId(f: Interface, oldId: string, newId: string): Interface { + const next: Interface = { + ...f, + widgets: (f.widgets || []).map(w => (w.id === oldId ? { ...w, id: newId } : w)), + }; + if (f.layout) next.layout = renameInLayout(f.layout, oldId, newId); + if (f.logic) { + next.logic = { + ...f.logic, + nodes: f.logic.nodes.map(n => + n.kind === 'action.widget' && n.params.widget === oldId + ? { ...n, params: { ...n.params, widget: newId } } + : n), + }; + } + return next; +} + // ── Align / distribute helpers ────────────────────────────────────────────── function alignWidgets(widgets: Widget[], ids: string[], mode: string): Widget[] { @@ -77,6 +103,8 @@ const STATIC_WIDGET_TYPES = [ { type: 'image', label: 'Image' }, { type: 'link', label: 'Link' }, { type: 'configselect', label: 'Config Selector' }, + { type: 'container', label: 'Container Pane' }, + { type: 'table', label: 'Table' }, ]; export default function EditMode({ initial, onDone }: Props) { @@ -90,6 +118,7 @@ export default function EditMode({ initial, onDone }: Props) { const [snapGrid, setSnapGrid] = useState(10); const [showSnapGrid, setShowSnapGrid] = useState(true); const [showInsertMenu, setShowInsertMenu] = useState(false); + const [showGroupMenu, setShowGroupMenu] = useState(false); const [showHelp, setShowHelp] = useState(false); const [helpSection, setHelpSection] = useState('edit'); const [leftW, setLeftW] = useState(220); @@ -195,6 +224,13 @@ export default function EditMode({ initial, onDone }: Props) { setDirty(true); }, [pushUndo]); + const handleWidgetRename = useCallback((oldId: string, newId: string) => { + pushUndo(); + setIface(f => renameWidgetId(f, oldId, newId)); + setSelectedIds(ids => ids.map(id => id === oldId ? newId : id)); + setDirty(true); + }, [pushUndo]); + const handleIfaceChange = useCallback((updated: Interface) => { setIface(updated); setDirty(true); @@ -284,8 +320,10 @@ export default function EditMode({ initial, onDone }: Props) { const step = e.shiftKey ? (snapGrid || 10) * 2 : (snapGrid || 1); const dx = e.key === 'ArrowLeft' ? -step : e.key === 'ArrowRight' ? step : 0; const dy = e.key === 'ArrowUp' ? -step : e.key === 'ArrowDown' ? step : 0; + // Nudging a container also nudges the widgets sitting on it. + const movers = withContainedWidgets(selectedIds, curWidgets); handleWidgetsChange(curWidgets.map(w => - selectedIds.includes(w.id) ? { ...w, x: w.x + dx, y: w.y + dy } : w + movers.includes(w.id) ? { ...w, x: w.x + dx, y: w.y + dy } : w )); } }, [selectedIds, snapGrid, undo, redo, handleWidgetsChange, openHelp, pushUndo, iface.kind, centerTab]); @@ -307,6 +345,43 @@ export default function EditMode({ initial, onDone }: Props) { handleWidgetsChange(distributed); }, [iface.widgets, selectedIds, handleWidgetsChange]); + // ── Group selection into a container ──────────────────────────────────────── + // Wrap the selected widgets in a new container sized to their bounding box. + // The container is appended so it paints behind the grouped widgets (the + // canvas always renders containers first); membership is geometric, so simply + // enclosing the widgets places them inside. Extra top inset leaves room for + // the title / tab bar above the grouped content. + const groupIntoContainer = useCallback((variant: 'pane' | 'tabs') => { + setShowGroupMenu(false); + const widgets = iface.widgets || []; + const sel = widgets.filter(w => selectedIds.includes(w.id)); + if (sel.length < 2) return; + const minX = Math.min(...sel.map(w => w.x)); + const minY = Math.min(...sel.map(w => w.y)); + const maxX = Math.max(...sel.map(w => w.x + w.w)); + const maxY = Math.max(...sel.map(w => w.y + w.h)); + const PAD = 12; + const TOP = 36; // room for the title / tab bar above the grouped widgets + const container: Widget = { + id: genWidgetId(), + type: 'container', + x: minX - PAD, + y: minY - TOP, + w: (maxX - minX) + PAD * 2, + h: (maxY - minY) + TOP + PAD, + signals: [], + options: variant === 'tabs' + ? { variant: 'tabs', tabs: 'Tab 1,Tab 2', bg: 'true' } + : { variant: 'pane', title: 'Group', collapsible: 'false', bg: 'true' }, + }; + // In tab mode, place the grouped widgets on the first tab so they show by default. + const rest = variant === 'tabs' + ? widgets.map(w => selectedIds.includes(w.id) ? { ...w, options: { ...w.options, tab: '0' } } : w) + : widgets; + handleWidgetsChange([...rest, container]); + setSelectedIds([container.id]); + }, [iface.widgets, selectedIds, handleWidgetsChange]); + // ── Insert static widget ─────────────────────────────────────────────────── const insertWidget = useCallback((type: string) => { @@ -324,6 +399,8 @@ export default function EditMode({ initial, onDone }: Props) { type === 'textlabel' ? { label: 'Label' } : type === 'button' ? { label: 'Button', mode: 'oneshot' } : type === 'configselect' ? { label: 'Config', set: '', output: '', instances: '' } : + type === 'container' ? { title: 'Pane', collapsible: 'false', bg: 'true' } : + type === 'table' ? { columns: 'name,value,unit', header: 'true' } : {}, }; handleWidgetsChange([...(iface.widgets || []), newWidget]); @@ -565,6 +642,16 @@ export default function EditMode({ initial, onDone }: Props) {
    )} + +
    + + {showGroupMenu && ( +
    setShowGroupMenu(false)}> + + +
    + )} +
    )} @@ -659,6 +746,7 @@ export default function EditMode({ initial, onDone }: Props) { multiCount={multiSelected ? selectedIds.length : 0} iface={iface} onChange={handleWidgetChange} + onRenameId={handleWidgetRename} onIfaceChange={handleIfaceChange} width={rightW} /> diff --git a/web/src/InterfaceList.tsx b/web/src/InterfaceList.tsx index bc6c7a4..c520a45 100644 --- a/web/src/InterfaceList.tsx +++ b/web/src/InterfaceList.tsx @@ -15,6 +15,30 @@ interface Props { onToggleCollapse: () => void; } +// Monochrome (currentColor) panel-kind glyph. Inline SVG avoids font-dependent +// emoji that may render in colour or as a missing-glyph box. +function KindIcon({ kind }: { kind?: 'panel' | 'plot' }) { + const plot = kind === 'plot'; + return ( + + {plot ? ( + + ) : ( + + )} + + ); +} + export default function InterfaceList({ onLoad, onSelect, onEdit, onNewPlot, onEditId, width, collapsed, onToggleCollapse }: Props) { const [interfaces, setInterfaces] = useState([]); const [folders, setFolders] = useState([]); @@ -87,6 +111,7 @@ export default function InterfaceList({ onLoad, onSelect, onEdit, onNewPlot, onE id: String(item.id || item.ID || ''), name: String(item.name || item.Name || ''), version: Number(item.version || item.Version || 0), + kind: item.kind === 'plot' ? ('plot' as const) : undefined, owner: item.owner ? String(item.owner) : '', folder: item.folder ? String(item.folder) : '', order: Number(item.order || 0), @@ -171,19 +196,21 @@ export default function InterfaceList({ onLoad, onSelect, onEdit, onNewPlot, onE return (
  • onSelect?.(item.id)} onDragStart={drag ? (e) => { dragId.current = item.id; e.dataTransfer!.effectAllowed = 'move'; } : undefined} onDragEnd={endDrag} onDragOver={(e) => { if (dragId.current) { e.preventDefault(); e.stopPropagation(); setDropTarget(`p:${item.id}`); } }} onDragLeave={() => setDropTarget(t => t === `p:${item.id}` ? null : t)} onDrop={(e) => dropOnPanel(item, e)} > - onSelect?.(item.id)} title={item.owner ? `Owner: ${item.owner}` : undefined}> + + {item.name || item.id} {item.perm === 'read' && ro} -
    +
    e.stopPropagation()}> {item.perm === 'write' && } {writable && } diff --git a/web/src/LogicEditor.tsx b/web/src/LogicEditor.tsx index a5900df..9083a94 100644 --- a/web/src/LogicEditor.tsx +++ b/web/src/LogicEditor.tsx @@ -3,6 +3,10 @@ import { useState, useEffect, useRef } from 'preact/hooks'; import SignalPicker, { SignalOption } from './SignalPicker'; import { checkExpr } from './lib/expr'; import type { LogicGraph, LogicNode, LogicNodeKind, LogicWire, StateVar, Widget } from './lib/types'; +import { groupBounds, groupContaining, genGroupId, pruneGroups, type NodeGroup, type Rect } from './lib/nodeGroups'; +import { useFlowZoom, type Box } from './lib/flowZoom'; +import { LogicEngine } from './lib/logic'; +import { useFlowDebug, formatBadge, badgeTitle, nodeDebugClass, type DebugSnapshot } from './lib/flowDebug'; interface DataSource { name: string; } interface SignalInfo { name: string; } @@ -34,6 +38,8 @@ const REM = (() => { return parseFloat(getComputedStyle(document.documentElement).fontSize) || 16; })(); const NODE_W = 11.5 * REM; // node width +const CANVAS_W = 125 * REM; // logical canvas size (matches .flow-canvas-inner) +const CANVAS_H = 87.5 * REM; const PORT_TOP = 2 * REM; // y of the first port row, relative to node top const PORT_GAP = 1.375 * REM; // vertical spacing between stacked output ports const PORT_R = 0.375 * REM; // port radius (half the 0.75rem dot) @@ -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. const BORDER = 1; const BORDER_TOP = 3; +// Group frame geometry. +const GROUP_PAD = 0.65 * REM; // padding around member nodes +const GROUP_HEADER = 1.65 * REM; // height of the group title bar +const GROUP_BOX_W = NODE_W; // collapsed-box width +const GROUP_BOX_H = GROUP_HEADER + 1.1 * REM; // collapsed-box height interface PaletteEntry { kind: LogicNodeKind; @@ -147,6 +158,12 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars, const [selectedNode, setSelectedNode] = useState(null); const [selectedWire, setSelectedWire] = useState(null); + // Multi-selection (Shift+click) used to build groups; selectedNode stays the + // "primary" node shown in the inspector. + const [selSet, setSelSet] = useState>(new Set()); + const [selectedGroup, setSelectedGroup] = useState(null); + + const groups = graph.groups ?? []; const [dataSources, setDataSources] = useState([]); const [dsSignals, setDsSignals] = useState>({}); @@ -154,7 +171,10 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars, const [configSets, setConfigSets] = useState<{ id: string; name: string }[]>([]); const canvasRef = useRef(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; pushed: boolean } | null + >(null); const [pendingWire, setPendingWire] = useState<{ from: string; port: string; x: number; y: number } | null>(null); const pendingRef = useRef(pendingWire); pendingRef.current = pendingWire; @@ -172,6 +192,12 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars, const canUndo = undoStack.current.length > 0; const canRedo = redoStack.current.length > 0; + // Live / debug mode: a throwaway dry-run LogicEngine evaluates the edited graph + // against live signals with all side effects (writes, config, dialogs) + // suppressed, so each node lights with its value without touching production. + const [debug, setDebug] = useState(false); + const dbgEngine = useRef(null); + useEffect(() => { fetch('/api/v1/datasources') .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); setSelectedNode(null); setSelectedWire(null); + setSelectedGroup(null); + setSelSet(new Set()); onChange(prev); bump(); } @@ -305,8 +333,9 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars, if (record) pushUndo(); onChange(next); } - function setNodes(next: LogicNode[], record = true) { setGraph({ nodes: next, wires }, record); } - function setWires(next: LogicWire[]) { setGraph({ nodes, wires: next }); } + function setNodes(next: LogicNode[], record = true) { setGraph({ nodes: next, wires, ...(groups.length ? { groups } : {}) }, record); } + function setWires(next: LogicWire[]) { setGraph({ nodes, wires: next, ...(groups.length ? { groups } : {}) }); } + function setGroups(next: NodeGroup[], record = true) { setGraph({ nodes, wires, groups: next }, record); } function addNode(entry: PaletteEntry, x?: number, y?: number) { const node: LogicNode = { @@ -316,23 +345,52 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars, y: y ?? 40 + (nodes.length % 5) * 30, params: { ...entry.params }, }; - setGraph({ nodes: [...nodes, node], wires }); + setGraph({ nodes: [...nodes, node], wires, ...(groups.length ? { groups } : {}) }); setSelectedNode(node.id); setSelectedWire(null); + setSelSet(new Set([node.id])); + setSelectedGroup(null); } function patchParams(id: string, patch: Record) { setNodes(nodes.map(n => (n.id === id ? { ...n, params: { ...n.params, ...patch } } : n))); } - function moveNode(id: string, x: number, y: number, record = false) { - setNodes(nodes.map(n => (n.id === id ? { ...n, x, y } : n)), record); + // Move several nodes at once (group / multi-select drag). `pos` maps idβ†’{x,y}. + function moveNodes(pos: Map, record = false) { + setNodes(nodes.map(n => (pos.has(n.id) ? { ...n, ...pos.get(n.id)! } : n)), record); + } + + // ── Groups ────────────────────────────────────────────────────────────────── + function groupSelection() { + if (selSet.size < 1) return; + const members = nodes.filter(n => selSet.has(n.id)).map(n => n.id); + if (members.length < 1) return; + const g: NodeGroup = { id: genGroupId(), label: 'Group', members, collapsed: false }; + setGroups([...groups, g]); + setSelectedGroup(g.id); + setSelectedNode(null); + setSelectedWire(null); + } + function ungroup(gid: string) { + setGroups(groups.filter(g => g.id !== gid)); + if (selectedGroup === gid) setSelectedGroup(null); + } + function toggleCollapse(gid: string) { + setGroups(groups.map(g => (g.id === gid ? { ...g, collapsed: !g.collapsed } : g))); + } + function renameGroup(gid: string, label: string) { + setGroups(groups.map(g => (g.id === gid ? { ...g, label } : g)), false); } function deleteNode(id: string) { + const nextNodes = nodes.filter(n => n.id !== id); + const nextGroups = pruneGroups(groups, new Set(nextNodes.map(n => n.id))); setGraph({ - nodes: nodes.filter(n => n.id !== id), + nodes: nextNodes, wires: wires.filter(w => w.from !== id && w.to !== id), + ...(nextGroups.length ? { groups: nextGroups } : {}), }); if (selectedNode === id) setSelectedNode(null); + if (selSet.has(id)) { const s = new Set(selSet); s.delete(id); setSelSet(s); } } function addWire(from: string, port: string, to: string) { if (from === to) return; @@ -365,7 +423,7 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars, const newWires = clip.wires .filter(w => idMap.has(w.from) && idMap.has(w.to)) .map(w => ({ ...w, from: idMap.get(w.from)!, to: idMap.get(w.to)! })); - setGraph({ nodes: [...cur.nodes, ...newNodes], wires: [...cur.wires, ...newWires] }); + setGraph({ nodes: [...cur.nodes, ...newNodes], wires: [...cur.wires, ...newWires], ...((cur.groups?.length) ? { groups: cur.groups } : {}) }); if (newNodes.length === 1) { setSelectedNode(newNodes[0].id); setSelectedWire(null); } } @@ -373,19 +431,44 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars, function toCanvas(e: MouseEvent): { x: number; y: number } { const el = canvasRef.current!; const rect = el.getBoundingClientRect(); - return { x: e.clientX - rect.left + el.scrollLeft, y: e.clientY - rect.top + el.scrollTop }; + const z = zoomRef.current; + return { x: (e.clientX - rect.left + el.scrollLeft) / z, y: (e.clientY - rect.top + el.scrollTop) / z }; } // ── Node dragging ────────────────────────────────────────────────────────── - function startNodeDrag(e: MouseEvent, node: LogicNode) { - e.stopPropagation(); + // Begin dragging `ids` (one node, a multi-selection, or a whole group). All + // members move together, keeping their relative offsets. + function beginDrag(e: MouseEvent, ids: string[]) { const p = toCanvas(e); - dragNode.current = { id: node.id, dx: p.x - node.x, dy: p.y - node.y, pushed: false }; - setSelectedNode(node.id); - setSelectedWire(null); + const starts = new Map(); + for (const id of ids) { + const n = nodes.find(x => x.id === id); + if (n) starts.set(id, { x: n.x, y: n.y }); + } + dragNode.current = { ids: [...starts.keys()], ox: p.x, oy: p.y, starts, pushed: false }; window.addEventListener('mousemove', onNodeDragMove); window.addEventListener('mouseup', onNodeDragUp); } + function startNodeDrag(e: MouseEvent, node: LogicNode) { + e.stopPropagation(); + // Shift+click toggles the node in the multi-selection without dragging. + if (e.shiftKey) { + const s = new Set(selSet); + if (s.has(node.id)) s.delete(node.id); else s.add(node.id); + setSelSet(s); + setSelectedNode(node.id); + setSelectedWire(null); + setSelectedGroup(null); + return; + } + setSelectedWire(null); + setSelectedGroup(null); + // Drag the whole multi-selection if this node is part of it, else just it. + const ids = selSet.has(node.id) && selSet.size > 1 ? [...selSet] : [node.id]; + if (!(selSet.has(node.id) && selSet.size > 1)) setSelSet(new Set([node.id])); + setSelectedNode(node.id); + beginDrag(e, ids); + } function onNodeDragMove(e: MouseEvent) { const d = dragNode.current; if (!d) return; @@ -394,7 +477,10 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars, // click-to-select doesn't create a spurious history step). const record = !d.pushed; d.pushed = true; - moveNode(d.id, Math.max(0, p.x - d.dx), Math.max(0, p.y - d.dy), record); + const dx = p.x - d.ox, dy = p.y - d.oy; + const pos = new Map(); + for (const [id, s] of d.starts) pos.set(id, { x: Math.max(0, s.x + dx), y: Math.max(0, s.y + dy) }); + moveNodes(pos, record); } function onNodeDragUp() { dragNode.current = null; @@ -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() === 'c') { e.preventDefault(); copySelection(); return; } if (mod && e.key.toLowerCase() === 'v') { e.preventDefault(); pasteClipboard(); return; } + // 'g' groups the current multi-selection (Ctrl+G or plain g). + if (e.key.toLowerCase() === 'g' && selSet.size >= 1) { e.preventDefault(); groupSelection(); return; } if (e.key !== 'Delete' && e.key !== 'Backspace') return; - if (selectedWire !== null) deleteWire(selectedWire); + if (selectedGroup !== null) ungroup(selectedGroup); + else if (selectedWire !== null) deleteWire(selectedWire); else if (selectedNode) deleteNode(selectedNode); } window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); - }, [selectedNode, selectedWire, nodes, wires]); + }, [selectedNode, selectedWire, selectedGroup, selSet, nodes, wires, groups]); const byId = new Map(nodes.map(n => [n.id, n])); @@ -488,12 +577,85 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars, patchParams(id, { [field]: `${cur}{${ds}:${sig}}` }); } + // ── Group geometry (for the current render) ───────────────────────────────── + const rectOf = (id: string): Rect | null => { + const n = byId.get(id); + return n ? { x: n.x, y: n.y, w: NODE_W, h: nodeHeight(n.kind) } : null; + }; + const groupGeom = groups.map(g => { + const bounds = groupBounds(g.members, rectOf, GROUP_PAD, GROUP_HEADER); + const box: Rect | null = bounds ? { x: bounds.x, y: bounds.y, w: GROUP_BOX_W, h: GROUP_BOX_H } : null; + return { g, bounds, box }; + }); + const hidden = new Set(); + const boxByGroup = new Map(); + 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 (
    + + +
    +
    + + + +
    Triggers
    {PALETTE.filter(e => category(e.kind) === 'trigger').map(paletteBtn)} @@ -514,17 +676,61 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
    { setSelectedNode(null); setSelectedWire(null); }} + onMouseDown={() => { setSelectedNode(null); setSelectedWire(null); setSelectedGroup(null); setSelSet(new Set()); }} onDragOver={onCanvasDragOver} onDrop={onCanvasDrop}> -
    +
    +
    + {/* 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 ( +
    { e.stopPropagation(); setSelectedGroup(g.id); setSelectedNode(null); setSelectedWire(null); beginDrag(e, g.members); }}> +
    + + e.stopPropagation()} + onInput={(e) => renameGroup(g.id, (e.target as HTMLInputElement).value)} /> +
    +
    {g.members.length} node{g.members.length === 1 ? '' : 's'}
    +
    + ); + } + return ( +
    { e.stopPropagation(); setSelectedGroup(g.id); setSelectedNode(null); setSelectedWire(null); beginDrag(e, g.members); }}> +
    + + e.stopPropagation()} + onInput={(e) => renameGroup(g.id, (e.target as HTMLInputElement).value)} /> + +
    +
    + ); + })} + {wires.map((w, idx) => { const a = byId.get(w.from); const b = byId.get(w.to); if (!a || !b) return null; - const p1 = outAnchor(a, w.fromPort ?? 'out'); - const p2 = inAnchor(b); + if (wireHidden(w.from, w.to)) return null; + const p1 = resolveOut(a, w.fromPort ?? 'out'); + const p2 = resolveIn(b); return ( - {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 (
    1 ? ' flow-node-multi' : ''}${dbg ? ' ' + nodeDebugClass(dbg) : ''}${firable ? ' flow-node-firable' : ''}${grouped}`} style={`left:${node.x}px; top:${node.y}px; width:${NODE_W}px; min-height:${nodeHeight(node.kind)}px;`} + title={firable ? 'Double-click to force this trigger to fire' : undefined} onMouseDown={(e) => startNodeDrag(e, node)} + onDblClick={() => { if (firable) dbgEngine.current?.fireTrigger(node.id); }} onMouseUp={() => { if (pendingRef.current) finishWire(node); }}> + {dbg && {(dbg.approx ? '~' : '') + formatBadge(dbg)}}
    {KIND_LABEL[node.kind]}
    - ))} + ); + })} +
    diff --git a/web/src/PropertiesPane.tsx b/web/src/PropertiesPane.tsx index 3b95687..3957122 100644 --- a/web/src/PropertiesPane.tsx +++ b/web/src/PropertiesPane.tsx @@ -1,12 +1,14 @@ import { h } from 'preact'; import { useState, useEffect } from 'preact/hooks'; import type { Widget, Interface } from './lib/types'; +import { containingTabPane, tabLabels } from './lib/containers'; interface Props { selected: Widget | null; multiCount: number; iface: Interface; onChange: (updated: Widget) => void; + onRenameId: (oldId: string, newId: string) => void; onIfaceChange: (updated: Interface) => void; width?: number; } @@ -44,6 +46,37 @@ 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 ( + setLocal((e.target as HTMLInputElement).value)} + onChange={commit} + onKeyDown={(e) => { + if (e.key === 'Enter') { + commit(); + (e.target as HTMLInputElement).blur(); + } + }} + /> + ); +} + // Widgets that show units from signal metadata (can be overridden) const UNIT_WIDGETS = new Set(['textview', 'gauge', 'barh', 'barv', 'setvalue']); // Widgets where the user can set a numeric format string @@ -51,9 +84,9 @@ const FORMAT_WIDGETS = new Set(['textview', 'gauge', 'barh', 'barv']); // Widgets with a signal-based label (can be overridden) const LABEL_WIDGETS = new Set(['textview', 'gauge', 'barh', 'barv', 'setvalue', 'led', 'multiled', 'toggle']); // Widgets with multiple signals -const MULTI_SIGNAL_WIDGETS = new Set(['plot', 'multiled']); +const MULTI_SIGNAL_WIDGETS = new Set(['plot', 'multiled', 'table']); -export default function PropertiesPane({ selected, multiCount, iface, onChange, onIfaceChange, width }: Props) { +export default function PropertiesPane({ selected, multiCount, iface, onChange, onRenameId, onIfaceChange, width }: Props) { const [collapsed, setCollapsed] = useState(false); const [configSets, setConfigSets] = useState<{ id: string; name: string }[]>([]); @@ -77,6 +110,17 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange, onChange({ ...selected, [key]: value } as Widget); } + // Apply a custom widget id. Rejects empty or duplicate ids (returns false so + // the field reverts); on success the rename propagates to logic/plot refs. + function renameId(newId: string): boolean { + if (!selected) return false; + if (!newId) return false; + if (newId === selected.id) return false; + if ((iface.widgets || []).some(x => x.id === newId)) return false; + onRenameId(selected.id, newId); + return true; + } + function removeSignal(idx: number) { if (!selected) return; const signals = selected.signals.filter((_, i) => i !== idx); @@ -135,6 +179,11 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
    {w.type}
    + {/* Widget id β€” editable so logic/plot refs can use a friendly name */} + + + + {/* Position / size */} {LABEL_WIDGETS.has(w.type) ? (
    @@ -428,6 +477,115 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
    )} + + {w.type === 'container' && ( +
    + + + + + setOpt('accent', v)} /> + + + + + {(w.options['variant'] ?? 'pane') === 'tabs' ? ( + +
    + setOpt('tabs', v)} /> + Comma-separated tab names. Assign each widget to a tab via its "Tab" field. +
    +
    + ) : ( +
    + + setOpt('title', v)} /> + + + + +
    + )} +
    + )} + + {w.type === 'table' && (() => { + const allCols = ['name', 'value', 'unit', 'quality', 'time']; + const colLabels: Record = { + 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 ( +
    + + setOpt('title', v)} /> + + +
    + {allCols.map(c => ( + + ))} +
    +
    + + + + +
    + setOpt('format', v)} /> + 3f Β· 2e Β· 4g Β· empty=auto +
    +
    + +
    + setOpt('labels', v)} /> + Comma-separated, one per signal. Empty = signal name. +
    +
    +
    + ); + })()} + + {/* 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 ( + + + + ); + })()}
    )} diff --git a/web/src/SyntheticGraphEditor.tsx b/web/src/SyntheticGraphEditor.tsx index 9a6a26e..e9bcb80 100644 --- a/web/src/SyntheticGraphEditor.tsx +++ b/web/src/SyntheticGraphEditor.tsx @@ -5,6 +5,9 @@ import LuaEditor from './LuaEditor'; import type { SignalDef, SynthGraph, SynthGraphNode } from './lib/types'; import { inferNodeTypes, portAccept, typesCompatible, SynthType } from './lib/synthTypes'; 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 SignalInfo { name: string; type?: string; } @@ -91,7 +94,7 @@ interface GNode { } // A wire terminates on a specific input port (index) of the target node. 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) ───────────────────────────── const REM = (() => { @@ -99,6 +102,9 @@ const REM = (() => { return parseFloat(getComputedStyle(document.documentElement).fontSize) || 16; })(); 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_GAP = 1.25 * REM; // vertical spacing between stacked input ports 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. const BORDER = 1; 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 hasOutput(k: NodeKind): boolean { return k !== 'output'; } @@ -163,7 +174,8 @@ function buildInitial(def: SignalDef): Graph { for (const n of def.graph.nodes) { (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. @@ -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 }; 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). @@ -308,6 +321,9 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o const [graph, setGraph] = useState({ nodes: [], wires: [] }); const [selected, setSelected] = useState(null); const [selectedWire, setSelectedWire] = useState(null); + // Multi-selection (Shift+click) and the selected group, for node grouping. + const [selSet, setSelSet] = useState>(new Set()); + const [selectedGroup, setSelectedGroup] = useState(null); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [error, setError] = useState(''); @@ -333,7 +349,8 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o const [dsSignals, setDsSignals] = useState>({}); const canvasRef = useRef(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; pushed: boolean } | null>(null); const [pendingWire, setPendingWire] = useState<{ from: string; x: number; y: number } | null>(null); const pendingRef = useRef(pendingWire); pendingRef.current = pendingWire; @@ -342,6 +359,8 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o graphRef.current = graph; const undoStack = useRef([]); const redoStack = useRef([]); + const clipboard = useRef<{ nodes: GNode[]; wires: GWire[] } | null>(null); + const [debug, setDebug] = useState(false); const [, setTick] = useState(0); const bump = () => setTick(t => t + 1); 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]; 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) { 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() { 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); - setSelected(null); setSelectedWire(null); + setSelected(null); setSelectedWire(null); setSelSet(new Set()); setSelectedGroup(null); setGraph(prev); bump(); } function redo() { @@ -444,7 +467,7 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o const next = redoStack.current[0]; undoStack.current = [...undoStack.current, graphRef.current]; redoStack.current = redoStack.current.slice(1); - setSelected(null); setSelectedWire(null); + setSelected(null); setSelectedWire(null); setSelSet(new Set()); setSelectedGroup(null); setGraph(next); bump(); } @@ -527,15 +550,79 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o .map(w => (w.to === id && w.toPort > idx ? { ...w, toPort: w.toPort - 1 } : w)); 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, record: boolean) { 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) { const n = graph.nodes.find(x => x.id === id); 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 (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()); + 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(); + 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) { if (from === to) return; @@ -572,13 +659,35 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o function toCanvas(e: MouseEvent) { const el = canvasRef.current!; const rect = el.getBoundingClientRect(); - return { x: e.clientX - rect.left + el.scrollLeft, y: e.clientY - rect.top + el.scrollTop }; + const z = zoomRef.current; + return { x: (e.clientX - rect.left + el.scrollLeft) / z, y: (e.clientY - rect.top + el.scrollTop) / z }; + } + // 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(); + 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) { e.stopPropagation(); - const p = toCanvas(e); - dragNode.current = { id: node.id, dx: p.x - node.x, dy: p.y - node.y, pushed: false }; - setSelected(node.id); setSelectedWire(null); + // 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); 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) { e.stopPropagation(); @@ -604,7 +713,10 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o const p = toCanvas(e); const record = !d.pushed; d.pushed = true; - moveNode(d.id, Math.max(0, p.x - d.dx), Math.max(0, p.y - d.dy), record); + const dx = p.x - d.ox, dy = p.y - d.oy; + const pos = new Map(); + 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; } const cur = pendingRef.current; @@ -651,22 +763,86 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o const mod = e.ctrlKey || e.metaKey; if (mod && e.key.toLowerCase() === 'z' && !e.shiftKey) { e.preventDefault(); undo(); return; } if (mod && (e.key.toLowerCase() === 'y' || (e.key.toLowerCase() === 'z' && e.shiftKey))) { e.preventDefault(); redo(); return; } + if (mod && e.key.toLowerCase() === 'c') { e.preventDefault(); copySelection(); return; } + if (mod && e.key.toLowerCase() === 'v') { e.preventDefault(); pasteClipboard(); return; } if (mod) return; if (e.key === 'Escape') { if (hud) closeHud(); return; } if (e.key.toLowerCase() === 's') { e.preventDefault(); openHud('signal'); return; } if (e.key.toLowerCase() === 'n') { e.preventDefault(); openHud('node'); return; } + // '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 (selectedWire !== null) deleteWire(selectedWire); + if (selectedGroup !== null) ungroup(selectedGroup); + else if (selectedWire !== null) deleteWire(selectedWire); else if (selected) deleteNode(selected); } window.addEventListener('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 groups = graph.groups ?? []; const sel = graph.nodes.find(n => n.id === selected) ?? null; 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(); + const boxByGroup = new Map(); + 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 // colour wires and surface type-incompatible wirings as node errors. Mirrors // the backend dsp.OpOutputType rules (see lib/synthTypes.ts). @@ -802,6 +978,16 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
    + + +
    +
    + + + +
    Inputs
    @@ -820,15 +1006,59 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
    { setSelected(null); setSelectedWire(null); }} + onMouseDown={() => { setSelected(null); setSelectedWire(null); setSelectedGroup(null); setSelSet(new Set()); }} onDragOver={onCanvasDragOver} onDrop={onCanvasDrop}> -
    +
    +
    + {/* 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 ( +
    { e.stopPropagation(); setSelectedGroup(g.id); setSelected(null); setSelectedWire(null); beginDrag(e, g.members); }}> +
    + + e.stopPropagation()} + onInput={(e) => renameGroup(g.id, (e.target as HTMLInputElement).value)} /> +
    +
    {g.members.length} node{g.members.length === 1 ? '' : 's'}
    +
    + ); + } + return ( +
    { e.stopPropagation(); setSelectedGroup(g.id); setSelected(null); setSelectedWire(null); beginDrag(e, g.members); }}> +
    + + e.stopPropagation()} + onInput={(e) => renameGroup(g.id, (e.target as HTMLInputElement).value)} /> + +
    +
    + ); + })} + {graph.wires.map((w, idx) => { const a = byId.get(w.from); const b = byId.get(w.to); if (!a || !b) return null; - const p1 = outAnchor(a); 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'; return ( - {graph.nodes.map(node => { + {graph.nodes.filter(n => !hiddenNodes.has(n.id)).map(node => { const nIn = inPortCount(node, graph.wires); + const dbg = debug ? debugSnap.get(node.id) : undefined; + const grouped = groupContaining(groups, node.id) ? ' flow-node-grouped' : ''; return (
    1 ? ' flow-node-multi' : ''}${dbg ? ' ' + nodeDebugClass(dbg) : ''}${grouped}`} title={nodeErrors.get(node.id) || undefined} style={`left:${node.x}px; top:${node.y}px; width:${NODE_W}px; min-height:${nodeMinHeight(node, graph.wires)}px;`} onMouseDown={(e) => startNodeDrag(e, node)} onMouseUp={() => { if (pendingRef.current) finishWire(node, firstFreePort(node)); }}> + {dbg && ( + + {(dbg.approx ? '~' : '') + formatBadge(dbg)} + + )}
    {node.kind === 'source' ? 'Signal' : node.kind === 'output' ? 'Output' : opLabel(node.op ?? '')} {node.kind !== 'output' && ( @@ -887,6 +1124,7 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
    ); })} +
    diff --git a/web/src/ViewMode.tsx b/web/src/ViewMode.tsx index 5e394f3..f17951f 100644 --- a/web/src/ViewMode.tsx +++ b/web/src/ViewMode.tsx @@ -12,6 +12,7 @@ import ZoomControl from './ZoomControl'; import ControlLogicEditor from './ControlLogicEditor'; import AuditViewer from './AuditViewer'; import ConfigManager from './ConfigManager'; +import AdminPane from './AdminPane'; interface Props { onEdit?: (iface?: Interface) => void; @@ -36,13 +37,14 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) { const [showControlLogic, setShowControlLogic] = useState(false); const [showAudit, setShowAudit] = useState(false); const [showConfig, setShowConfig] = useState(false); + const [showAdmin, setShowAdmin] = useState(false); const [toolsOpen, setToolsOpen] = useState(false); const [leftW, setLeftW] = useState(220); const [listCollapsed, setListCollapsed] = useState(false); const me = useAuth(); const writable = canWrite(me.level); // Advanced tools collapsed into the Tools dropdown (shown only if β‰₯1 available). - const hasTools = (writable && me.canEditLogic) || me.canViewAudit || writable; + const hasTools = (writable && me.canEditLogic) || me.canViewAudit || writable || me.canAdmin; function startResize(e: MouseEvent) { e.preventDefault(); @@ -187,6 +189,9 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) { {writable && ( )} + {me.canAdmin && ( + + )}
    )}
    @@ -303,6 +308,9 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) { {showConfig && ( setShowConfig(false)} /> )} + {showAdmin && ( + setShowAdmin(false)} /> + )}
    ); } diff --git a/web/src/WidgetTypePicker.tsx b/web/src/WidgetTypePicker.tsx index 86de0f5..685dc43 100644 --- a/web/src/WidgetTypePicker.tsx +++ b/web/src/WidgetTypePicker.tsx @@ -18,6 +18,7 @@ const WIDGET_TYPES = [ { type: 'toggle', label: 'Toggle Switch', desc: 'On/off switch control' }, { type: 'button', label: 'Button', desc: 'Send command on click' }, { type: 'plot', label: 'Plot', desc: 'Time-series / charts' }, + { type: 'table', label: 'Table', desc: 'Multi-signal value table' }, { type: 'textlabel', label: 'Label', desc: 'Static text label' }, { type: 'image', label: 'Image', desc: 'Image from URL' }, { type: 'link', label: 'Link', desc: 'Navigate to interface' }, diff --git a/web/src/lib/auth.ts b/web/src/lib/auth.ts index 2026ce7..5b246c3 100644 --- a/web/src/lib/auth.ts +++ b/web/src/lib/auth.ts @@ -4,7 +4,7 @@ import type { Me, AccessLevel } from './types'; // Default identity used before /api/v1/me resolves (or if it fails): assume a // trusted LAN user with full access, matching the backend default. -export const DEFAULT_ME: Me = { user: '', level: 'write', groups: [], canEditLogic: true, 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 // current user throughout the app. @@ -38,6 +38,7 @@ export async function fetchMe(): Promise { groups: Array.isArray(data.groups) ? data.groups : [], canEditLogic: data.canEditLogic !== false, canViewAudit: data.canViewAudit !== false, + canAdmin: data.canAdmin !== false, }; } catch { return DEFAULT_ME; diff --git a/web/src/lib/containers.ts b/web/src/lib/containers.ts new file mode 100644 index 0000000..01bf387 --- /dev/null +++ b/web/src/lib/containers.ts @@ -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']; +} diff --git a/web/src/lib/flowDebug.ts b/web/src/lib/flowDebug.ts new file mode 100644 index 0000000..50b9ed3 --- /dev/null +++ b/web/src/lib/flowDebug.ts @@ -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; + +/** 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, + intervalMs = 500, +): DebugSnapshot { + const [snap, setSnap] = useState(() => 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; +} diff --git a/web/src/lib/flowZoom.ts b/web/src/lib/flowZoom.ts new file mode 100644 index 0000000..b071ad5 --- /dev/null +++ b/web/src/lib/flowZoom.ts @@ -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 }; +} diff --git a/web/src/lib/logic.ts b/web/src/lib/logic.ts index e519f87..ee4bd86 100644 --- a/web/src/lib/logic.ts +++ b/web/src/lib/logic.ts @@ -204,7 +204,7 @@ interface WireOut { to: string; port: string } // expression resolver that exposes the system signals {sys:time}/{sys:dt}. interface RunCtx { firedTrigger: string; steps: { n: number }; dt: number; resolve: Resolver } -class LogicEngine { +export class LogicEngine { private graph: LogicGraph = { nodes: [], wires: [] }; private byId = new Map(); // Outgoing wires grouped by source node id. @@ -227,6 +227,37 @@ class LogicEngine { private lastFire = new Map(); 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(); + + /** 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 { + const now = Date.now(); + const out = new Map(); + for (const [id, s] of this.debugStates) out.set(id, { value: s.value, active: now - s.ts < 800 }); + return out; + } + + // Suppress signal writes in dry-run; otherwise forward to the live client. + private dryWrite(ref: SignalRef, val: any): void { + if (!this.dryRun) wsClient.write(ref, val); + } + // Resolve an expression reference. Two built-in system signals are served // synthetically (never subscribed): {sys:time} = current epoch seconds, // {sys:dt} = seconds since the firing trigger last fired. Everything else @@ -320,9 +351,11 @@ class LogicEngine { this.watchers = new Map(); this.arrays = new Map(); this.lastFire = new Map(); + this.debugStates = new Map(); // Restore every widget to its default state so reopening a panel (whose - // widget ids are stable) does not inherit disabled/hidden/paused flags. - resetWidgetCmds(); + // widget ids are stable) does not inherit disabled/hidden/paused flags. The + // dry-run editor engine must not touch the real widget command stores. + if (!this.dryRun) resetWidgetCmds(); } /** Fire every button-trigger node whose `name` param matches. */ @@ -435,12 +468,22 @@ class LogicEngine { // ── Execution ─────────────────────────────────────────────────────────────── + /** Manually fire a trigger node (debug mode: double-click to force a flow + * run). No-op if the id isn't a trigger node. */ + fireTrigger(nodeId: string): boolean { + const node = this.byId.get(nodeId); + if (!node || !node.kind.startsWith('trigger.')) return false; + this.activate(nodeId); + return true; + } + // A trigger activated: walk its outgoing 'out' wires. private activate(triggerId: string): void { const now = Date.now(); const last = this.lastFire.get(triggerId); const dt = last === undefined ? 0 : (now - last) / 1000; this.lastFire.set(triggerId, now); + this.markActive(triggerId); const ctx: RunCtx = { firedTrigger: triggerId, steps: { n: 0 }, @@ -462,15 +505,20 @@ class LogicEngine { if (ctx.steps.n++ > MAX_STEPS) return; const node = this.byId.get(nodeId); if (!node) return; + this.markActive(node.id); switch (node.kind) { - case 'gate.and': - if (this.gateSatisfied(node.id, ctx.firedTrigger)) await this.follow(node.id, 'out', ctx); + case 'gate.and': { + const ok = this.gateSatisfied(node.id, ctx.firedTrigger); + this.markActive(node.id, ok); + if (ok) await this.follow(node.id, 'out', ctx); return; + } case 'flow.if': { - const branch = evalBool(node.params.cond ?? '', ctx.resolve) ? 'then' : 'else'; - await this.follow(node.id, branch, ctx); + const pass = evalBool(node.params.cond ?? '', ctx.resolve); + this.markActive(node.id, pass); + await this.follow(node.id, pass ? 'then' : 'else', ctx); return; } @@ -493,14 +541,15 @@ class LogicEngine { case 'action.write': { const ref = parseRef(node.params.target ?? ''); const val = evalExpr(node.params.expr ?? '', ctx.resolve); - if (ref && !isNaN(val)) wsClient.write(ref, val); + this.markActive(node.id, val); + if (ref && !isNaN(val)) this.dryWrite(ref, val); await this.follow(node.id, 'out', ctx); return; } case 'action.config.apply': { const id = this.resolveInstanceId(node); - if (id) { + if (id && !this.dryRun) { try { await fetch(`/api/v1/config/instances/${encodeURIComponent(id)}/apply`, { method: 'POST' }); } catch {} @@ -513,9 +562,9 @@ class LogicEngine { const id = this.resolveInstanceId(node); const key = (node.params.key ?? '').trim(); const ref = parseRef(node.params.target ?? ''); - if (id && key && ref) { + if (id && key && ref && !this.dryRun) { 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); return; @@ -525,7 +574,7 @@ class LogicEngine { const id = this.resolveInstanceId(node); const key = (node.params.key ?? '').trim(); 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 this.follow(node.id, 'out', ctx); @@ -537,9 +586,9 @@ class LogicEngine { const name = (node.params.name ?? '').trim() || 'auto'; const fromId = (node.params.from ?? '').trim(); const target = parseRef(node.params.target ?? ''); - if (setId) { + if (setId && !this.dryRun) { 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); return; @@ -549,9 +598,9 @@ class LogicEngine { const setId = (node.params.set ?? '').trim(); const name = (node.params.name ?? '').trim(); const target = parseRef(node.params.target ?? ''); - if (setId) { + if (setId && !this.dryRun) { 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); return; @@ -565,6 +614,7 @@ class LogicEngine { case 'action.accumulate': { const name = (node.params.array ?? '').trim(); const val = evalExpr(node.params.expr ?? '', ctx.resolve); + this.markActive(node.id, val); if (name && !isNaN(val)) { const arr = this.arrays.get(name) ?? this.arrays.set(name, []).get(name)!; arr.push({ t: Date.now(), v: val }); @@ -574,7 +624,7 @@ class LogicEngine { } case 'action.export': { - this.exportArrays(this.exportColumns(node), node.params.align ?? 'common', node.params.filename ?? ''); + if (!this.dryRun) this.exportArrays(this.exportColumns(node), node.params.align ?? 'common', node.params.filename ?? ''); await this.follow(node.id, 'out', ctx); return; } @@ -589,6 +639,7 @@ class LogicEngine { case 'action.log': { const val = evalExpr(node.params.expr ?? '', ctx.resolve); const label = (node.params.label ?? '').trim(); + this.markActive(node.id, val); console.log(`[logic]${label ? ' ' + label + ':' : ''}`, val); await this.follow(node.id, 'out', ctx); return; @@ -596,7 +647,7 @@ class LogicEngine { case 'action.widget': { const wid = (node.params.widget ?? '').trim(); - if (wid) { + if (wid && !this.dryRun) { switch (node.params.op ?? '') { case 'enable': setWidgetDisabled(wid, false); break; case 'disable': setWidgetDisabled(wid, true); break; @@ -613,6 +664,7 @@ class LogicEngine { case 'action.dialog.info': case 'action.dialog.error': { + if (this.dryRun) { await this.follow(node.id, 'out', ctx); return; } const specs = this.dialogFieldSpecs(node); await this.showDialog({ kind: node.kind === 'action.dialog.error' ? 'error' : 'info', @@ -625,6 +677,7 @@ class LogicEngine { } case 'action.dialog.setpoint': { + if (this.dryRun) { await this.follow(node.id, 'out', ctx); return; } const specs = this.dialogFieldSpecs(node); const result = await this.showDialog({ kind: 'setpoint', diff --git a/web/src/lib/nodeGroups.ts b/web/src/lib/nodeGroups.ts new file mode 100644 index 0000000..82dbd1e --- /dev/null +++ b/web/src/lib/nodeGroups.ts @@ -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, +): 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 }; +} diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index ff07d36..f44680d 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -1,3 +1,6 @@ +import type { NodeGroup } from './nodeGroups'; +export type { NodeGroup }; + // Core signal reference: identifies a signal by datasource + name export interface SignalRef { ds: string; @@ -181,6 +184,7 @@ export interface LogicWire { export interface LogicGraph { nodes: LogicNode[]; wires: LogicWire[]; + groups?: NodeGroup[]; } // 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 // allowlist is configured and excludes them, or auditing is disabled. 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. @@ -262,6 +270,8 @@ export interface InterfaceListItem { id: string; name: string; version: number; + // 'plot' for split-layout plot panels; absent/'panel' for free-form panels. + kind?: 'panel' | 'plot'; owner?: string; folder?: string; order?: number; @@ -298,6 +308,7 @@ export interface SynthGraphNode { export interface SynthGraph { nodes: SynthGraphNode[]; output: string; + groups?: NodeGroup[]; } export interface SignalDef { diff --git a/web/src/lib/ws.ts b/web/src/lib/ws.ts index 72a87f8..df6c4ec 100644 --- a/web/src/lib/ws.ts +++ b/web/src/lib/ws.ts @@ -12,6 +12,25 @@ interface SubscriberEntry { 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 ───────────────────────────────────────────────────────────────── class WsClient { @@ -35,6 +54,8 @@ class WsClient { private subscribers = new Map>(); // pending history request callbacks keyed by "ds\0name" private histCallbacks = new Map void>>(); + // single control-logic debug listener (the editor currently watching) + private debugListener: ((ev: DebugNodeEvent) => void) | null = null; connect(url: string): void { this.url = url; @@ -174,6 +195,17 @@ class WsClient { }); 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': // Resolve any pending history callbacks with empty data on error if (key && this.histCallbacks.has(key)) { @@ -289,6 +321,39 @@ class WsClient { sendDialogResponse(id: string, value: number | null): void { 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 diff --git a/web/src/lib/xml.ts b/web/src/lib/xml.ts index 823dfdb..4f77fa2 100644 --- a/web/src/lib/xml.ts +++ b/web/src/lib/xml.ts @@ -27,6 +27,14 @@ function serializeLogic(graph: LogicGraph, lines: string[]): void { ? ` port="${xmlEsc(wire.fromPort)}"` : ''; lines.push(` `); } + for (const group of graph.groups ?? []) { + const collAttr = group.collapsed ? ` collapsed="1"` : ''; + lines.push(` `); + for (const member of group.members) { + lines.push(` `); + } + lines.push(` `); + } lines.push(` `); } @@ -119,9 +127,25 @@ function parseLayout(el: Element): PlotLayout { function parseLogic(logicEl: Element): LogicGraph { const nodes: LogicGraph['nodes'] = []; const wires: LogicGraph['wires'] = []; + const groups: NonNullable = []; 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') ?? ''; if (!id) continue; const params: Record = {}; @@ -146,7 +170,7 @@ function parseLogic(logicEl: Element): LogicGraph { } } - return { nodes, wires }; + return { nodes, wires, ...(groups.length ? { groups } : {}) }; } export function parseInterface(xml: string): Interface { diff --git a/web/src/styles.css b/web/src/styles.css index 7a01ee6..e6d2b42 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -408,7 +408,8 @@ body { .canvas-view-bare .multiled-widget, .canvas-view-bare .setvalue, .canvas-view-bare .toggle-widget, -.canvas-view-bare .plot-widget { +.canvas-view-bare .plot-widget, +.canvas-view-bare .table-widget { background: transparent; border-color: transparent; box-shadow: none; @@ -457,6 +458,178 @@ body { 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 ──────────────────────────────────────────────────────── */ .ctx-menu { @@ -1408,10 +1581,22 @@ body { .flow-palette-toolbar { display: flex; - gap: 0.35rem; + gap: 0.25rem; 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 { font-size: 0.7rem; @@ -1459,6 +1644,13 @@ body { 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 { position: absolute; top: 0; left: 0; @@ -1499,9 +1691,104 @@ body { .flow-node-flow { border-top: 3px solid #10b981; } .flow-node-action { border-top: 3px solid #3b82f6; } .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.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 { display: flex; align-items: center; @@ -2574,6 +2861,66 @@ body { 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 */ /* ── Synthetic pipeline ─────────────────────────────────────────────────────── */ @@ -2755,6 +3102,14 @@ body { cursor: pointer; } +.iface-item-icon { + display: inline-flex; + align-items: center; + margin-right: 0.4rem; + vertical-align: -2px; + opacity: 0.7; +} + .iface-item-actions { display: none; gap: 2px; @@ -4084,15 +4439,21 @@ kbd { display: flex; align-items: center; justify-content: space-between; + gap: 0.4rem; padding: 0.5rem 0.75rem; border-bottom: 1px solid #2d3748; font-weight: 600; color: #94a3b8; 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-filter { padding: 0.4rem 0.5rem; border-bottom: 1px solid #2d3748; } +.cl-list-filter .prop-select { width: 100%; } + .cl-list-item { display: flex; align-items: center; @@ -4143,6 +4504,181 @@ kbd { 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 { display: flex; align-items: center; diff --git a/web/src/widgets/Container.tsx b/web/src/widgets/Container.tsx new file mode 100644 index 0000000..6cb93bb --- /dev/null +++ b/web/src/widgets/Container.tsx @@ -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 ( +
    +
    + {tabs.map((t, i) => ( + + ))} +
    +
    + ); + } + + 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 ( +
    + {hasTitleBar && ( +
    + {canCollapse && ( + + )} + {title} +
    + )} +
    + ); +} diff --git a/web/src/widgets/TableWidget.tsx b/web/src/widgets/TableWidget.tsx new file mode 100644 index 0000000..24bfd7a --- /dev/null +++ b/web/src/widgets/TableWidget.tsx @@ -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 = { + 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(DEFAULT_VALUE); + const [meta, setMeta] = useState(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 ( + + {cols.map(c => { + switch (c) { + case 'name': + return {label}; + case 'value': + return {valStr()}; + case 'unit': + return {unit}; + case 'quality': + return ( + + + + ); + case 'time': + return {time}; + } + })} + + ); +} + +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 ( +
    + {title &&
    {title}
    } +
    + + {showHeader && ( + + {cols.map(c => )} + + )} + + {widget.signals.length === 0 ? ( + + ) : ( + widget.signals.map((s, i) => ( + + )) + )} + +
    {COL_LABELS[c]}
    No signals β€” drop signals here
    +
    +
    + ); +} diff --git a/workspace/data/audit.db b/workspace/data/audit.db index 5df6f5f422eecf699ff29042dc155d27f7c0516c..bc3eab74015388c32f6204f1ed191feb7bf3bf59 100644 GIT binary patch literal 32768 zcmeHQdvp_38c&)wZIZcnN=s>>lul_&TT0qY(l$+@FCZeeDX(IA=ypSzme7!tB;~2H zt|EKTqbRQHF2{BC?4rA_ud}*%Ko0JrsGzv(;u8<-%5i-FzCbyM>+YR(T1xb< zJvXJ{`@XsJ`|kbSM`x1nezRynyD#DudV`_W?ucO1WNK1UG%bRl(P(t&Hv|1fU#Uot zexomKR2boZou+ErSbz$oafdW8AG{1UfMO)F7h`}iz!+c*Fa{U{i~+^~V}LQh82BCx zbfxKY&4t=EzCe$6y{~6|w|lV17wL9;BEDdN$o^oTe%3-~Yo}9~+cDd@L{JtJ=5+|M zqUGe&iq2GhZe^i%bs}&u6f2xIJZ>Tdbm;WCrG?r)1!^Q5gG?O;G7^r*i-fxa z;fnbwsFs!5r4jcEzc;+bkM8Qa!`?N6-hfA`>SW-~)|u^2K~c6m;9l*WhR&?GX@xdD zx3EyVH5+5b>J<}G;&5VSq1TD?9Opt|{=&Jg)`d%jHs?~Ib#dpsxgF?q zm$RdDn$dupKu(Aj@0iyqbS!RfM@LaVAP(`X)42qh6^FVb8wR|B$|2P8s%Q0t2^xBj zJ4=}DyrOk+d#6xVhTMjIeF3*W5g@+QTDO1DOW;Pp>hVV0zDr{H21Zk_;9$fPT#bDW z<1yGT)!!QmsnSN(-&m1`W}KG4D9yVJ{vtdFpMoo40eBCr1LfR@+})g$OEo=h>NS~+ z`;7y}BEz!=w;?OzK!z**i}d@`UHb3zPw7{sJ(K23n~-`abz`bU_o8lQqlP(CG7XYyg0+!Z9Fk@ABU)`NZ*y)$UNl7YGi9y&+F95D5kS zXa(?jss;yo+!3$GZ?5;)t)BW7JvMud#olA@6*|1@1i7ey%ZgTel?DBX!G2Gb(#h3A z(*w(YSSKzsc49^?5lKt9(mUV<%84%!@rHWc9g<}P zfLnIDX!mYs(r}eZxISY&xJPguMoiUPBKS`i!)={j)%RBYTlUa#)62AkX;Od}~ z17WR=!gEcIQ7YFj^J&=ks5%~5QQ|2T*4EH)MHp^qVz=%6c>H~EC@p!7 zs3UG9U2d;moO_E(Pxu1ih#Or6RD}cXf$+*;B)*#FsM&0_SnJG|Ic{t}KiZP=@=vV&)&8UVo1& z0X<&7m-J4I6dGxg5~-$`?snhkuDx_wVgi^!#Pl& zRN^L$Sn;WVXY{bn*^n~TPoWvEq|8U`dYig;hiqFR`%3 zQ|`)Dz>VE@-LZWGmjg%FZt1uf+#F0v_L*;h^XY8 z=BvzbJoM024*|d2s~#8n`Ub5tX2dR@B72U$@#(nNo}-+^Cf2AfNV1i1uOG?3vpQM0 ztFmJ3R!HpbIlA)s*@wQR;HbVIT2+SFrRZEbCWeK3YR^&kz1n*|SI4@%o^oX>TD|45 z`L}=b0tJ0}B=SnQdmh-ny&_q-W*CFZ!6a_EyWoj-de*Adj$+OQVgO`}Y`LvGN8G7e zAJPf(GHQ2-jylm=C)SA++yV~6N#oi^>T|Pk-#HTnhx%`5ToXhzt}VR|N3EmY+x1=(zh)lhEY;IOd7rZn+L1kpGr9yYV=UIP>r6OjsZ$@ zOL>4hB68n}+s0Ta0CJC9wJwA{ZtxEHJmJOj=Fe@I>1=g%&+DN3^eR1q0{9M%^l7Bx z>rMM_O}I6Sb*8tb4qfQh*VekldeJMoscJQ)VJvA$D#FzYUp%#0QzKywHHdTqt`qtT z6W5o{RD3>aRt{-a`22nMKGZ6muihS)bJ3#KIZg#ki%y0qlGzC#n^~hV3=h-Z+Bxe= z`jV(6MTzk4p>v+BOCZp+Hfv>s!e|{1zcXw0KZi$f&719%0nnXwI=XL`?*G5gpnvSe z7+?%A1{ed30mcAhfHA-rU<@z@7z2y}#=!pz13;@Q)W%{pT8yY&E!)-|O40r_m8r%tPMiB+8z_nmLm<8%U1(*nUkivb%eZ(E( zj-U##7h`}iz!+c*Fa{U{i~+^~V}LQh7`PMzoKCOJ+v|UJ=aC&%EwZwA%~OV%&9d@j z-Uk`FCRus>(|^E8jYL`T==GV`9-kpA%WkUKHDNkYezZCN!E^csqFj5rY0)EP4x(I> zvUA~2-mNFf^6y@q`Sv$Kwtl;PyURH4I43rfd2LogUN3n3Sg5yt2 zL?N*xMxu~N5Cc(2#6|{DNc2Uztl;l0UD(9GG5#(7FuxytF7Ob)lfQ$%iC@R} z^WFR+ehxpKujY%+n-~qTBJ_7HDJK$FI*+39_U>9tI&CmwRU_Rub z4x9(4z*evc1c3*1fi}<#Y?qqm>^Nh9F~AsL3@`>51B?O20AqkLz!(^TfgD}6R@x^h zN79q<_!d=bv#Pa8)!Jy%VQ@+y`@QSsnVNHdP~vifC1nC8+U8KanKG9fQPxW+@xjLIRZkILLGCX6f&a74LBc85iY)~^g;*DfK zbA7y#Z0EMe8%fE!cq1uUtE_vC(qK~>tV)A8Omi*6*sF)JSH)M5aAsCBPE#{hsu`!M z87tI`<a_SgNo5KvVQtDdofs`kWg{w8nn~#r%1mVlDpo*~_D)u-ThT;aW7JFmOIBdN%wcq4IGFhS=?z$S+#4u2?L?GW)YAxl@GjZelhUbU%ts@7bE zm4J>*7&FA4qt>LcYQ}5@F5xLlVI`K3Yg-Jq&tiW z2&AwfzA%B!P|niygtJLOef-(PRhshbRHZ?uG^FslQaQ09N!lRo|F1>~`q20L*Ynd^ z{y*jCg;d%JmjAEc#q$5rxwn*83365&^;C>3{~yc$N9EDl%kuxR{C`xQ&*&9nd@?$g z|4&K_$nyV@TOLI&R+j&-pXL7}3G!I}KbHTG<^N;(|KzCtQRV-`N$sWk|1pL^4T|hH z!~JjpI0?1^k$aol%vntTGTmh|qrCqM3@1?$_F@b$1{ed30mcAhfHA-rNR|PA3(DGD zJzmPI?E=!{ipcimgka_HhA?_K0{uOALXK@qTy%3oVm|O-zrR`O4eyUm#+x__Z%s;X zcmsAa-bT~5Mx{5r!#f#oqiCCF2J%KfW{PYfr}Cl8{CuCsOu43GCsKBE@=THycxyW4 z;T#G$vOPXoxUTvboKyNFV=viYuL?)L2@Rl!2@Uvz5%YjM;PuPT zL~>ZI_8L3=;HxgX0+?*pR|O_t(2f?kP6>=R{;L9yP~&Z703%&__N_;&0L41Dv6Zi%AHF)dzv-p1VX7aD# ox8-}rcad)oUo~GE?_~ZD{P+3K@$coY=luyZj)ix#PwW;Z0O%1mA^-pY diff --git a/workspace/data/audit.db-shm b/workspace/data/audit.db-shm new file mode 100644 index 0000000000000000000000000000000000000000..fe9ac2845eca6fe6da8a63cd096d9cf9e24ece10 GIT binary patch literal 32768 zcmeIuAr62r3