Compare commits

...

2 Commits

Author SHA1 Message Date
Martino Ferrari ac24011487 Implemented admin pane + user permission 2026-06-22 17:49:14 +02:00
Martino Ferrari 73fcbe7b28 Improved plot view 2026-06-22 00:30:15 +02:00
72 changed files with 6667 additions and 470 deletions
+72 -21
View File
@@ -40,33 +40,63 @@
- [x] when validation fails the error is propagated to the user (live `/config/rules/check` panel while editing; save returns the structured violation)
- [x] 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:
- [ ] color code the node link by type
- [ ] edges color depends on type (e.g. float) including arrays
- [ ] input / outputs are color coded (if can accept many, gray, otherwise specific color, can be dynamic)
- [ ] can not connect input / output that are not compatible
- [x] color code the node link by type
- [x] edges color depends on type (e.g. float) including arrays (scalar = slate, array = purple)
- [x] input / outputs are color coded (out port = node's type; in port = accepted type, gray when it accepts either)
- [x] can not connect input / output that are not compatible (definite scalar↔array mismatch blocked; unknown/any always allowed)
- [x] hover on a block in error should show the reason
- [x] add proper array functionality
- [x] add shortcut to add Signals (S) and nodes (N) with HUD
- [ ] Panels:
- [x] in view mode the widgets should have no border/bg but blend with background
- [ ] add widgets such as toggle switch, table and other industrial hmi widgets
- [ ] add container widgets: labelled/title pane, tab panes, collapsable panes etc
- [ ] plot pane:
- [ ] add toolbar
- [ ] add time window selector to toolbar
- [ ] plot x-axis synchronised (option to unlink in toolbar)
- [ ] cursors and measuraments
- [ ] pause/resume
- [ ] save screenshot
- [ ] clean ui:
- create small statusbar where connection widget and other status related info will be placed
- group advanced items (audit/control loops/config manager etc) in a tool menu or something similar to not fill the toolbar
- make the toolbar as clean as possible
- [ ] Logic editor:
- add full support to local array values: dynamic, dynamic but capped max, fixed size etc:
- [x] toggle switch (`web/src/widgets/Toggle.tsx`; read+write bool control, configurable on/off value+label, confirm dialog)
- [x] table widget (`web/src/widgets/TableWidget.tsx`; multi-signal value table, one row per bound signal, configurable columns name/value/unit/status/time, optional header + title, per-signal value format + row-label overrides; blends in view mode)
- [ ] other industrial hmi widgets
- [x] widget panel exposes an editable Widget ID field (default = generated uuid) for easier logic interaction (`PropertiesPane.tsx` `IdInput`; `EditMode.renameWidgetId` propagates the rename to `action.widget` logic refs + plot-layout leaves; rejects empty/duplicate ids)
- [x] add container widgets: labelled/title pane, tab panes, collapsable panes (`web/src/widgets/Container.tsx`; decorative grouping frame rendered behind widgets, accent/bg options; `pane` variant = title + view-mode collapse; `tabs` variant = tab bar where each contained widget is assigned a tab via its "Tab" field and only the active tab shows; geometric membership via `web/src/lib/containers.ts`)
- [x] moving container widget should move the widgets on it — starting a move (drag or arrow nudge) on a container snapshots the widgets geometrically inside it (centre-inside, transitive through nested containers) and moves them by the same delta; membership is captured at move start so widgets never re-parent mid-move (`withContainedWidgets` in `web/src/lib/containers.ts`, used by `EditCanvas` drag-start + `EditMode` arrow nudge)
- [x] plot pane:
- [x] add toolbar (hover toolbar in `PlotWidget`, `.plot-toolbar`)
- [x] add time window selector to toolbar (Auto/15s/30s/1m/5m/15m/1h rolling-window override, live windowed plot types)
- [x] plot x-axis synchronised (shared uPlot cursor crosshair across all linked timeseries plots + zoom/pan range sync in historical mode; per-plot 🔗 link/unlink toolbar toggle; `web/src/lib/plotSync.ts`)
- [x] cursors and measuraments (📏 measure mode: click cursor A then B → on-canvas A/B lines + readout panel with Δt/rate and per-signal value@A→value@B and Δ)
- [x] pause/resume (local toolbar pause, OR'd with panel-logic pause; buffers persist across plot-type/window changes)
- [x] save screenshot (PNG export — uPlot canvas / ECharts getDataURL)
- [x] clean ui:
- [x] create small statusbar where connection widget and other status related info will be placed (bottom `.statusbar`: connection chip + current panel name + history indicator)
- [x] group advanced items (audit/control loops/config manager etc) in a tool menu or something similar to not fill the toolbar (⋯ Tools ▾ dropdown)
- [x] make the toolbar as clean as possible (toolbar-right now: History · 📖 · zoom · Tools · Edit)
- [x] panel-selection list: the whole row is clickable to open a panel (not just the name text); `onClick` moved to the `<li>`, action buttons `stopPropagation` (`InterfaceList.tsx`)
- [x] panel-selection list: plot vs panel entries shown with distinguishing monochrome inline-SVG icons (line-chart = plot / control-panel = HMI; `KindIcon`, currentColor); backend `InterfaceMeta.Kind` surfaced from the XML `kind` attr, `InterfaceListItem.kind` in the frontend type (preserved through the list normalizer)
- [ ] implement proper grouping strategy, in all selector tree the options should be filtered by user (private config), group (combo to select group if user in multiple groups), global (public config)
- [ ] panel tree by user / group / global
- [ ] signal tree by user / group / global
- [ ] config tree by user / group / global
- [ ] control sequence by user / group / global
- [ ] Node editors:
- [x] In all editors, implement node grouping and collapsing feature (with optional group label) (Shift+click multi-select → G/⊞ to group; editable label; ▾/▸ collapse to a compact box that reroutes crossing wires; Delete ungroups; shared `web/src/lib/nodeGroups.ts`; persisted in panel-logic XML + control-logic/synthetic JSON via Go `NodeGroup` structs — cosmetic editor metadata, ignored at eval)
- [x] opened group should be on top of other nodes (group frame lifted to z-index 1 above loose nodes; member nodes get `.flow-node-grouped` at z-index 2 so they stay above their own frame and clickable; applied in all three editors)
- [x] node counter should be better padded (`.flow-group-count` gained vertical padding so the "N nodes" line isn't cramped against the collapsed-box header)
- [x] In all editors: undo / redo + copy / paste with shortcuts (Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y undo-redo; Ctrl+C / Ctrl+V copy-paste of the selection, multi-select aware, internal wires preserved, ids remapped, pasted +offset; ref-based 50-deep history + clipboard scoped per editor; toolbar ↩/↪ buttons). Panel `LogicEditor` already had this; added to `ControlLogicEditor` (undo/redo + copy/paste) and `SyntheticGraphEditor` (copy/paste — it already had undo/redo). Synthetic paste never copies the permanent output node
- [x] In all editors: implement zoom in / out, home zoom and fit zoom to help user navigate complex graph (shared `web/src/lib/flowZoom.ts` `useFlowZoom` hook; CSS `transform: scale()` on a `.flow-canvas-zoom` layer inside the scaled `.flow-canvas-inner`; toolbar row /%//⤢ in all three editors; `toCanvas` divides pointer offsets by zoom)
- [x] fix: the fit-zoom (⤢) button was clipped outside the fixed-width palette — toolbar buttons now shrink to fit (`min-width:0`, zero side padding in `.flow-palette-toolbar .toolbar-btn`)
- [x] fix: zoom value (e.g. 100%) is overflowing, reduce padding and increase the zoom value widget width (`flow-zoom-pct` class on the zoom-value button → `flex:1.9` so it gets ~2× the width of the single-glyph icon buttons; toolbar font dropped to 0.75rem, gap to 0.25rem, `tabular-nums` to stop width jitter; all three editors)
- [x] Live / debug mode in all three node editors — evaluate the graph online and badge each node's value at human speed (~0.5 s). Shared frontend `web/src/lib/flowDebug.ts` (badge/active classes, `useFlowDebug` poller) + CSS `.flow-node-active`/`.flow-node-badge`/`.flow-wire-active`; a green ◉ toggle in each `.flow-palette-toolbar`. Per-editor backends:
- [ ] Syntetic signal editor:
- [x] Implement live / debug mode where value are evaluated online and visualized at human speed (e.g. 0.5s) — server-side stateless single-shot trace: `POST /api/v1/synthetic/trace` snapshots live source signals (broker `ReadNow`) and runs `evalSampleTrace` with fresh state, returning every node's value; stateful ops (moving_average/rms/lowpass/derivative/integrate/lua) badged `~approx`
- [ ] Logic editor:
- [x] Implement live / debug mode where value are evaluated online and visualized at human speed (e.g. 0.5s) — editor spins up a second client-side `LogicEngine` in new `dryRun` mode (real writes/config/dialogs suppressed) loaded with the edited graph; per-node values polled into the shared badges; the view-mode singleton is untouched
- add full suppor to local array values: dynamic, dynamic but capped max, fixed size etc:
- array functions should work with new local array
- [ ] Control loop:
- [ ] 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
@@ -78,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
+30 -11
View File
@@ -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)
+6
View File
@@ -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).
---
+89 -22
View File
@@ -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 `<logic>` block; it is surfaced to the frontend through `/api/v1/me`
(`canEditLogic`) so the UI can hide logic-editing affordances.
Identity and global policy live in `internal/access` (`Policy`, `Role`, `Level`). The user
identity is read per request from `server.trusted_user_header` (with a `default_user`
fallback) and stored on the request context. Access is **role-based** through group
memberships: each `[[groups]]` block lists members by role along the cumulative ladder
`viewer < operator < logiceditor < auditor < admin`, and a user's **effective** global
capability is the highest role across all their memberships. Roles map to capabilities as:
operator+ → write (`Level` is derived, `LevelWrite` for operator+, else `LevelRead`);
logiceditor+ → `CanEditLogic`; auditor+ → `CanViewAudit`; admin → `CanAdmin`.
`accessMiddleware` gates mutating HTTP methods by the derived global level. Per-panel
ownership and ACL evaluation (with folder inheritance) live in `internal/panelacl`, backed
by the `acl.json` sidecar.
A built-in **public** group is always present: every user (and anonymous) is an implicit
viewer member, so an identified caller is at least read-only. Groups may **nest** via a
`parent` pointer (a forest rooted at top-level groups); a member of a parent group inherits
its role on every descendant group too, unless overridden lower. Cycles are rejected
(offending parent dropped to root), and the public group is protected from rename, reparent,
and delete. As a bootstrap convenience, a `Policy` with **no roles assigned anywhere** is
treated as unconfigured → fully open (everyone is admin), matching trusted-LAN/dev use;
assigning any role switches to strict mode where unlisted users are read-only viewers.
The capability checks (`CanEditLogic`, `CanViewAudit`, `CanAdmin`) are surfaced to the
frontend through `/api/v1/me` (`canEditLogic`, `canViewAudit`, `canAdmin`) so the UI can
hide affordances. The `Policy` is seeded from the TOML config at startup but is
**runtime-mutable** through the admin pane. `Policy.EnablePersistence(storageDir)` points
it at an `access.json` sidecar; once any admin mutation is made, that file is written (tmp +
atomic rename) and, on a later startup, supersedes the TOML access config (which then only
bootstraps an empty install). All policy state is guarded by an `RWMutex` (reads share,
mutations are exclusive and persist), keeping the shared `*Policy` pointer wiring intact.
The admin REST routes live under `/api/v1/admin/*` (all `requireAdmin`-gated):
`GET /admin/access` returns an `AccessSnapshot` (users with effective role + per-group
roles, groups with members and parents, the role ladder, and the configured flag);
`PUT /admin/users/{user}` replaces a user's full set of per-group roles (body
`{roles: {group: role}}`, creating missing groups); `POST|PUT|DELETE /admin/groups[/{name}]`
create (name + optional parent), rename + set parent and member roles, and delete groups;
and `GET /admin/stats` reports live server statistics (the `internal/metrics` counters via
`metrics.Snapshot`, the broker's observed-signal count and data-source list, Go runtime
stats, and the Linux `/proc/loadavg` load average). The frontend `AdminPane.tsx` (a modal
opened from the view-mode Tools dropdown when `canAdmin`) presents Users (per-group role
assignment with an effective-role badge), Groups (nesting + per-member roles), and
Server-stats tabs.
### 3.11 Configuration Manager
@@ -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.
---
+602 -136
View File
@@ -1,32 +1,114 @@
// Package access implements uopi's global user-access policy: every user is
// trusted (full write) by default, while a configured blacklist can downgrade
// specific users to read-only or no access. It also resolves the per-request
// user identity and the user→group memberships defined in config.
// Package access implements uopi's role-based access policy. Access is granted
// through group memberships: every user belongs implicitly to the built-in
// "public" group (granting the baseline viewer role), and may additionally be
// assigned a higher role in any number of named groups. Roles form a cumulative
// ladder — viewer < operator < logic-editor < auditor < admin — where each level
// includes all powers below it. A user's effective capability is the highest
// role they hold across all their groups.
//
// This is the foundation layer (Phase 1). Per-panel ownership/ACL evaluation is
// layered on top of this in a later phase.
// Groups can be nested: a member of a parent group holds that role on every
// descendant group too (unless the descendant assigns them a different role),
// so an admin of an organisation group administers its sub-teams.
//
// As a bootstrap convenience, a policy with no role assignments at all is treated
// as fully open (everyone is admin), matching an unconfigured/dev deployment.
// Assigning any role switches to strict mode where unlisted users are viewers.
//
// The policy is seeded from the TOML config at startup but is runtime-mutable
// through the admin pane: once EnablePersistence is called, every mutation is
// written to a JSON sidecar ({storageDir}/access.json) which, when present on a
// later startup, becomes the source of truth (the TOML config is then only a
// bootstrap seed). All access is guarded by an RWMutex and safe for concurrent
// use; the *Policy pointer is stable so existing shared-pointer wiring is
// preserved.
package access
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"sort"
"strings"
"sync"
)
// Level is a global access level. Higher levels include lower ones.
// PublicGroup is the built-in group every user implicitly belongs to. It cannot
// be renamed or deleted and is always a top-level (parentless) group.
const PublicGroup = "public"
// Role is a cumulative access level granted by a group membership. Higher roles
// include every power of the lower ones.
type Role int
const (
// RoleViewer permits reads only (no signal writes). It is the baseline every
// user receives via the public group.
RoleViewer Role = iota
// RoleOperator adds signal writes (full HMI interaction).
RoleOperator
// RoleLogic adds editing panel and server-side control logic.
RoleLogic
// RoleAuditor adds viewing the audit log.
RoleAuditor
// RoleAdmin adds managing users, groups and access, and viewing server stats.
RoleAdmin
)
// RoleNames lists the role tokens from lowest to highest, for the admin UI.
var RoleNames = []string{"viewer", "operator", "logiceditor", "auditor", "admin"}
// String renders the role using the tokens accepted by ParseRole.
func (r Role) String() string {
switch r {
case RoleOperator:
return "operator"
case RoleLogic:
return "logiceditor"
case RoleAuditor:
return "auditor"
case RoleAdmin:
return "admin"
default:
return "viewer"
}
}
// ParseRole maps a config/JSON token to a Role. Unknown values fall back to the
// least-privileged viewer.
func ParseRole(s string) Role {
switch strings.ToLower(strings.TrimSpace(s)) {
case "operator", "write", "operate", "rw":
return RoleOperator
case "logiceditor", "logic", "logic_editor", "editor":
return RoleLogic
case "auditor", "audit":
return RoleAuditor
case "admin", "administrator":
return RoleAdmin
default:
return RoleViewer
}
}
// Level is a coarse global access level retained for the rest of the codebase
// (WebSocket auth, middleware, per-panel ACL capping). It is derived from a
// user's effective role: viewer → read-only, operator and above → write.
type Level int
const (
// LevelNone denies all access.
// LevelNone denies all access. Never produced by the role model, but kept so
// existing switch statements remain exhaustive.
LevelNone Level = iota
// LevelRead permits reads only (no create/update/delete/share, no signal writes).
// LevelRead permits reads only.
LevelRead
// LevelWrite permits full access. This is the default for any user not blacklisted.
// LevelWrite permits full write access.
LevelWrite
)
// String renders the level using the same tokens accepted by ParseLevel and
// surfaced to the frontend via /api/v1/me.
// String renders the level using the tokens surfaced to the frontend via
// /api/v1/me.
func (l Level) String() string {
switch l {
case LevelNone:
@@ -38,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),
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
}
p.userGroups[m] = append(p.userGroups[m], g)
g := p.ensureGroupLocked(name)
g.parent = strings.TrimSpace(s.Parent)
for u, r := range s.Members {
if u = strings.TrimSpace(u); u != "" {
g.members[u] = r
}
}
sort.Strings(p.groupNames)
}
p.ensureGroupLocked(PublicGroup).parent = ""
p.normalizeParentsLocked()
return p
}
// GroupNames returns a copy of every configured user-group name, sorted.
func (p *Policy) GroupNames() []string {
out := make([]string, len(p.groupNames))
copy(out, p.groupNames)
return out
// ensureGroupLocked returns the named group, creating an empty one if needed.
// The caller must hold p.mu for writing (or be in construction).
func (p *Policy) ensureGroupLocked(name string) *group {
g, ok := p.groups[name]
if !ok {
g = &group{members: make(map[string]Role)}
p.groups[name] = g
}
return g
}
// normalizeParentsLocked drops parent references to missing groups or that would
// form a cycle, and forces the public group to be a root.
func (p *Policy) normalizeParentsLocked() {
for name, g := range p.groups {
if name == PublicGroup {
g.parent = ""
continue
}
if g.parent == "" {
continue
}
if _, ok := p.groups[g.parent]; !ok || p.hasCycleLocked(name) {
g.parent = ""
}
}
}
// hasCycleLocked reports whether following parent links from start loops back.
func (p *Policy) hasCycleLocked(start string) bool {
seen := make(map[string]bool)
for cur := start; cur != ""; {
if seen[cur] {
return true
}
seen[cur] = true
g, ok := p.groups[cur]
if !ok {
return false
}
cur = g.parent
}
return false
}
// ── effective role ─────────────────────────────────────────────────────────
// configuredLocked reports whether any explicit role is assigned anywhere. An
// unconfigured policy is treated as fully open (bootstrap-safe).
func (p *Policy) configuredLocked() bool {
for _, g := range p.groups {
if len(g.members) > 0 {
return true
}
}
return false
}
// effectiveRoleLocked returns a user's highest role across all groups. Unlisted
// users (and anonymous callers) get the viewer baseline once the policy is
// configured; an unconfigured policy grants everyone admin.
func (p *Policy) effectiveRoleLocked(user string) Role {
if !p.configuredLocked() {
return RoleAdmin
}
best := RoleViewer
if user = strings.TrimSpace(user); user == "" {
return best
}
for _, g := range p.groups {
if r, ok := g.members[user]; ok && r > best {
best = r
}
}
return best
}
// ── persistence ────────────────────────────────────────────────────────────
// persisted is the on-disk schema for access.json.
type persisted struct {
DefaultUser string `json:"defaultUser"`
Groups map[string]persistedGroup `json:"groups"`
}
type persistedGroup struct {
Parent string `json:"parent"`
Members map[string]string `json:"members"` // user → role token
}
// EnablePersistence points the policy at {storageDir}/access.json. If the file
// exists its contents replace the TOML-seeded state (the file is the source of
// truth once written); otherwise the current state is kept and the file is only
// created on the first mutation.
func (p *Policy) EnablePersistence(storageDir string) error {
p.mu.Lock()
defer p.mu.Unlock()
p.path = filepath.Join(storageDir, "access.json")
data, err := os.ReadFile(p.path)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return err
}
var ps persisted
if err := json.Unmarshal(data, &ps); err != nil {
return err
}
p.defaultUser = strings.TrimSpace(ps.DefaultUser)
p.groups = make(map[string]*group)
for name, pg := range ps.Groups {
if name = strings.TrimSpace(name); name == "" {
continue
}
g := p.ensureGroupLocked(name)
g.parent = strings.TrimSpace(pg.Parent)
for u, token := range pg.Members {
if u = strings.TrimSpace(u); u != "" {
g.members[u] = ParseRole(token)
}
}
}
p.ensureGroupLocked(PublicGroup).parent = ""
p.normalizeParentsLocked()
return nil
}
// saveLocked atomically persists the current state when persistence is enabled.
func (p *Policy) saveLocked() error {
if p.path == "" {
return nil
}
ps := persisted{DefaultUser: p.defaultUser, Groups: make(map[string]persistedGroup, len(p.groups))}
for name, g := range p.groups {
pg := persistedGroup{Parent: g.parent, Members: make(map[string]string, len(g.members))}
for u, r := range g.members {
pg.Members[u] = r.String()
}
ps.Groups[name] = pg
}
data, err := json.MarshalIndent(ps, "", " ")
if err != nil {
return err
}
tmp := p.path + ".tmp"
if err := os.WriteFile(tmp, data, 0o644); err != nil {
return err
}
return os.Rename(tmp, p.path)
}
// ── reads ──────────────────────────────────────────────────────────────────
// ResolveUser trims the proxy-provided header value and falls back to the
// configured default_user when it is empty (e.g. unproxied/dev deployments).
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{}
+257 -26
View File
@@ -1,35 +1,266 @@
package access
import "testing"
import (
"os"
"path/filepath"
"sync"
"testing"
)
func TestCanEditLogic(t *testing.T) {
groups := map[string][]string{"ops": {"carol"}}
// spec is a small helper to build a GroupSpec.
func spec(name, parent string, members map[string]Role) GroupSpec {
return GroupSpec{Name: name, Parent: parent, Members: members}
}
// No allowlist configured: everyone with write access may edit logic.
open := New("", nil, groups, nil, 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
}
+257 -6
View File
@@ -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")
+147 -1
View File
@@ -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
+34
View File
@@ -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})
}
+21 -32
View File
@@ -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"`
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
// <logic> block of interfaces) and server-side control logic. Entries are
// usernames or group names. When empty, no restriction applies (any user
// with write access may edit logic). Anonymous/trusted-LAN callers are
// always permitted.
LogicEditors []string `toml:"logic_editors"`
}
type DatasourceConfig struct {
@@ -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
}
+8
View File
@@ -24,12 +24,20 @@ type ConfigRule struct {
Name string `json:"name"`
SetID string `json:"setId"`
Description string `json:"description,omitempty"`
// Enabled gates whether the rule runs when instances of the bound set are
// saved/applied. A nil pointer (legacy rules created before the flag) is
// treated as enabled, so existing rules keep their behaviour.
Enabled *bool `json:"enabled,omitempty"`
Version int `json:"version"`
Tag string `json:"tag,omitempty"`
Owner string `json:"owner,omitempty"`
Source string `json:"source"`
}
// IsEnabled reports whether the rule participates in instance evaluation. A nil
// Enabled flag (legacy rules) is treated as enabled.
func (r ConfigRule) IsEnabled() bool { return r.Enabled == nil || *r.Enabled }
// RuleViolation is a single constraint failure from evaluating a rule.
type RuleViolation struct {
Rule string `json:"rule,omitempty"` // rule ID that produced it (aggregate runs)
+9 -1
View File
@@ -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
+20
View File
@@ -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 {
+174
View File
@@ -0,0 +1,174 @@
package controllogic
import (
"context"
"sync"
"time"
"github.com/uopi/uopi/internal/broker"
)
// DebugEvent reports a single node execution for the live debug view. Value is
// only meaningful when HasValue is true (e.g. an action.write's written value or
// a flow.if branch as 0/1); otherwise the event just marks the node as active.
type DebugEvent struct {
GraphID string `json:"graphId"`
NodeID string `json:"nodeId"`
Value float64 `json:"value"`
HasValue bool `json:"hasValue"`
TS int64 `json:"ts"` // unix millis
}
// DebugObserver receives node-execution events from running (and simulated)
// graphs. The server implements it; Observe must not block (the hub fans out
// drop-on-full so a slow editor never stalls the engine).
type DebugObserver interface {
Observe(DebugEvent)
}
// debugObsBox wraps a DebugObserver so atomic.Value always sees one type.
type debugObsBox struct{ o DebugObserver }
// SetDebugObserver installs the sink for node-execution events. Safe to call
// once at startup; read lock-free by running flows.
func (e *Engine) SetDebugObserver(o DebugObserver) {
e.debugObs.Store(debugObsBox{o: o})
}
// SetDebugWatch replaces the set of graph ids with at least one live debug
// subscriber. emitDebug short-circuits for graphs absent from this set, so the
// common (nobody watching) case costs a single atomic load. The hub owns the
// map and must not mutate it after publishing (it is read without a lock).
func (e *Engine) SetDebugWatch(ids map[string]bool) {
e.debugWatch.Store(ids)
}
// registerFire records a compiled graph's manual-fire channel under its route id
// (live graph id or simulate sandbox id).
func (e *Engine) registerFire(id string, ch chan string) {
e.fireMu.Lock()
e.fireChs[id] = ch
e.fireMu.Unlock()
}
// unregisterFire removes id's fire channel, but only if it still points at ch —
// so a newer generation that reused the same id (live reload) is not clobbered
// by the old generation's teardown.
func (e *Engine) unregisterFire(id string, ch chan string) {
e.fireMu.Lock()
if e.fireChs[id] == ch {
delete(e.fireChs, id)
}
e.fireMu.Unlock()
}
// FireTrigger asks the graph behind a debug route (graphID) to run triggerID's
// flow now, as if the trigger had fired. Non-blocking and best-effort: returns
// false if the route is gone or its fire buffer is full. The receiving graph
// validates that triggerID is actually one of its trigger nodes.
func (e *Engine) FireTrigger(graphID, triggerID string) bool {
e.fireMu.Lock()
ch := e.fireChs[graphID]
e.fireMu.Unlock()
if ch == nil || triggerID == "" {
return false
}
select {
case ch <- triggerID:
return true
default:
return false
}
}
// emitDebug reports a node execution to the observer when the graph is watched
// (or the graph is a simulate sandbox, which is always its own subscriber).
func (cg *compiledGraph) emitDebug(nodeID string, value float64, hasValue bool) {
if !cg.alwaysDebug {
w, _ := cg.engine.debugWatch.Load().(map[string]bool)
if !w[cg.id] {
return
}
}
box, _ := cg.engine.debugObs.Load().(debugObsBox)
if box.o == nil {
return
}
box.o.Observe(DebugEvent{
GraphID: cg.id,
NodeID: nodeID,
Value: value,
HasValue: hasValue,
TS: time.Now().UnixMilli(),
})
}
// StartSimulate runs g in a throwaway sandbox generation: real side effects are
// suppressed (data-source writes, config mutations, dialogs) but local vars,
// triggers, timers and the flow itself execute normally, emitting debug events
// the editor can visualise. It is independent of Reload's live generation. The
// returned stop func cancels the sandbox and waits for its goroutines to drain;
// it is safe to call more than once.
func (e *Engine) StartSimulate(g Graph) func() {
cg := compile(g)
cg.engine = e
cg.dryRun = true
cg.alwaysDebug = true
ctx, cancel := context.WithCancel(e.root)
wg := &sync.WaitGroup{}
cg.genCtx = ctx
cg.wg = wg
// Sandbox-local live cache so simulate reads don't disturb the live engine.
updates := make(chan broker.Update, 128)
var unsubs []func()
for _, r := range cg.refs {
unsub, err := e.broker.Subscribe(broker.SignalRef{DS: r.DS, Name: r.Name}, updates)
if err != nil {
e.log.Warn("control logic simulate: subscribe failed", "ds", r.DS, "signal", r.Name, "err", err)
continue
}
unsubs = append(unsubs, unsub)
}
e.registerFire(cg.id, cg.fireCh)
wg.Add(1)
go func() {
defer wg.Done()
<-ctx.Done()
for _, u := range unsubs {
u()
}
e.unregisterFire(cg.id, cg.fireCh)
}()
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case u := <-updates:
val := toNum(u.Value.Data)
key := refKey(u.Ref.DS, u.Ref.Name)
e.liveMu.Lock()
e.live[key] = val
e.liveMu.Unlock()
cg.onSignal(key, val)
case <-ctx.Done():
return
}
}
}()
cg.startTriggers()
var once sync.Once
return func() {
once.Do(func() {
cancel()
wg.Wait()
})
}
}
+196
View File
@@ -0,0 +1,196 @@
package controllogic
import (
"context"
"sync"
"testing"
"time"
)
// fakeObserver records every DebugEvent it receives, in order.
type fakeObserver struct {
mu sync.Mutex
events []DebugEvent
}
func (f *fakeObserver) Observe(ev DebugEvent) {
f.mu.Lock()
f.events = append(f.events, ev)
f.mu.Unlock()
}
func (f *fakeObserver) snapshot() []DebugEvent {
f.mu.Lock()
defer f.mu.Unlock()
return append([]DebugEvent(nil), f.events...)
}
func (f *fakeObserver) last(nodeID string) (DebugEvent, bool) {
f.mu.Lock()
defer f.mu.Unlock()
for i := len(f.events) - 1; i >= 0; i-- {
if f.events[i].NodeID == nodeID {
return f.events[i], true
}
}
return DebugEvent{}, false
}
// thresholdWriteGraph is a 2-node flow: a timer trigger → an action.write of 42
// to "tgt:OUT". Used by both the observe and simulate tests.
func thresholdWriteGraph(id string) Graph {
return Graph{
ID: id,
Name: "flow",
Nodes: []Node{
{ID: "t", Kind: "trigger.timer", Params: map[string]string{"interval": "100"}},
{ID: "w", Kind: "action.write", Params: map[string]string{"target": "tgt:OUT", "expr": "42"}},
},
Wires: []Wire{{From: "t", To: "w"}},
}
}
// TestDebugObserverCapturesNodeValues drives a flow directly and asserts the
// observer saw the trigger fire and the write node's value — and that the real
// data-source write still happened (live mode, not dry-run).
func TestDebugObserverCapturesNodeValues(t *testing.T) {
e, src, _ := setupConfigEngine(t)
obs := &fakeObserver{}
e.SetDebugObserver(obs)
e.SetDebugWatch(map[string]bool{"g1": true})
cg := compile(thresholdWriteGraph("g1"))
cg.engine = e
cg.genCtx = context.Background()
cg.wg = &sync.WaitGroup{}
cg.activate("t")
cg.wg.Wait()
events := obs.snapshot()
if len(events) == 0 {
t.Fatal("observer captured no events")
}
// Trigger then write must both be reported.
if _, ok := obs.last("t"); !ok {
t.Error("no event for trigger node 't'")
}
wEv, ok := obs.last("w")
if !ok {
t.Fatal("no event for write node 'w'")
}
if !wEv.HasValue || wEv.Value != 42 {
t.Errorf("write node event = %+v, want value 42 hasValue true", wEv)
}
if wEv.GraphID != "g1" {
t.Errorf("event GraphID = %q, want g1", wEv.GraphID)
}
// Live mode: the real write went through.
if got, ok := src.get("OUT"); !ok || toNum(got) != 42 {
t.Errorf("OUT = %v (ok=%v), want 42 written", got, ok)
}
}
// manualWriteGraph is a flow whose trigger never fires on its own (a threshold
// with no signal wired) → an action.write of 42 to "tgt:OUT". Used to prove
// FireTrigger forces a run that would otherwise never happen.
func manualWriteGraph(id string) Graph {
return Graph{
ID: id,
Name: "flow",
Nodes: []Node{
{ID: "t", Kind: "trigger.threshold", Params: map[string]string{"op": ">", "value": "0"}},
{ID: "w", Kind: "action.write", Params: map[string]string{"target": "tgt:OUT", "expr": "42"}},
},
Wires: []Wire{{From: "t", To: "w"}},
}
}
// TestFireTriggerForcesRun verifies FireTrigger drives a flow whose trigger
// never fires on its own, routed through the simulate sandbox.
func TestFireTriggerForcesRun(t *testing.T) {
e, _, _ := setupConfigEngine(t)
obs := &fakeObserver{}
e.SetDebugObserver(obs)
e.SetDebugWatch(map[string]bool{})
stop := e.StartSimulate(manualWriteGraph("sim"))
defer stop()
// Nothing should have run yet — the threshold trigger has no signal.
time.Sleep(50 * time.Millisecond)
if _, ok := obs.last("w"); ok {
t.Fatal("write node ran before any manual fire")
}
if !e.FireTrigger("sim", "t") {
t.Fatal("FireTrigger returned false for an active simulate route")
}
// Poll for the write node event (the flow runs on its own goroutine).
var wEv DebugEvent
for i := 0; i < 100; i++ {
if ev, ok := obs.last("w"); ok {
wEv = ev
break
}
time.Sleep(10 * time.Millisecond)
}
if !wEv.HasValue || wEv.Value != 42 {
t.Errorf("write node event = %+v, want value 42 hasValue true", wEv)
}
// An unknown route id must be a no-op (best-effort, returns false).
if e.FireTrigger("does-not-exist", "t") {
t.Error("FireTrigger returned true for an unknown route")
}
}
// TestDebugWatchGatesEmission verifies emitDebug is silent for graphs absent
// from the watch set, so unwatched live graphs cost nothing.
func TestDebugWatchGatesEmission(t *testing.T) {
e, _, _ := setupConfigEngine(t)
obs := &fakeObserver{}
e.SetDebugObserver(obs)
e.SetDebugWatch(map[string]bool{}) // nobody watching
cg := compile(thresholdWriteGraph("g1"))
cg.engine = e
cg.genCtx = context.Background()
cg.wg = &sync.WaitGroup{}
cg.activate("t")
cg.wg.Wait()
if got := obs.snapshot(); len(got) != 0 {
t.Errorf("observer received %d events for an unwatched graph, want 0", len(got))
}
}
// TestSimulateSuppressesWrites runs the graph through the dry-run sandbox and
// asserts node events are still emitted (alwaysDebug) but NO real write occurs.
func TestSimulateSuppressesWrites(t *testing.T) {
e, src, _ := setupConfigEngine(t)
obs := &fakeObserver{}
e.SetDebugObserver(obs)
// Deliberately leave the watch set empty: simulate must emit regardless.
e.SetDebugWatch(map[string]bool{})
stop := e.StartSimulate(thresholdWriteGraph("sim"))
time.Sleep(250 * time.Millisecond) // let the 100 ms timer fire at least once
stop()
if _, ok := src.get("OUT"); ok {
t.Errorf("simulate performed a real write to OUT, want none")
}
wEv, ok := obs.last("w")
if !ok {
t.Fatal("simulate produced no event for write node 'w'")
}
if !wEv.HasValue || wEv.Value != 42 {
t.Errorf("simulate write event = %+v, want value 42 hasValue true", wEv)
}
if wEv.GraphID != "sim" {
t.Errorf("simulate event GraphID = %q, want sim", wEv.GraphID)
}
}
+79 -1
View File
@@ -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
@@ -91,6 +105,7 @@ func NewEngine(root context.Context, brk *broker.Broker, store *Store, cfg *conf
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":
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.emitDebug(node.ID, val, true)
if !cg.dryRun {
cg.engine.writeConfigParam(cg, strings.TrimSpace(node.param("instance")), strings.TrimSpace(node.param("key")), val)
}
cg.follow(node.ID, "out", ctx)
case "action.config.create":
if !cg.dryRun {
cg.engine.createConfigInstance(cg, strings.TrimSpace(node.param("set")), strings.TrimSpace(node.param("name")), strings.TrimSpace(node.param("from")))
}
cg.follow(node.ID, "out", ctx)
case "action.config.snapshot":
if !cg.dryRun {
cg.engine.snapshotConfig(cg, strings.TrimSpace(node.param("set")), strings.TrimSpace(node.param("name")))
}
cg.follow(node.ID, "out", ctx)
case "action.delay":
@@ -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":
if !cg.dryRun {
cg.engine.emitDialog(node)
}
cg.follow(node.ID, "out", ctx)
default:
+10
View File
@@ -50,6 +50,15 @@ 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"`
@@ -59,6 +68,7 @@ type Graph struct {
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 {
@@ -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.
+16 -3
View File
@@ -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
@@ -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 {
+12
View File
@@ -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
+24
View File
@@ -38,6 +38,30 @@ func IncWrites() { writeOps.Add(1) }
// IncHistoryReqs increments the history request counter.
func IncHistoryReqs() { historyReqs.Add(1) }
// Stats is a point-in-time snapshot of the in-process counters, for rendering
// as JSON in the admin pane (the Prometheus Handler renders the same data as
// text).
type Stats struct {
UptimeSeconds float64 `json:"uptimeSeconds"`
WsConnections int64 `json:"wsConnections"`
MsgIn int64 `json:"msgIn"`
MsgOut int64 `json:"msgOut"`
WriteOps int64 `json:"writeOps"`
HistoryReqs int64 `json:"historyReqs"`
}
// Snapshot returns the current values of all counters and gauges.
func Snapshot() Stats {
return Stats{
UptimeSeconds: time.Since(startTime).Seconds(),
WsConnections: wsConns.Load(),
MsgIn: msgIn.Load(),
MsgOut: msgOut.Load(),
WriteOps: writeOps.Load(),
HistoryReqs: historyReqs.Load(),
}
}
// Handler returns an http.HandlerFunc that renders Prometheus-format metrics.
// activeSubs is called each request to read the current number of unique signal
// subscriptions from the broker; pass nil to omit the metric.
+187
View File
@@ -0,0 +1,187 @@
package server
import (
"encoding/json"
"log/slog"
"strconv"
"sync"
"sync/atomic"
"github.com/uopi/uopi/internal/controllogic"
)
// DebugHub fans control-logic node-execution events out to the editors watching
// them, and owns the per-client debug subscriptions. It implements
// controllogic.DebugObserver; the engine (and simulate sandboxes) call Observe
// on every node execution.
//
// Two subscription modes share one event shape:
// - "live" — observe the running, enabled graph by its id.
// - "simulate" — dry-run an unsaved graph in a throwaway sandbox. Each
// simulate sub gets a unique route id so its events never mix with the live
// graph's (which may share the same persisted id).
//
// Routing is by event GraphID: live events carry the real graph id (only
// emitted while that id is in the engine's debugWatch set, which the hub keeps
// in sync with its live subs), simulate events carry the sandbox's unique id.
type DebugHub struct {
engine *controllogic.Engine
log *slog.Logger
seq uint64 // unique simulate route ids
mu sync.Mutex
subs map[*wsClient]*debugSub // one debug sub per client
routes map[string]map[*wsClient]struct{} // route id → watching clients
liveCount map[string]int // live-subscribed graph id → count
}
type debugSub struct {
route string // graph id (live) or unique sandbox id (simulate)
live bool // true for "live", false for "simulate"
stop func() // simulate sandbox teardown (nil for live)
}
// NewDebugHub builds an empty hub bound to the engine it observes.
func NewDebugHub(engine *controllogic.Engine, log *slog.Logger) *DebugHub {
return &DebugHub{
engine: engine,
log: log,
subs: map[*wsClient]*debugSub{},
routes: map[string]map[*wsClient]struct{}{},
liveCount: map[string]int{},
}
}
// debugOut is the client-bound JSON form of a node-execution event.
type debugOut struct {
Type string `json:"type"` // always "debugNode"
GraphID string `json:"graphId"`
NodeID string `json:"nodeId"`
Value float64 `json:"value"`
HasValue bool `json:"hasValue"`
TS int64 `json:"ts"`
}
// Observe implements controllogic.DebugObserver: push the event to every client
// watching its route (drop-on-full so a slow editor never stalls the engine).
func (h *DebugHub) Observe(ev controllogic.DebugEvent) {
h.mu.Lock()
watchers := h.routes[ev.GraphID]
if len(watchers) == 0 {
h.mu.Unlock()
return
}
targets := make([]*wsClient, 0, len(watchers))
for c := range watchers {
targets = append(targets, c)
}
h.mu.Unlock()
b, err := json.Marshal(debugOut{
Type: "debugNode",
GraphID: ev.GraphID,
NodeID: ev.NodeID,
Value: ev.Value,
HasValue: ev.HasValue,
TS: ev.TS,
})
if err != nil {
return
}
for _, c := range targets {
select {
case c.outCh <- b:
default:
}
}
}
// subscribeLive starts (or replaces) a client's live observation of graphID.
func (h *DebugHub) subscribeLive(c *wsClient, graphID string) {
h.unsubscribe(c)
if graphID == "" {
return
}
sub := &debugSub{route: graphID, live: true}
h.mu.Lock()
h.addRoute(c, sub)
h.liveCount[graphID]++
watch := h.snapshotWatch()
h.mu.Unlock()
h.engine.SetDebugWatch(watch)
}
// subscribeSimulate starts (or replaces) a client's dry-run of an unsaved graph.
func (h *DebugHub) subscribeSimulate(c *wsClient, g controllogic.Graph) {
h.unsubscribe(c)
id := "sim-" + strconv.FormatUint(atomic.AddUint64(&h.seq, 1), 10)
g.ID = id // route sandbox events under a unique id (never collides with live)
stop := h.engine.StartSimulate(g)
sub := &debugSub{route: id, live: false, stop: stop}
h.mu.Lock()
h.addRoute(c, sub)
h.mu.Unlock()
}
// fire forces nodeID's trigger to run in the graph the client is currently
// debugging (live or simulate), routed by that session's id. No-op if the
// client has no active debug session.
func (h *DebugHub) fire(c *wsClient, nodeID string) {
h.mu.Lock()
sub := h.subs[c]
h.mu.Unlock()
if sub == nil {
return
}
h.engine.FireTrigger(sub.route, nodeID)
}
// addRoute records sub for c; caller holds h.mu.
func (h *DebugHub) addRoute(c *wsClient, sub *debugSub) {
h.subs[c] = sub
if h.routes[sub.route] == nil {
h.routes[sub.route] = map[*wsClient]struct{}{}
}
h.routes[sub.route][c] = struct{}{}
}
// unsubscribe tears down a client's debug sub (stopping its sandbox if any) and
// refreshes the engine's watch set. Safe when the client has no sub.
func (h *DebugHub) unsubscribe(c *wsClient) {
h.mu.Lock()
sub := h.subs[c]
if sub == nil {
h.mu.Unlock()
return
}
delete(h.subs, c)
if m := h.routes[sub.route]; m != nil {
delete(m, c)
if len(m) == 0 {
delete(h.routes, sub.route)
}
}
if sub.live {
if h.liveCount[sub.route]--; h.liveCount[sub.route] <= 0 {
delete(h.liveCount, sub.route)
}
}
watch := h.snapshotWatch()
h.mu.Unlock()
if sub.stop != nil {
sub.stop()
}
h.engine.SetDebugWatch(watch)
}
// snapshotWatch builds a fresh immutable set of live-watched graph ids. Caller
// holds h.mu; the result is published to the engine which reads it lock-free.
func (h *DebugHub) snapshotWatch() map[string]bool {
w := make(map[string]bool, len(h.liveCount))
for k := range h.liveCount {
w[k] = true
}
return w
}
+2 -2
View File
@@ -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))
+47
View File
@@ -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)
+4 -1
View File
@@ -22,6 +22,8 @@ type InterfaceMeta struct {
ID string `json:"id"`
Name string `json:"name"`
Version int `json:"version"`
// Kind is "plot" for split-layout plot panels, empty for free-form panels.
Kind string `json:"kind,omitempty"`
}
// VersionMeta describes a single persisted revision of an interface.
@@ -134,6 +136,7 @@ type rootAttrs struct {
Name string `xml:"name,attr"`
Version int `xml:"version,attr"`
Tag string `xml:"tag,attr"`
Kind string `xml:"kind,attr"`
}
func (s *Store) readMeta(id string) (InterfaceMeta, error) {
@@ -145,7 +148,7 @@ func (s *Store) readMeta(id string) (InterfaceMeta, error) {
if err := xml.Unmarshal(data, &root); err != nil {
return InterfaceMeta{}, err
}
return InterfaceMeta{ID: id, Name: root.Name, Version: root.Version}, nil
return InterfaceMeta{ID: id, Name: root.Name, Version: root.Version, Kind: root.Kind}, nil
}
// Get returns the raw XML bytes for the interface with the given ID.
+26 -16
View File
@@ -11,23 +11,33 @@ trusted_user_header = "" # e.g. "X-Forwarded-User"
# Identity used when the trusted header is absent/empty. Empty = anonymous.
default_user = ""
# Every user is trusted with full write access by default. The blacklist
# downgrades specific users. Levels: "readonly" (view only, no writes) or
# "noaccess" (denied entirely).
# [[server.blacklist]]
# user = "guest"
# level = "readonly"
# Named sets of users, referenced by per-panel sharing rules.
# Role-based access through group memberships. Each [[groups]] block lists
# members by role; a user's effective global capability is the highest role
# across all their memberships, along this ladder:
# viewer < operator < logiceditor < auditor < admin
# Capabilities: operator+ may write signals; logiceditor+ may edit panel and
# control logic; auditor+ may view the audit log; admin may open the admin pane.
#
# The built-in "public" group is implicit (every user is a viewer of it). A
# config with NO roles assigned anywhere is fully open (everyone is admin), for
# trusted-LAN/dev use; assigning any role switches to strict mode where unlisted
# users are read-only viewers. Once changed at runtime via the admin pane,
# {storage_dir}/access.json supersedes the groups below.
#
# Groups may nest via "parent": a member of a parent group inherits its role on
# every descendant group too (unless overridden lower).
# [[groups]]
# name = "operators"
# members = ["alice", "bob"]
# Optional: restrict who may add or edit panel logic (the <logic> block of
# interfaces) AND server-side control logic. Entries are usernames or group
# names. Empty/unset = no restriction (any writer may edit logic). Also settable
# via UOPI_SERVER_LOGIC_EDITORS (space-separated).
# logic_editors = ["operators", "alice"]
# name = "public"
# admins = ["alice"] # alice is a global admin
# [[groups]]
# name = "operations"
# operators = ["bob"]
# auditors = ["carol"]
# [[groups]]
# name = "engineers"
# parent = "operations" # bob inherits operator here
# logiceditors = ["dave"]
# viewers = ["erin"]
[datasource.epics]
enabled = true
+487
View File
@@ -0,0 +1,487 @@
import { h } from 'preact';
import { useState, useEffect, useCallback, useMemo } from 'preact/hooks';
// ── Types (mirror internal/access AccessSnapshot + the /admin/stats payload) ──
interface MemberInfo { user: string; role: string; }
interface GroupInfo {
name: string;
parent: string;
members: MemberInfo[];
}
interface UserInfo {
name: string;
effectiveRole: string;
roles: Record<string, string>; // group → role token
}
interface AccessSnapshot {
defaultUser: string;
publicGroup: string;
roles: string[]; // role ladder, low → high
configured: boolean;
users: UserInfo[];
groups: GroupInfo[];
}
interface ServerStats {
uptimeSeconds: number;
wsConnections: number;
observedSignals: number;
msgIn: number;
msgOut: number;
writeOps: number;
historyReqs: number;
goroutines: number;
memAllocBytes: number;
memSysBytes: number;
heapInuseBytes: number;
loadAvg: number[] | null;
dataSources: string[];
}
interface Props { onClose: () => void; }
const msg = (err: unknown): string => (err instanceof Error ? err.message : String(err));
async function apiJSON<T = any>(url: string, opts?: RequestInit): Promise<T | null> {
const res = await fetch(url, opts);
if (!res.ok && res.status !== 204) {
let m = `HTTP ${res.status}`;
try { const e = await res.json(); if (e && e.error) m = e.error; } catch { /* ignore */ }
throw new Error(m);
}
if (res.status === 204) return null;
return res.json();
}
const jsonPut = (body: any): RequestInit => ({ method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
const jsonPost = (body: any): RequestInit => ({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
const ROLE_LABELS: Record<string, string> = {
viewer: 'Viewer',
operator: 'Operator',
logiceditor: 'Logic editor',
auditor: 'Auditor',
admin: 'Admin',
};
const roleLabel = (r: string): string => ROLE_LABELS[r] ?? r;
/** Order group names so children follow their parent, and compute nesting depth. */
function orderGroups(groups: GroupInfo[]): { group: GroupInfo; depth: number }[] {
const byParent = new Map<string, GroupInfo[]>();
for (const g of groups) {
const key = g.parent || '';
(byParent.get(key) ?? byParent.set(key, []).get(key)!).push(g);
}
for (const list of byParent.values()) list.sort((a, b) => a.name.localeCompare(b.name));
const out: { group: GroupInfo; depth: number }[] = [];
const names = new Set(groups.map(g => g.name));
const walk = (parent: string, depth: number) => {
for (const g of byParent.get(parent) ?? []) {
out.push({ group: g, depth });
walk(g.name, depth + 1);
}
};
walk('', 0);
// Orphans (parent missing) rendered at root.
for (const g of groups) {
if (g.parent && !names.has(g.parent) && !out.some(o => o.group.name === g.name)) {
out.push({ group: g, depth: 0 });
}
}
return out;
}
export default function AdminPane({ onClose }: Props) {
const [tab, setTab] = useState<'users' | 'groups' | 'stats'>('users');
const [snap, setSnap] = useState<AccessSnapshot | null>(null);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
setError(null);
try {
setSnap(await apiJSON<AccessSnapshot>('/api/v1/admin/access'));
} catch (err) { setError(msg(err)); }
}, []);
useEffect(() => { load(); }, [load]);
return (
<div class="cl-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div class="cl-modal cl-modal-full">
<header class="cl-header">
<span class="cl-title">Admin</span>
<span class="hint cl-subtitle">Manage role-based access through group memberships, and view live server statistics.</span>
<div class="cl-header-actions">
<button class="panel-btn" onClick={onClose}>Close</button>
</div>
</header>
<div class="cfg-tabs">
<button class={`cfg-tab${tab === 'users' ? ' cfg-tab-active' : ''}`} onClick={() => setTab('users')}>Users</button>
<button class={`cfg-tab${tab === 'groups' ? ' cfg-tab-active' : ''}`} onClick={() => setTab('groups')}>Groups</button>
<button class={`cfg-tab${tab === 'stats' ? ' cfg-tab-active' : ''}`} onClick={() => setTab('stats')}>Server stats</button>
</div>
{error && <div class="cl-error">{error}</div>}
{snap && !snap.configured && tab !== 'stats' && (
<div class="hint admin-banner">
No roles assigned yet access is fully open (everyone is admin). Assigning any role
switches to strict mode where unlisted users are read-only viewers.
</div>
)}
{tab === 'users' && snap && <UsersManager snap={snap} onSnap={setSnap} onError={setError} />}
{tab === 'groups' && snap && <GroupsManager snap={snap} onSnap={setSnap} onError={setError} />}
{tab === 'stats' && <StatsView onError={setError} />}
</div>
</div>
);
}
interface ManagerProps {
snap: AccessSnapshot;
onSnap: (s: AccessSnapshot) => void;
onError: (e: string | null) => void;
}
// ── Users ─────────────────────────────────────────────────────────────────────
function UsersManager({ snap, onSnap, onError }: ManagerProps) {
const [selected, setSelected] = useState<string | null>(null);
// Draft maps group → role token for the selected user (only groups with a role).
const [draft, setDraft] = useState<Record<string, string> | null>(null);
const [newName, setNewName] = useState('');
const [busy, setBusy] = useState(false);
const ordered = useMemo(() => orderGroups(snap.groups), [snap.groups]);
function select(name: string) {
const u = snap.users.find(x => x.name === name);
setSelected(name);
setDraft({ ...(u?.roles ?? {}) });
}
function addUser() {
const n = newName.trim();
if (!n) return;
setNewName('');
select(n);
}
const setGroupRole = (group: string, role: string) => setDraft(d => {
const next = { ...(d ?? {}) };
if (role === '') delete next[group];
else next[group] = role;
return next;
});
async function save() {
if (!draft || !selected) return;
setBusy(true);
onError(null);
try {
const updated = await apiJSON<AccessSnapshot>(
`/api/v1/admin/users/${encodeURIComponent(selected)}`, jsonPut({ roles: draft }));
if (updated) onSnap(updated);
} catch (err) { onError(msg(err)); }
finally { setBusy(false); }
}
const effective = selected ? snap.users.find(u => u.name === selected)?.effectiveRole : undefined;
return (
<div class="cl-body">
<div class="cl-list">
<div class="cl-list-head">
<input class="audit-filter" type="text" placeholder="Add user…" value={newName}
onInput={(e) => setNewName((e.target as HTMLInputElement).value)}
onKeyDown={(e) => { if (e.key === 'Enter') addUser(); }} />
<button class="panel-btn" onClick={addUser}>Add</button>
</div>
{snap.users.length === 0 && <div class="hint cl-list-empty">No users with explicit roles. Everyone is a viewer of the public group.</div>}
{snap.users.map(u => (
<div key={u.name} class={`cl-list-item${selected === u.name ? ' cl-list-item-active' : ''}`} onClick={() => select(u.name)}>
<span class="cl-list-name" title={u.name}>{u.name}</span>
<span class={`admin-role-tag admin-role-${u.effectiveRole}`}>{roleLabel(u.effectiveRole)}</span>
</div>
))}
</div>
<div class="cl-editor-wrap">
{!draft && <div class="hint admin-empty">Select a user, or add one, to assign roles per group.</div>}
{draft && (
<div class="admin-editor">
<h3 class="admin-editor-title">
{selected}
{effective && <span class={`admin-role-tag admin-role-${effective}`}>{roleLabel(effective)}</span>}
</h3>
<p class="hint">Effective access is the highest role across all groups. Every user is at least a viewer via the public group.</p>
<label class="admin-field-label">Role per group</label>
<div class="admin-role-table">
{ordered.map(({ group, depth }) => (
<div key={group.name} class="admin-role-row">
<span class="admin-role-group" style={{ paddingLeft: `${depth * 1.1}rem` }}>
{depth > 0 && <span class="admin-tree-mark"> </span>}
{group.name}
{group.name === snap.publicGroup && <span class="hint"> (everyone)</span>}
</span>
<select class="prop-select admin-role-select"
value={draft[group.name] ?? ''}
onChange={(e) => setGroupRole(group.name, (e.target as HTMLSelectElement).value)}>
<option value=""> none </option>
{snap.roles.map(r => <option key={r} value={r}>{roleLabel(r)}</option>)}
</select>
</div>
))}
</div>
<div class="admin-editor-actions">
<button class="panel-btn" disabled={busy} onClick={save}>{busy ? 'Saving…' : 'Save'}</button>
</div>
</div>
)}
</div>
</div>
);
}
// ── Groups ──────────────────────────────────────────────────────────────────
function descendantsOf(groups: GroupInfo[], name: string): Set<string> {
const out = new Set<string>();
const walk = (parent: string) => {
for (const g of groups) {
if (g.parent === parent && !out.has(g.name)) {
out.add(g.name);
walk(g.name);
}
}
};
walk(name);
return out;
}
function GroupsManager({ snap, onSnap, onError }: ManagerProps) {
const [selected, setSelected] = useState<string | null>(null);
const [name, setName] = useState('');
const [parent, setParent] = useState('');
const [members, setMembers] = useState<MemberInfo[]>([]);
const [newMember, setNewMember] = useState('');
const [newGroup, setNewGroup] = useState('');
const [busy, setBusy] = useState(false);
const ordered = useMemo(() => orderGroups(snap.groups), [snap.groups]);
const isPublic = selected === snap.publicGroup;
function select(gname: string) {
const g = snap.groups.find(x => x.name === gname);
if (!g) return;
setSelected(gname);
setName(g.name);
setParent(g.parent);
setMembers(g.members.map(m => ({ ...m })));
setNewMember('');
}
// Parent options exclude the group itself and its descendants (cycle guard) and public.
const parentOptions = useMemo(() => {
if (!selected) return [];
const blocked = descendantsOf(snap.groups, selected);
blocked.add(selected);
return snap.groups.filter(g => !blocked.has(g.name)).map(g => g.name);
}, [snap.groups, selected]);
async function create() {
const n = newGroup.trim();
if (!n) return;
setBusy(true);
onError(null);
try {
const updated = await apiJSON<AccessSnapshot>('/api/v1/admin/groups', jsonPost({ name: n }));
if (updated) onSnap(updated);
setNewGroup('');
} catch (err) { onError(msg(err)); }
finally { setBusy(false); }
}
function addMember() {
const u = newMember.trim();
if (!u || members.some(m => m.user === u)) { setNewMember(''); return; }
setMembers(ms => [...ms, { user: u, role: 'operator' }]);
setNewMember('');
}
const setMemberRole = (user: string, role: string) =>
setMembers(ms => ms.map(m => m.user === user ? { ...m, role } : m));
const removeMember = (user: string) => setMembers(ms => ms.filter(m => m.user !== user));
async function save() {
if (!selected) return;
setBusy(true);
onError(null);
try {
const memberMap: Record<string, string> = {};
for (const m of members) memberMap[m.user] = m.role;
const body = { name: name.trim() || selected, parent: isPublic ? '' : parent, members: memberMap };
const updated = await apiJSON<AccessSnapshot>(
`/api/v1/admin/groups/${encodeURIComponent(selected)}`, jsonPut(body));
if (updated) { onSnap(updated); setSelected(body.name); }
} catch (err) { onError(msg(err)); }
finally { setBusy(false); }
}
async function remove() {
if (!selected || isPublic || !confirm(`Delete group "${selected}"? Members keep their other groups.`)) return;
setBusy(true);
onError(null);
try {
const updated = await apiJSON<AccessSnapshot>(
`/api/v1/admin/groups/${encodeURIComponent(selected)}`, { method: 'DELETE' });
if (updated) onSnap(updated);
setSelected(null);
} catch (err) { onError(msg(err)); }
finally { setBusy(false); }
}
return (
<div class="cl-body">
<div class="cl-list">
<div class="cl-list-head">
<input class="audit-filter" type="text" placeholder="New group…" value={newGroup}
onInput={(e) => setNewGroup((e.target as HTMLInputElement).value)}
onKeyDown={(e) => { if (e.key === 'Enter') create(); }} />
<button class="panel-btn" disabled={busy} onClick={create}>Create</button>
</div>
{ordered.map(({ group, depth }) => (
<div key={group.name} class={`cl-list-item${selected === group.name ? ' cl-list-item-active' : ''}`} onClick={() => select(group.name)}>
<span class="cl-list-name" title={group.name} style={{ paddingLeft: `${depth * 0.9}rem` }}>
{depth > 0 && <span class="admin-tree-mark"> </span>}
{group.name}
</span>
<span class="hint">{group.members.length}</span>
</div>
))}
</div>
<div class="cl-editor-wrap">
{!selected && <div class="hint admin-empty">Select a group, or create one, to manage its parent and member roles.</div>}
{selected && (
<div class="admin-editor">
<label class="admin-field-label">Name</label>
<input class="admin-input" type="text" value={name} disabled={isPublic}
onInput={(e) => setName((e.target as HTMLInputElement).value)} />
{isPublic && <p class="hint">The built-in public group cannot be renamed, reparented or deleted; every user is an implicit viewer member.</p>}
<label class="admin-field-label">Parent group (nesting)</label>
<select class="prop-select admin-input" value={parent} disabled={isPublic}
onChange={(e) => setParent((e.target as HTMLSelectElement).value)}>
<option value=""> none (top level) </option>
{parentOptions.map(p => <option key={p} value={p}>{p}</option>)}
</select>
{!isPublic && <p class="hint">Members of a parent group inherit their role on this group too, unless overridden here.</p>}
<label class="admin-field-label">Members</label>
<div class="admin-member-add">
<input class="admin-input" type="text" placeholder="Add member…" value={newMember}
onInput={(e) => setNewMember((e.target as HTMLInputElement).value)}
onKeyDown={(e) => { if (e.key === 'Enter') addMember(); }} />
<button class="panel-btn" onClick={addMember}>Add</button>
</div>
{members.length === 0 && <div class="hint">No explicit members.</div>}
<div class="admin-role-table">
{members.map(m => (
<div key={m.user} class="admin-role-row">
<span class="admin-role-group">{m.user}</span>
<select class="prop-select admin-role-select" value={m.role}
onChange={(e) => setMemberRole(m.user, (e.target as HTMLSelectElement).value)}>
{snap.roles.map(r => <option key={r} value={r}>{roleLabel(r)}</option>)}
</select>
<button class="admin-row-remove" title="Remove member" onClick={() => removeMember(m.user)}></button>
</div>
))}
</div>
<div class="admin-editor-actions">
<button class="panel-btn" disabled={busy} onClick={save}>{busy ? 'Saving…' : 'Save'}</button>
{!isPublic && <button class="panel-btn" disabled={busy} onClick={remove}>Delete</button>}
</div>
</div>
)}
</div>
</div>
);
}
// ── Server stats ─────────────────────────────────────────────────────────────
function fmtUptime(s: number): string {
s = Math.floor(s);
const d = Math.floor(s / 86400); s -= d * 86400;
const h = Math.floor(s / 3600); s -= h * 3600;
const m = Math.floor(s / 60); s -= m * 60;
const parts: string[] = [];
if (d) parts.push(`${d}d`);
if (h || d) parts.push(`${h}h`);
parts.push(`${m}m`, `${s}s`);
return parts.join(' ');
}
const fmtMB = (b: number): string => `${(b / (1024 * 1024)).toFixed(1)} MB`;
function StatsView({ onError }: { onError: (e: string | null) => void }) {
const [stats, setStats] = useState<ServerStats | null>(null);
useEffect(() => {
let live = true;
const load = async () => {
try {
const s = await apiJSON<ServerStats>('/api/v1/admin/stats');
if (live && s) setStats(s);
} catch (err) { if (live) onError(msg(err)); }
};
load();
const t = setInterval(load, 2000);
return () => { live = false; clearInterval(t); };
}, [onError]);
if (!stats) return <div class="cl-body"><div class="hint admin-empty">Loading</div></div>;
const rows: [string, string][] = [
['Uptime', fmtUptime(stats.uptimeSeconds)],
['WebSocket connections', String(stats.wsConnections)],
['Observed signals', String(stats.observedSignals)],
['Messages in', String(stats.msgIn)],
['Messages out', String(stats.msgOut)],
['Signal writes', String(stats.writeOps)],
['History requests', String(stats.historyReqs)],
['Goroutines', String(stats.goroutines)],
['Memory allocated', fmtMB(stats.memAllocBytes)],
['Memory from OS', fmtMB(stats.memSysBytes)],
['Heap in use', fmtMB(stats.heapInuseBytes)],
['Load average', stats.loadAvg ? stats.loadAvg.map(n => n.toFixed(2)).join(' / ') : '—'],
];
return (
<div class="cl-body">
<div class="admin-stats">
<table class="audit-table admin-stats-table">
<tbody>
{rows.map(([k, v]) => (
<tr key={k}><th class="admin-stat-key">{k}</th><td class="admin-stat-val">{v}</td></tr>
))}
</tbody>
</table>
<div class="admin-stats-ds">
<h4 class="admin-field-label">Data sources ({stats.dataSources.length})</h4>
<div class="admin-ds-list">
{stats.dataSources.map(d => <span key={d} class="admin-ds-tag">{d}</span>)}
</div>
</div>
</div>
</div>
);
}
+54 -1
View File
@@ -12,15 +12,19 @@ import BarV from './widgets/BarV';
import Led from './widgets/Led';
import MultiLed from './widgets/MultiLed';
import SetValue from './widgets/SetValue';
import Toggle from './widgets/Toggle';
import Button from './widgets/Button';
import PlotWidget from './widgets/PlotWidget';
import ImageWidget from './widgets/ImageWidget';
import LinkWidget from './widgets/LinkWidget';
import ConfigSelect from './widgets/ConfigSelect';
import Container from './widgets/Container';
import TableWidget from './widgets/TableWidget';
import ContextMenu from './ContextMenu';
import InfoPanel from './InfoPanel';
import SplitLayout from './SplitLayout';
import LogicDialogs from './LogicDialogs';
import { centreInside, widgetTab, tabPanes } from './lib/containers';
const COMPONENTS: Record<string, any> = {
textview: TextView,
@@ -31,13 +35,24 @@ const COMPONENTS: Record<string, any> = {
led: Led,
multiled: MultiLed,
setvalue: SetValue,
toggle: Toggle,
button: Button,
plot: PlotWidget,
image: ImageWidget,
link: LinkWidget,
configselect: ConfigSelect,
container: Container,
table: TableWidget,
};
// Container panes are decorative frames placed behind other widgets; render them
// first so they paint underneath.
function renderOrder(widgets: Widget[]): Widget[] {
const containers = widgets.filter(w => w.type === 'container');
const rest = widgets.filter(w => w.type !== 'container');
return [...containers, ...rest];
}
interface CtxState {
visible: boolean;
x: number;
@@ -82,6 +97,26 @@ export default function Canvas({ iface, onNavigate, timeRange }: Props) {
visible: false, x: 0, y: 0, signal: null,
});
const [infoSignal, setInfoSignal] = useState<SignalRef | null>(null);
// View-mode collapse state for collapsible container panes (ephemeral, by id).
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set());
// View-mode active-tab state for tabbed container panes (ephemeral, by id).
const [activeTabs, setActiveTabs] = useState<Map<string, number>>(() => new Map());
function toggleCollapse(id: string) {
setCollapsed(prev => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
}
function selectTab(id: string, i: number) {
setActiveTabs(prev => {
const next = new Map(prev);
next.set(id, i);
return next;
});
}
// Instantiate this panel's local state variables from their initial values.
useEffect(() => {
@@ -155,10 +190,24 @@ export default function Canvas({ iface, onNavigate, timeRange }: Props) {
);
}
// Collapsible panes currently in the collapsed state — their contents are hidden.
const collapsedPanes = iface.widgets.filter(
w => w.type === 'container' && w.options['collapsible'] === 'true' && collapsed.has(w.id),
);
// Tabbed panes — widgets inside that aren't on the active tab are hidden.
const tPanes = tabPanes(iface.widgets);
return (
<div class="canvas-container">
<div class="canvas-area canvas-view-bare" style={`width:${iface.w}px;height:${iface.h}px;`}>
{iface.widgets.map(widget => {
{renderOrder(iface.widgets).map(widget => {
// Hide widgets that fall inside a currently-collapsed container pane.
const hidden = collapsedPanes.some(c => c.id !== widget.id && centreInside(widget, c));
// Hide widgets inside a tabbed pane that aren't on its active tab.
const tabHidden = tPanes.some(c =>
c.id !== widget.id && centreInside(widget, c) && widgetTab(widget) !== (activeTabs.get(c.id) ?? 0),
);
if (hidden || tabHidden) return null;
const Comp = COMPONENTS[widget.type];
const inner = Comp
? <Comp
@@ -166,6 +215,10 @@ export default function Canvas({ iface, onNavigate, timeRange }: Props) {
onContextMenu={(e: MouseEvent) => onCtxMenu(e, widget)}
onNavigate={onNavigate}
timeRange={timeRange}
collapsed={collapsed.has(widget.id)}
onToggleCollapse={() => toggleCollapse(widget.id)}
activeTab={activeTabs.get(widget.id) ?? 0}
onSelectTab={(i: number) => selectTab(widget.id, i)}
/>
: (
<div
+111 -6
View File
@@ -46,7 +46,7 @@ interface ConfigInstance {
values: Record<string, any>;
}
interface Meta { id: string; name: string; version: number; setId?: string; }
interface Meta { id: string; name: string; version: number; setId?: string; enabled?: boolean; }
// ConfigRule mirrors internal/confmgr.ConfigRule: CUE validation/transformation
// 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<string, any>; compileError?: string; }
// Snapshot / preview payloads mirror the backend /config/rules/preview response.
interface SnapshotEntry { key: string; ds: string; signal: string; value?: any; ok: boolean; error?: string; }
interface SnapshotResult { setId: string; entries: SnapshotEntry[]; captured: number; failed: number; }
interface RulePreview { snapshot: SnapshotResult; result: RuleResult; }
// A rule with no explicit enabled flag (legacy) is treated as enabled.
const ruleEnabled = (r: { enabled?: boolean }) => r.enabled !== false;
interface ApplyEntry { key: string; ds: string; signal: string; value?: any; ok: boolean; skipped?: boolean; error?: string; }
interface 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<Meta[]>([]);
const [sets, setSets] = useState<Meta[]>([]);
const [setFilter, setSetFilter] = useState<string>('');
const [selectedId, setSelectedId] = useState<string | null>(null);
const hist = useUndo<ConfigInstance>();
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 (
<div class="cl-body">
@@ -983,9 +994,19 @@ function InstancesManager({ registerGuard }: { registerGuard: (fn: CloseGuard |
<button class="panel-btn" disabled={busy || sets.length === 0} title="Snapshot a set's current live signal values into a new instance" onClick={() => setShowSnap(true)}> Snapshot</button>
<button class="panel-btn" disabled={busy || sets.length === 0} onClick={() => setShowNew(true)}>+ New</button>
</div>
{sets.length > 1 && (
<div class="cl-list-filter">
<select class="prop-select" value={setFilter} title="Filter instances by config set"
onChange={(e) => setSetFilter((e.target as HTMLSelectElement).value)}>
<option value="">All sets</option>
{sets.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</div>
)}
{sets.length === 0 && <div class="hint cl-list-empty">Create a config set first.</div>}
{sets.length > 0 && instances.length === 0 && <div class="hint cl-list-empty">No instances yet.</div>}
{instances.map(s => (
{sets.length > 0 && instances.length > 0 && visibleInstances.length === 0 && <div class="hint cl-list-empty">No instances in this set.</div>}
{visibleInstances.map(s => (
<div key={s.id} class={`cl-list-item${selectedId === s.id ? ' cl-list-item-active' : ''}`} onClick={() => select(s.id)}>
<span class="cl-list-name" title={s.name}>{s.name || '(unnamed)'}</span>
<span class="cfg-badge">v{s.version}</span>
@@ -1446,6 +1467,8 @@ function RulesManager({ registerGuard }: { registerGuard: (fn: CloseGuard | null
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [check, setCheck] = useState<RuleResult | null>(null);
const [setFilter, setSetFilter] = useState<string>('');
const [preview, setPreview] = useState<RulePreview | null>(null);
const [showNew, setShowNew] = useState(false);
const [viewingVersion, setViewingVersion] = useState<number | null>(null);
const [verReload, setVerReload] = useState(0);
@@ -1469,9 +1492,20 @@ function RulesManager({ registerGuard }: { registerGuard: (fn: CloseGuard | null
return apiJSON<ConfigSet>(`/api/v1/config/sets/${encodeURIComponent(setId)}`);
}
async function runPreview() {
if (!working) return;
setBusy(true); setError(null);
try {
const res = await apiJSON<RulePreview>('/api/v1/config/rules/preview',
jsonPost({ setId: working.setId, source: working.source }));
setPreview(res);
} catch (err) { setError(`Preview failed: ${msg(err)}`); }
finally { setBusy(false); }
}
async function select(id: string) {
if (dirty && !confirm('Discard unsaved changes?')) return;
setError(null); setCheck(null);
setError(null); setCheck(null); setPreview(null);
try {
const rule = await apiJSON<ConfigRule>(`/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<ConfigRule>('/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 (
<div class="cl-body">
@@ -1581,11 +1616,22 @@ function RulesManager({ registerGuard }: { registerGuard: (fn: CloseGuard | null
<span>Rules</span>
<button class="panel-btn" disabled={busy || sets.length === 0} onClick={() => setShowNew(true)}>+ New</button>
</div>
{sets.length > 1 && (
<div class="cl-list-filter">
<select class="prop-select" value={setFilter} title="Filter rules by config set"
onChange={(e) => setSetFilter((e.target as HTMLSelectElement).value)}>
<option value="">All sets</option>
{sets.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</div>
)}
{sets.length === 0 && <div class="hint cl-list-empty">Create a config set first.</div>}
{sets.length > 0 && rules.length === 0 && <div class="hint cl-list-empty">No rules yet.</div>}
{rules.map(s => (
<div key={s.id} class={`cl-list-item${selectedId === s.id ? ' cl-list-item-active' : ''}`} onClick={() => select(s.id)}>
{sets.length > 0 && rules.length > 0 && visibleRules.length === 0 && <div class="hint cl-list-empty">No rules in this set.</div>}
{visibleRules.map(s => (
<div key={s.id} class={`cl-list-item${selectedId === s.id ? ' cl-list-item-active' : ''}${ruleEnabled(s) ? '' : ' cfg-rule-disabled'}`} onClick={() => select(s.id)}>
<span class="cl-list-name" title={s.name}>{s.name || '(unnamed)'}</span>
{!ruleEnabled(s) && <span class="hint cfg-rule-off" title="Disabled — not run on save/apply">off</span>}
{s.setId && <span class="hint cfg-rule-set" title="Bound set">{setName(s.setId)}</span>}
<span class="cfg-badge">v{s.version}</span>
<button class="cl-mini-btn" title="Delete" onClick={(e) => { e.stopPropagation(); remove(s.id); }}></button>
@@ -1605,6 +1651,7 @@ function RulesManager({ registerGuard }: { registerGuard: (fn: CloseGuard | null
{dirty && <span class="cl-dirty">unsaved</span>}
<button class="panel-btn" disabled={!hist.canUndo} title="Undo (Ctrl+Z)" onClick={undo}></button>
<button class="panel-btn" disabled={!hist.canRedo} title="Redo (Ctrl+Shift+Z)" onClick={redo}></button>
<button class="panel-btn" disabled={busy} title="Run this rule against a live snapshot of the set's signals (nothing is stored)" onClick={runPreview}> Preview</button>
<button class="panel-btn panel-btn-primary" disabled={busy || !dirty} onClick={save}>Save</button>
</div>
@@ -1621,6 +1668,14 @@ function RulesManager({ registerGuard }: { registerGuard: (fn: CloseGuard | null
<input class="prop-input" value={working.description ?? ''} placeholder="(optional)"
onInput={(e) => patch({ description: (e.target as HTMLInputElement).value })} />
</div>
<div class="cfg-field cfg-field-enabled">
<label>Enabled</label>
<label class="cfg-rule-enable" title="When enabled, this rule runs every time an instance of the bound set is saved or applied">
<input type="checkbox" checked={ruleEnabled(working)}
onChange={(e) => patch({ enabled: (e.target as HTMLInputElement).checked })} />
<span>{ruleEnabled(working) ? 'Runs on save/apply' : 'Disabled (not run)'}</span>
</label>
</div>
</div>
<div class="cfg-section-head"><span>CUE source</span></div>
@@ -1635,6 +1690,8 @@ function RulesManager({ registerGuard }: { registerGuard: (fn: CloseGuard | null
<RuleCheckView result={check} />
{preview && <RulePreviewView preview={preview} onClose={() => setPreview(null)} />}
<VersionPanel kind="rules" id={working.id}
viewing={viewingVersion} dirty={dirty} reloadKey={verReload}
onView={viewVersion}
@@ -1697,6 +1754,54 @@ function RuleCheckView({ result }: { result: RuleResult | null }) {
);
}
// RulePreviewView shows the outcome of running a rule against a live snapshot of
// the bound set's signals: the captured input values on the left and the
// processed output (input merged with rule-derived values) on the right, plus
// any compile error / violations. Nothing is persisted.
function RulePreviewView({ preview, onClose }: { preview: RulePreview; onClose: () => void }) {
const input: Record<string, any> = {};
for (const e of preview.snapshot.entries) if (e.ok) input[e.key] = e.value;
const output = { ...input, ...(preview.result.transformed ?? {}) };
const failed = preview.snapshot.entries.filter(e => !e.ok);
return (
<div class="cfg-rule-preview">
<div class="cfg-rule-preview-head">
<b>Live preview</b>
<span class="hint">{preview.snapshot.captured} captured · {preview.snapshot.failed} failed</span>
<button class="cl-mini-btn" title="Close preview" onClick={onClose}></button>
</div>
{preview.result.compileError && (
<div class="cfg-rule-check cfg-rule-check-err"><b>Compile error:</b> {preview.result.compileError}</div>
)}
{!preview.result.compileError && !preview.result.ok && (
<div class="cfg-rule-check cfg-rule-check-err">
<b>{(preview.result.violations ?? []).length} violation(s):</b>
<ul class="cfg-rule-violations">
{(preview.result.violations ?? []).map((v, i) => (
<li key={i}>{v.path ? <code>{v.path}</code> : null} {v.message}</li>
))}
</ul>
</div>
)}
{failed.length > 0 && (
<div class="hint cfg-rule-preview-failed">
Could not read: {failed.map(e => `${e.key} (${e.error})`).join(', ')}
</div>
)}
<div class="cfg-rule-preview-cols">
<div class="cfg-rule-preview-col">
<div class="hint">Snapshot input</div>
<pre class="cfg-json">{JSON.stringify(input, null, 2)}</pre>
</div>
<div class="cfg-rule-preview-col">
<div class="hint">Processed output{preview.result.ok ? '' : ' (would be rejected)'}</div>
<pre class="cfg-json">{JSON.stringify(output, null, 2)}</pre>
</div>
</div>
</div>
);
}
function NewRuleDialog({ sets, busy, onCancel, onCreate }: {
sets: Meta[]; busy: boolean; onCancel: () => void; onCreate: (name: string, setId: string) => void;
}) {
+347 -26
View File
@@ -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<string, string>; }
@@ -354,7 +366,7 @@ export default function ControlLogicEditor({ onClose }: Props) {
</div>
<div class="cl-editor-main">
<FlowEditor graph={graph}
onChange={(g) => { setGraph({ ...graph, nodes: g.nodes, wires: g.wires }); setDirty(true); }} />
onChange={(g) => { setGraph({ ...graph, nodes: g.nodes, wires: g.wires, groups: g.groups }); setDirty(true); }} />
{showVersions && (
<div class="ver-pane">
<div class="ver-pane-head">Version history</div>
@@ -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<string | null>(null);
const [selectedWire, setSelectedWire] = useState<number | null>(null);
const [selSet, setSelSet] = useState<Set<string>>(new Set());
const [selectedGroup, setSelectedGroup] = useState<string | null>(null);
const [dataSources, setDataSources] = useState<string[]>([]);
const [dsSignals, setDsSignals] = useState<Record<string, string[]>>({});
const [configInstances, setConfigInstances] = useState<{ id: string; name: string }[]>([]);
const [configSets, setConfigSets] = useState<{ id: string; name: string }[]>([]);
const canvasRef = useRef<HTMLDivElement>(null);
const dragNode = useRef<{ id: string; dx: number; dy: number } | null>(null);
const zoomRef = useRef(1);
const dragNode = useRef<
{ ids: string[]; ox: number; oy: number; starts: Map<string, { x: number; y: number }>; pushed: boolean } | null
>(null);
const [pendingWire, setPendingWire] = useState<{ from: string; port: string; x: number; y: number } | null>(null);
const pendingRef = useRef(pendingWire);
pendingRef.current = pendingWire;
// Undo/redo history + clipboard, scoped to this graph. `graphRef` always holds
// the latest {nodes,wires,groups} so the ref-based stacks read a stable value
// across renders. Snapshots are pushed right before each discrete edit.
type Snapshot = { nodes: CLNode[]; wires: CLWire[]; groups: NodeGroup[] };
const graphRef = useRef<Snapshot>({ nodes, wires, groups });
graphRef.current = { nodes, wires, groups };
const undoStack = useRef<Snapshot[]>([]);
const redoStack = useRef<Snapshot[]>([]);
const clipboard = useRef<{ nodes: CLNode[]; wires: CLWire[] } | null>(null);
const [, setHistTick] = useState(0); // re-render so toolbar enabled-state updates
const bump = () => setHistTick(t => t + 1);
const canUndo = undoStack.current.length > 0;
const canRedo = redoStack.current.length > 0;
// Live debug: observe the running graph ('live') or dry-run the unsaved edits
// ('simulate'). Node events arrive over the WS; debugStates holds the latest
// value/timestamp per node and debugSnap is the decayed view the nodes render.
const [debug, setDebug] = useState(false);
const [debugMode, setDebugMode] = useState<'live' | 'simulate'>('live');
const [debugSnap, setDebugSnap] = useState<DebugSnapshot>(new Map());
const debugStates = useRef<Map<string, { value: number; hasValue: boolean; ts: number }>>(new Map());
// Debug session: register the node-event listener, (live) subscribe to the
// running graph, and rebuild the rendered snapshot on a 250 ms tick so node
// highlights fade ~0.8 s after a node last fired. The simulate subscription is
// handled by the separate effect below so it can re-send on each edit.
useEffect(() => {
if (!debug || !graph) return;
debugStates.current = new Map();
setDebugSnap(new Map());
const off = wsClient.onDebugNode((ev) => {
debugStates.current.set(ev.nodeId, { value: ev.value, hasValue: ev.hasValue, ts: Date.now() });
});
if (debugMode === 'live') wsClient.debugSubscribeLive(graph.id);
const iv = setInterval(() => {
const now = Date.now();
const snap: DebugSnapshot = new Map();
for (const [id, s] of debugStates.current) {
snap.set(id, { value: s.hasValue ? s.value : undefined, type: 'scalar', active: now - s.ts < 800 });
}
setDebugSnap(snap);
}, 250);
return () => { off(); wsClient.debugUnsubscribe(); clearInterval(iv); };
}, [debug, debugMode, graph?.id]);
// Simulate mode: (re)compile the unsaved graph server-side on toggle and after
// each edit (debounced) so the sandbox always reflects what's on screen.
useEffect(() => {
if (!debug || debugMode !== 'simulate' || !graph) return;
const t = setTimeout(() => {
wsClient.debugSubscribeSimulate({ id: graph.id, name: graph.name, nodes, wires, groups });
}, 400);
return () => clearTimeout(t);
}, [debug, debugMode, nodes, wires, groups, graph?.id]);
useEffect(() => {
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<string, string>) {
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<string, { x: number; y: number }>, 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<string>());
if (ids.size === 0) return;
const picked = cur.nodes.filter(n => ids.has(n.id));
if (picked.length === 0) return;
const innerWires = cur.wires.filter(w => ids.has(w.from) && ids.has(w.to));
clipboard.current = {
nodes: picked.map(n => ({ ...n, params: { ...n.params } })),
wires: innerWires.map(w => ({ ...w })),
};
}
function pasteClipboard() {
const clip = clipboard.current;
if (!clip || clip.nodes.length === 0) return;
const idMap = new Map<string, string>();
const cur = graphRef.current;
const newNodes = clip.nodes.map(n => {
const id = genId();
idMap.set(n.id, id);
return { ...n, id, x: n.x + 30, y: n.y + 30, params: { ...n.params } };
});
const newWires = clip.wires
.filter(w => idMap.has(w.from) && idMap.has(w.to))
.map(w => ({ ...w, from: idMap.get(w.from)!, to: idMap.get(w.to)! }));
emit({ nodes: [...cur.nodes, ...newNodes], wires: [...cur.wires, ...newWires] });
setSelSet(new Set(newNodes.map(n => n.id)));
setSelectedNode(newNodes.length === 1 ? newNodes[0].id : null);
setSelectedWire(null);
setSelectedGroup(null);
}
function toCanvas(e: MouseEvent): { x: number; y: number } {
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<string, { x: number; y: number }>();
for (const id of ids) {
const n = nodes.find(x => x.id === id);
if (n) starts.set(id, { x: n.x, y: n.y });
}
dragNode.current = { ids: [...starts.keys()], ox: p.x, oy: p.y, starts, pushed: false };
window.addEventListener('mousemove', onNodeDragMove);
window.addEventListener('mouseup', onNodeDragUp);
}
function startNodeDrag(e: MouseEvent, node: 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<string, { x: number; y: number }>();
for (const [id, s] of d.starts) pos.set(id, { x: Math.max(0, s.x + dx), y: Math.max(0, s.y + dy) });
moveNodes(pos, record);
}
function onNodeDragUp() {
dragNode.current = null;
@@ -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<string>();
const boxByGroup = new Map<string, Rect>();
for (const gg of groupGeom) if (gg.g.collapsed && gg.box) {
boxByGroup.set(gg.g.id, gg.box);
for (const m of gg.g.members) hidden.add(m);
}
const collapsedBoxOf = (id: string): Rect | null => {
const g = groupContaining(groups, id);
return g && g.collapsed ? (boxByGroup.get(g.id) ?? null) : null;
};
const getRects = (): Box[] => nodes.map(n => ({ x: n.x, y: n.y, w: NODE_W, h: nodeHeight(n.kind) }));
const { zoom, zoomIn, zoomOut, zoomHome, zoomFit, innerStyle, zoomStyle } = useFlowZoom(canvasRef, zoomRef, CANVAS_W, CANVAS_H, getRects);
const resolveOut = (n: CLNode, port: string) => {
const box = collapsedBoxOf(n.id);
return box ? { x: box.x + box.w, y: box.y + box.h / 2 } : outAnchor(n, port);
};
const resolveIn = (n: CLNode) => {
const box = collapsedBoxOf(n.id);
return box ? { x: box.x, y: box.y + box.h / 2 } : inAnchor(n);
};
const wireHidden = (from: string, to: string) => {
const gf = groupContaining(groups, from), gt = groupContaining(groups, to);
return !!gf && gf === gt && gf.collapsed;
};
return (
<div class="flow-editor">
<div class="flow-palette">
<div class="flow-palette-toolbar">
<button class="toolbar-btn" disabled={!canUndo} onClick={undo} title="Undo (Ctrl+Z)"></button>
<button class="toolbar-btn" disabled={!canRedo} onClick={redo} title="Redo (Ctrl+Shift+Z)"></button>
<button class="toolbar-btn" disabled={selSet.size < 1} onClick={groupSelection}
title="Group selected nodes (G) — Shift+click nodes to multi-select"></button>
<button class={`toolbar-btn${debug ? ' flow-debug-on' : ''}`} onClick={() => setDebug(d => !d)}
title={debugMode === 'live'
? 'Live debug — observe the running graph\u2019s node values'
: 'Live debug — dry-run the unsaved edits (no real writes)'}></button>
{debug && (
<button class="toolbar-btn flow-debug-mode" onClick={() => setDebugMode(m => m === 'live' ? 'simulate' : 'live')}
title="Switch between observing the running graph (Live) and dry-running the unsaved edits (Simulate)">
{debugMode === 'live' ? 'Live' : 'Sim'}
</button>
)}
</div>
<div class="flow-palette-toolbar">
<button class="toolbar-btn" onClick={zoomOut} title="Zoom out"></button>
<button class="toolbar-btn flow-zoom-pct" onClick={zoomHome} title="Reset zoom (100%)">{Math.round(zoom * 100)}%</button>
<button class="toolbar-btn" onClick={zoomIn} title="Zoom in"></button>
<button class="toolbar-btn" onClick={zoomFit} title="Fit graph to view"></button>
</div>
<div class="flow-palette-title">Triggers</div>
{PALETTE.filter(e => category(e.kind) === 'trigger').map(paletteBtn)}
<div class="flow-palette-title">Logic</div>
@@ -623,17 +892,60 @@ function FlowEditor({ graph, onChange }: {
</div>
<div class="flow-canvas" ref={canvasRef}
onMouseDown={() => { setSelectedNode(null); setSelectedWire(null); }}
onMouseDown={() => { setSelectedNode(null); setSelectedWire(null); setSelectedGroup(null); setSelSet(new Set()); }}
onDragOver={onCanvasDragOver}
onDrop={onCanvasDrop}>
<div class="flow-canvas-inner">
<div class="flow-canvas-inner" style={innerStyle}>
<div class="flow-canvas-zoom" style={zoomStyle}>
{groupGeom.map(({ g, bounds, box }) => {
if (!bounds) return null;
const sel = selectedGroup === g.id;
if (g.collapsed && box) {
return (
<div key={g.id}
class={`flow-group-box${sel ? ' flow-group-selected' : ''}`}
style={`left:${box.x}px; top:${box.y}px; width:${box.w}px; height:${box.h}px;`}
onMouseDown={(e) => { e.stopPropagation(); setSelectedGroup(g.id); setSelectedNode(null); setSelectedWire(null); beginDrag(e, g.members); }}>
<div class="flow-group-header">
<button class="flow-group-caret" title="Expand"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); toggleCollapse(g.id); }}></button>
<input class="flow-group-label" value={g.label}
onMouseDown={(e) => e.stopPropagation()}
onInput={(e) => renameGroup(g.id, (e.target as HTMLInputElement).value)} />
</div>
<div class="flow-group-count hint">{g.members.length} node{g.members.length === 1 ? '' : 's'}</div>
</div>
);
}
return (
<div key={g.id}
class={`flow-group-frame${sel ? ' flow-group-selected' : ''}`}
style={`left:${bounds.x}px; top:${bounds.y}px; width:${bounds.w}px; height:${bounds.h}px;`}
onMouseDown={(e) => { e.stopPropagation(); setSelectedGroup(g.id); setSelectedNode(null); setSelectedWire(null); beginDrag(e, g.members); }}>
<div class="flow-group-header">
<button class="flow-group-caret" title="Collapse"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); toggleCollapse(g.id); }}></button>
<input class="flow-group-label" value={g.label}
onMouseDown={(e) => e.stopPropagation()}
onInput={(e) => renameGroup(g.id, (e.target as HTMLInputElement).value)} />
<button class="flow-group-del" title="Ungroup"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); ungroup(g.id); }}></button>
</div>
</div>
);
})}
<svg class="flow-wires">
{wires.map((w, idx) => {
const a = byId.get(w.from);
const b = byId.get(w.to);
if (!a || !b) return null;
const p1 = outAnchor(a, w.fromPort ?? 'out');
const p2 = inAnchor(b);
if (wireHidden(w.from, w.to)) return null;
const p1 = resolveOut(a, w.fromPort ?? 'out');
const p2 = resolveIn(b);
return (
<path key={idx}
class={`flow-wire${selectedWire === idx ? ' flow-wire-selected' : ''}`}
@@ -649,12 +961,19 @@ function FlowEditor({ graph, onChange }: {
})()}
</svg>
{nodes.map(node => (
{nodes.filter(n => !hidden.has(n.id)).map(node => {
const dbg = debug ? debugSnap.get(node.id) : undefined;
const firable = debug && node.kind.startsWith('trigger.');
const grouped = groupContaining(groups, node.id) ? ' flow-node-grouped' : '';
return (
<div key={node.id}
class={`flow-node flow-node-${category(node.kind)}${selectedNode === node.id ? ' flow-node-selected' : ''}`}
class={`flow-node flow-node-${category(node.kind)}${selectedNode === node.id ? ' flow-node-selected' : ''}${selSet.has(node.id) && selSet.size > 1 ? ' flow-node-multi' : ''}${dbg ? ' ' + nodeDebugClass(dbg) : ''}${firable ? ' flow-node-firable' : ''}${grouped}`}
style={`left:${node.x}px; top:${node.y}px; width:${NODE_W}px; min-height:${nodeHeight(node.kind)}px;`}
title={firable ? 'Double-click to force this trigger to fire' : undefined}
onMouseDown={(e) => startNodeDrag(e, node)}
onDblClick={() => { if (firable) wsClient.debugFireTrigger(node.id); }}
onMouseUp={() => { if (pendingRef.current) finishWire(node); }}>
{dbg && <span class="flow-node-badge" title={badgeTitle(dbg)}>{formatBadge(dbg)}</span>}
<div class="flow-node-header">
<span class="flow-node-title">{KIND_LABEL[node.kind]}</span>
<button class="flow-node-del" title="Delete node"
@@ -681,7 +1000,9 @@ function FlowEditor({ graph, onChange }: {
</Fragment>
))}
</div>
))}
);
})}
</div>
</div>
</div>
+66 -10
View File
@@ -9,19 +9,33 @@ import BarV from './widgets/BarV';
import Led from './widgets/Led';
import MultiLed from './widgets/MultiLed';
import SetValue from './widgets/SetValue';
import Toggle from './widgets/Toggle';
import Button from './widgets/Button';
import PlotWidget from './widgets/PlotWidget';
import ImageWidget from './widgets/ImageWidget';
import LinkWidget from './widgets/LinkWidget';
import Container from './widgets/Container';
import TableWidget from './widgets/TableWidget';
import WidgetTypePicker from './WidgetTypePicker';
import { centreInside, widgetTab, tabPanes, withContainedWidgets } from './lib/containers';
const COMPONENTS: Record<string, any> = {
textview: TextView, textlabel: TextLabel, gauge: Gauge,
barh: BarH, barv: BarV, led: Led, multiled: MultiLed,
setvalue: SetValue, button: Button, plot: PlotWidget,
image: ImageWidget, link: LinkWidget,
setvalue: SetValue, toggle: Toggle, button: Button, plot: PlotWidget,
image: ImageWidget, link: LinkWidget, container: Container,
table: TableWidget,
};
// Container panes are decorative frames that other widgets sit over, so they
// must paint behind everything else. Render them first (their relative order
// preserved) regardless of their position in the widget array.
function renderOrder(widgets: Widget[]): Widget[] {
const containers = widgets.filter(w => w.type === 'container');
const rest = widgets.filter(w => w.type !== 'container');
return [...containers, ...rest];
}
export const DEFAULT_SIZES: Record<string, [number, number]> = {
textview: [200, 50],
textlabel: [150, 36],
@@ -31,17 +45,20 @@ export const DEFAULT_SIZES: Record<string, [number, number]> = {
led: [60, 60],
multiled: [200, 80],
setvalue: [200, 60],
toggle: [140, 50],
button: [120, 50],
plot: [400, 200],
image: [200, 150],
link: [140, 50],
configselect: [220, 50],
container: [320, 220],
table: [280, 160],
};
// Widget types that support multiple signals (signal can be appended)
const MULTI_SIGNAL_TYPES = new Set(['plot', 'multiled']);
const MULTI_SIGNAL_TYPES = new Set(['plot', 'multiled', 'table']);
// Widget types that have exactly one signal (signal can be replaced)
const SINGLE_SIGNAL_TYPES = new Set(['textview', 'gauge', 'barh', 'barv', 'led', 'setvalue', 'button']);
const SINGLE_SIGNAL_TYPES = new Set(['textview', 'gauge', 'barh', 'barv', 'led', 'setvalue', 'toggle', 'button']);
interface PickerState {
visible: boolean;
@@ -132,6 +149,17 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna
const [signalDropMenu, setSignalDropMenu] = useState<SignalDropMenuState | null>(null);
// Which tab is being edited per tabbed container (ephemeral). Widgets not on
// the active tab are hidden so each tab's content can be laid out separately.
const [activeTabs, setActiveTabs] = useState<Map<string, number>>(() => new Map());
function selectTab(id: string, i: number) {
setActiveTabs(prev => {
const next = new Map(prev);
next.set(id, i);
return next;
});
}
useEffect(() => {
function onMouseMove(e: MouseEvent) {
const grid = snapGridRef.current;
@@ -248,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
);
@@ -303,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 }));
@@ -325,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,
@@ -355,6 +395,15 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna
onSelect(selectedIds.filter(s => s !== id));
}
// A widget is hidden in the editor when it sits inside a tabbed container but
// belongs to a tab other than the one currently being edited.
const tPanes = tabPanes(iface.widgets);
function tabHidden(widget: Widget): boolean {
return tPanes.some(c =>
c.id !== widget.id && centreInside(widget, c) && widgetTab(widget) !== (activeTabs.get(c.id) ?? 0),
);
}
return (
<div
class="edit-canvas-container"
@@ -365,10 +414,16 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna
>
<div class="canvas-area" style={`width:${iface.w}px;height:${iface.h}px;`}>
{(iface.widgets || []).map(widget => {
{renderOrder(iface.widgets || []).map(widget => {
if (tabHidden(widget)) return null;
const Comp = COMPONENTS[widget.type];
return Comp
? <Comp key={widget.id} widget={widget} />
? <Comp
key={widget.id}
widget={widget}
activeTab={activeTabs.get(widget.id) ?? 0}
onSelectTab={(i: number) => selectTab(widget.id, i)}
/>
: (
<div key={widget.id} class="unknown-widget"
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}>
@@ -377,7 +432,8 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna
);
})}
{(iface.widgets || []).map(widget => {
{renderOrder(iface.widgets || []).map(widget => {
if (tabHidden(widget)) return null;
const isSelected = selectedIds.includes(widget.id);
return (
+89 -1
View File
@@ -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) {
<button class="toolbar-btn icon-only" onClick={() => doDistribute('v')} title="Distribute V"></button>
</div>
)}
<span class="toolbar-sep" />
<div class="toolbar-dropdown">
<button class="toolbar-btn" onClick={() => setShowGroupMenu(m => !m)} title="Group selection into a container"> Group </button>
{showGroupMenu && (
<div class="toolbar-dropdown-menu" onClick={() => setShowGroupMenu(false)}>
<button class="ctx-item" onClick={() => groupIntoContainer('pane')}>Into Pane</button>
<button class="ctx-item" onClick={() => groupIntoContainer('tabs')}>Into Tabs</button>
</div>
)}
</div>
</div>
)}
@@ -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}
/>
+30 -3
View File
@@ -15,6 +15,30 @@ interface Props {
onToggleCollapse: () => void;
}
// Monochrome (currentColor) panel-kind glyph. Inline SVG avoids font-dependent
// emoji that may render in colour or as a missing-glyph box.
function KindIcon({ kind }: { kind?: 'panel' | 'plot' }) {
const plot = kind === 'plot';
return (
<span class="iface-item-icon" title={plot ? 'Plot panel' : 'HMI panel'}>
{plot ? (
<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M2.5 2.5 v11 h11" />
<path d="M4.5 11 L7 7.5 L9 9.5 L13 4.5" />
</svg>
) : (
<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="2" y="2.5" width="12" height="11" rx="1.5" />
<path d="M4.5 6 h7" />
<circle cx="6" cy="6" r="1.2" fill="currentColor" stroke="none" />
<path d="M4.5 10 h7" />
<circle cx="10" cy="10" r="1.2" fill="currentColor" stroke="none" />
</svg>
)}
</span>
);
}
export default function InterfaceList({ onLoad, onSelect, onEdit, onNewPlot, onEditId, width, collapsed, onToggleCollapse }: Props) {
const [interfaces, setInterfaces] = useState<InterfaceListItem[]>([]);
const [folders, setFolders] = useState<Folder[]>([]);
@@ -87,6 +111,7 @@ export default function InterfaceList({ onLoad, onSelect, onEdit, onNewPlot, onE
id: String(item.id || item.ID || ''),
name: String(item.name || item.Name || ''),
version: Number(item.version || item.Version || 0),
kind: item.kind === 'plot' ? ('plot' as const) : undefined,
owner: item.owner ? String(item.owner) : '',
folder: item.folder ? String(item.folder) : '',
order: Number(item.order || 0),
@@ -171,19 +196,21 @@ export default function InterfaceList({ onLoad, onSelect, onEdit, onNewPlot, onE
return (
<li
key={item.id}
class={`iface-item${dropTarget === `p:${item.id}` ? ' drop-target' : ''}`}
class={`iface-item iface-item-clickable${dropTarget === `p:${item.id}` ? ' drop-target' : ''}`}
draggable={drag}
onClick={() => onSelect?.(item.id)}
onDragStart={drag ? (e) => { dragId.current = item.id; e.dataTransfer!.effectAllowed = 'move'; } : undefined}
onDragEnd={endDrag}
onDragOver={(e) => { if (dragId.current) { e.preventDefault(); e.stopPropagation(); setDropTarget(`p:${item.id}`); } }}
onDragLeave={() => setDropTarget(t => t === `p:${item.id}` ? null : t)}
onDrop={(e) => dropOnPanel(item, e)}
>
<span class="iface-item-name" onClick={() => onSelect?.(item.id)} title={item.owner ? `Owner: ${item.owner}` : undefined}>
<span class="iface-item-name" title={item.owner ? `Owner: ${item.owner}` : undefined}>
<KindIcon kind={item.kind} />
{item.name || item.id}
{item.perm === 'read' && <span class="iface-badge" title="Read-only">ro</span>}
</span>
<div class="iface-item-actions">
<div class="iface-item-actions" onClick={(e) => e.stopPropagation()}>
{item.perm === 'write' && <button class="icon-btn" title="Edit" onClick={() => onEditId?.(item.id)}></button>}
<button class="icon-btn" title="Open fullscreen in new tab" onClick={() => handleFullscreen(item.id)}></button>
{writable && <button class="icon-btn" title="Clone" onClick={() => handleClone(item.id)}></button>}
+239 -24
View File
@@ -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<string | null>(null);
const [selectedWire, setSelectedWire] = useState<number | null>(null);
// Multi-selection (Shift+click) used to build groups; selectedNode stays the
// "primary" node shown in the inspector.
const [selSet, setSelSet] = useState<Set<string>>(new Set());
const [selectedGroup, setSelectedGroup] = useState<string | null>(null);
const groups = graph.groups ?? [];
const [dataSources, setDataSources] = useState<string[]>([]);
const [dsSignals, setDsSignals] = useState<Record<string, string[]>>({});
@@ -154,7 +171,10 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
const [configSets, setConfigSets] = useState<{ id: string; name: string }[]>([]);
const canvasRef = useRef<HTMLDivElement>(null);
const dragNode = useRef<{ id: string; dx: number; dy: number; pushed: boolean } | null>(null);
const zoomRef = useRef(1);
const dragNode = useRef<
{ ids: string[]; ox: number; oy: number; starts: Map<string, { x: number; y: number }>; pushed: boolean } | null
>(null);
const [pendingWire, setPendingWire] = useState<{ from: string; port: string; x: number; y: number } | null>(null);
const pendingRef = useRef(pendingWire);
pendingRef.current = pendingWire;
@@ -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<LogicEngine | null>(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<string, string>) {
setNodes(nodes.map(n => (n.id === id ? { ...n, params: { ...n.params, ...patch } } : n)));
}
function moveNode(id: string, x: number, y: number, record = false) {
setNodes(nodes.map(n => (n.id === id ? { ...n, x, y } : n)), record);
// Move several nodes at once (group / multi-select drag). `pos` maps id→{x,y}.
function moveNodes(pos: Map<string, { x: number; y: number }>, record = false) {
setNodes(nodes.map(n => (pos.has(n.id) ? { ...n, ...pos.get(n.id)! } : n)), record);
}
// ── Groups ──────────────────────────────────────────────────────────────────
function groupSelection() {
if (selSet.size < 1) return;
const members = nodes.filter(n => selSet.has(n.id)).map(n => n.id);
if (members.length < 1) return;
const g: NodeGroup = { id: genGroupId(), label: 'Group', members, collapsed: false };
setGroups([...groups, g]);
setSelectedGroup(g.id);
setSelectedNode(null);
setSelectedWire(null);
}
function ungroup(gid: string) {
setGroups(groups.filter(g => g.id !== gid));
if (selectedGroup === gid) setSelectedGroup(null);
}
function toggleCollapse(gid: string) {
setGroups(groups.map(g => (g.id === gid ? { ...g, collapsed: !g.collapsed } : g)));
}
function renameGroup(gid: string, label: string) {
setGroups(groups.map(g => (g.id === gid ? { ...g, label } : g)), false);
}
function deleteNode(id: string) {
const nextNodes = nodes.filter(n => n.id !== id);
const nextGroups = pruneGroups(groups, new Set(nextNodes.map(n => n.id)));
setGraph({
nodes: nodes.filter(n => n.id !== id),
nodes: nextNodes,
wires: wires.filter(w => w.from !== id && w.to !== id),
...(nextGroups.length ? { groups: nextGroups } : {}),
});
if (selectedNode === id) setSelectedNode(null);
if (selSet.has(id)) { const s = new Set(selSet); s.delete(id); setSelSet(s); }
}
function addWire(from: string, port: string, to: string) {
if (from === to) return;
@@ -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<string, { x: number; y: number }>();
for (const id of ids) {
const n = nodes.find(x => x.id === id);
if (n) starts.set(id, { x: n.x, y: n.y });
}
dragNode.current = { ids: [...starts.keys()], ox: p.x, oy: p.y, starts, pushed: false };
window.addEventListener('mousemove', onNodeDragMove);
window.addEventListener('mouseup', onNodeDragUp);
}
function startNodeDrag(e: MouseEvent, node: LogicNode) {
e.stopPropagation();
// Shift+click toggles the node in the multi-selection without dragging.
if (e.shiftKey) {
const s = new Set(selSet);
if (s.has(node.id)) s.delete(node.id); else s.add(node.id);
setSelSet(s);
setSelectedNode(node.id);
setSelectedWire(null);
setSelectedGroup(null);
return;
}
setSelectedWire(null);
setSelectedGroup(null);
// Drag the whole multi-selection if this node is part of it, else just it.
const ids = selSet.has(node.id) && selSet.size > 1 ? [...selSet] : [node.id];
if (!(selSet.has(node.id) && selSet.size > 1)) setSelSet(new Set([node.id]));
setSelectedNode(node.id);
beginDrag(e, ids);
}
function onNodeDragMove(e: MouseEvent) {
const d = dragNode.current;
if (!d) return;
@@ -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<string, { x: number; y: number }>();
for (const [id, s] of d.starts) pos.set(id, { x: Math.max(0, s.x + dx), y: Math.max(0, s.y + dy) });
moveNodes(pos, record);
}
function onNodeDragUp() {
dragNode.current = null;
@@ -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<string>();
const boxByGroup = new Map<string, Rect>();
for (const gg of groupGeom) if (gg.g.collapsed && gg.box) {
boxByGroup.set(gg.g.id, gg.box);
for (const m of gg.g.members) hidden.add(m);
}
const collapsedBoxOf = (id: string): Rect | null => {
const g = groupContaining(groups, id);
return g && g.collapsed ? (boxByGroup.get(g.id) ?? null) : null;
};
const getRects = (): Box[] => graph.nodes.map(n => ({ x: n.x, y: n.y, w: NODE_W, h: nodeHeight(n.kind) }));
const { zoom, zoomIn, zoomOut, zoomHome, zoomFit, innerStyle, zoomStyle } = useFlowZoom(canvasRef, zoomRef, CANVAS_W, CANVAS_H, getRects);
// Create/tear down the dry-run engine as debug mode toggles.
useEffect(() => {
if (!debug) return;
const eng = new LogicEngine();
eng.setDryRun(true);
eng.load(graphRef.current);
dbgEngine.current = eng;
return () => { eng.clear(); dbgEngine.current = null; };
}, [debug]);
// Reload the dry-run engine when the edited graph changes (debounced so rapid
// edits settle before re-running triggers).
useEffect(() => {
if (!debug || !dbgEngine.current) return;
const h = setTimeout(() => dbgEngine.current?.load(graphRef.current), 300);
return () => clearTimeout(h);
}, [debug, graph]);
// Poll the dry-run engine's per-node state into the shared overlay.
const debugSnap: DebugSnapshot = useFlowDebug(debug, async () => {
const eng = dbgEngine.current;
if (!eng) return null;
const snap: DebugSnapshot = new Map();
for (const [id, s] of eng.getDebug()) snap.set(id, { value: s.value, active: s.active });
return snap;
});
const resolveOut = (n: LogicNode, port: string) => {
const box = collapsedBoxOf(n.id);
return box ? { x: box.x + box.w, y: box.y + box.h / 2 } : outAnchor(n, port);
};
const resolveIn = (n: LogicNode) => {
const box = collapsedBoxOf(n.id);
return box ? { x: box.x, y: box.y + box.h / 2 } : inAnchor(n);
};
// A wire is hidden when both ends collapse into the same group.
const wireHidden = (from: string, to: string) => {
const gf = groupContaining(groups, from), gt = groupContaining(groups, to);
return !!gf && gf === gt && gf.collapsed;
};
return (
<div class="flow-editor">
<div class="flow-palette">
<div class="flow-palette-toolbar">
<button class="toolbar-btn" disabled={!canUndo} onClick={undo} title="Undo (Ctrl+Z)"></button>
<button class="toolbar-btn" disabled={!canRedo} onClick={redo} title="Redo (Ctrl+Shift+Z)"></button>
<button class="toolbar-btn" disabled={selSet.size < 1} onClick={groupSelection}
title="Group selected nodes (G) — Shift+click nodes to multi-select"></button>
<button class={`toolbar-btn${debug ? ' flow-debug-on' : ''}`} onClick={() => setDebug(d => !d)}
title="Live debug — dry-run the flow and show each node's value (no real writes)"></button>
</div>
<div class="flow-palette-toolbar">
<button class="toolbar-btn" onClick={zoomOut} title="Zoom out"></button>
<button class="toolbar-btn flow-zoom-pct" onClick={zoomHome} title="Reset zoom (100%)">{Math.round(zoom * 100)}%</button>
<button class="toolbar-btn" onClick={zoomIn} title="Zoom in"></button>
<button class="toolbar-btn" onClick={zoomFit} title="Fit graph to view"></button>
</div>
<div class="flow-palette-title">Triggers</div>
{PALETTE.filter(e => category(e.kind) === 'trigger').map(paletteBtn)}
@@ -514,17 +676,61 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
</div>
<div class="flow-canvas" ref={canvasRef}
onMouseDown={() => { setSelectedNode(null); setSelectedWire(null); }}
onMouseDown={() => { setSelectedNode(null); setSelectedWire(null); setSelectedGroup(null); setSelSet(new Set()); }}
onDragOver={onCanvasDragOver}
onDrop={onCanvasDrop}>
<div class="flow-canvas-inner">
<div class="flow-canvas-inner" style={innerStyle}>
<div class="flow-canvas-zoom" style={zoomStyle}>
{/* Group frames (behind nodes). Collapsed groups render a compact box. */}
{groupGeom.map(({ g, bounds, box }) => {
if (!bounds) return null;
const sel = selectedGroup === g.id;
if (g.collapsed && box) {
return (
<div key={g.id}
class={`flow-group-box${sel ? ' flow-group-selected' : ''}`}
style={`left:${box.x}px; top:${box.y}px; width:${box.w}px; height:${box.h}px;`}
onMouseDown={(e) => { e.stopPropagation(); setSelectedGroup(g.id); setSelectedNode(null); setSelectedWire(null); beginDrag(e, g.members); }}>
<div class="flow-group-header">
<button class="flow-group-caret" title="Expand"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); toggleCollapse(g.id); }}></button>
<input class="flow-group-label" value={g.label}
onMouseDown={(e) => e.stopPropagation()}
onInput={(e) => renameGroup(g.id, (e.target as HTMLInputElement).value)} />
</div>
<div class="flow-group-count hint">{g.members.length} node{g.members.length === 1 ? '' : 's'}</div>
</div>
);
}
return (
<div key={g.id}
class={`flow-group-frame${sel ? ' flow-group-selected' : ''}`}
style={`left:${bounds.x}px; top:${bounds.y}px; width:${bounds.w}px; height:${bounds.h}px;`}
onMouseDown={(e) => { e.stopPropagation(); setSelectedGroup(g.id); setSelectedNode(null); setSelectedWire(null); beginDrag(e, g.members); }}>
<div class="flow-group-header">
<button class="flow-group-caret" title="Collapse"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); toggleCollapse(g.id); }}></button>
<input class="flow-group-label" value={g.label}
onMouseDown={(e) => e.stopPropagation()}
onInput={(e) => renameGroup(g.id, (e.target as HTMLInputElement).value)} />
<button class="flow-group-del" title="Ungroup"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); ungroup(g.id); }}></button>
</div>
</div>
);
})}
<svg class="flow-wires">
{wires.map((w, idx) => {
const a = byId.get(w.from);
const b = byId.get(w.to);
if (!a || !b) return null;
const p1 = outAnchor(a, w.fromPort ?? 'out');
const p2 = inAnchor(b);
if (wireHidden(w.from, w.to)) return null;
const p1 = resolveOut(a, w.fromPort ?? 'out');
const p2 = resolveIn(b);
return (
<path key={idx}
class={`flow-wire${selectedWire === idx ? ' flow-wire-selected' : ''}`}
@@ -540,12 +746,19 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
})()}
</svg>
{nodes.map(node => (
{nodes.filter(n => !hidden.has(n.id)).map(node => {
const dbg = debug ? debugSnap.get(node.id) : undefined;
const firable = debug && node.kind.startsWith('trigger.');
const grouped = groupContaining(groups, node.id) ? ' flow-node-grouped' : '';
return (
<div key={node.id}
class={`flow-node flow-node-${category(node.kind)}${selectedNode === node.id ? ' flow-node-selected' : ''}`}
class={`flow-node flow-node-${category(node.kind)}${selectedNode === node.id ? ' flow-node-selected' : ''}${selSet.has(node.id) && selSet.size > 1 ? ' flow-node-multi' : ''}${dbg ? ' ' + nodeDebugClass(dbg) : ''}${firable ? ' flow-node-firable' : ''}${grouped}`}
style={`left:${node.x}px; top:${node.y}px; width:${NODE_W}px; min-height:${nodeHeight(node.kind)}px;`}
title={firable ? 'Double-click to force this trigger to fire' : undefined}
onMouseDown={(e) => startNodeDrag(e, node)}
onDblClick={() => { if (firable) dbgEngine.current?.fireTrigger(node.id); }}
onMouseUp={() => { if (pendingRef.current) finishWire(node); }}>
{dbg && <span class={`flow-node-badge${dbg.approx ? ' approx' : ''}${dbg.error ? ' error' : ''}`} title={badgeTitle(dbg)}>{(dbg.approx ? '~' : '') + formatBadge(dbg)}</span>}
<div class="flow-node-header">
<span class="flow-node-title">{KIND_LABEL[node.kind]}</span>
<button class="flow-node-del" title="Delete node"
@@ -572,7 +785,9 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
</Fragment>
))}
</div>
))}
);
})}
</div>
</div>
</div>
+186 -4
View File
@@ -1,12 +1,14 @@
import { h } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import type { Widget, Interface } from './lib/types';
import { containingTabPane, tabLabels } from './lib/containers';
interface Props {
selected: Widget | null;
multiCount: number;
iface: Interface;
onChange: (updated: Widget) => void;
onRenameId: (oldId: string, newId: string) => void;
onIfaceChange: (updated: Interface) => void;
width?: number;
}
@@ -44,16 +46,47 @@ function TextInput({ value, onCommit }: { value: string; onCommit: (v: string) =
);
}
// Like TextInput, but for the widget id: rejects invalid (empty/duplicate)
// commits and reverts the field to the current id when `onRename` returns false.
function IdInput({ value, onRename }: { value: string; onRename: (v: string) => boolean }) {
const [local, setLocal] = useState(value || '');
useEffect(() => {
setLocal(value || '');
}, [value]);
function commit() {
const v = local.trim();
if (v === value) { setLocal(value); return; }
if (!onRename(v)) setLocal(value); // rejected — revert to the current id
}
return (
<input
class="prop-input"
value={local}
onInput={(e) => setLocal((e.target as HTMLInputElement).value)}
onChange={commit}
onKeyDown={(e) => {
if (e.key === 'Enter') {
commit();
(e.target as HTMLInputElement).blur();
}
}}
/>
);
}
// Widgets that show units from signal metadata (can be overridden)
const UNIT_WIDGETS = new Set(['textview', 'gauge', 'barh', 'barv', 'setvalue']);
// Widgets where the user can set a numeric format string
const FORMAT_WIDGETS = new Set(['textview', 'gauge', 'barh', 'barv']);
// Widgets with a signal-based label (can be overridden)
const LABEL_WIDGETS = new Set(['textview', 'gauge', 'barh', 'barv', 'setvalue', 'led', 'multiled']);
const LABEL_WIDGETS = new Set(['textview', 'gauge', 'barh', 'barv', 'setvalue', 'led', 'multiled', 'toggle']);
// Widgets with multiple signals
const MULTI_SIGNAL_WIDGETS = new Set(['plot', 'multiled']);
const MULTI_SIGNAL_WIDGETS = new Set(['plot', 'multiled', 'table']);
export default function PropertiesPane({ selected, multiCount, iface, onChange, onIfaceChange, width }: Props) {
export default function PropertiesPane({ selected, multiCount, iface, onChange, onRenameId, onIfaceChange, width }: Props) {
const [collapsed, setCollapsed] = useState(false);
const [configSets, setConfigSets] = useState<{ id: string; name: string }[]>([]);
@@ -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,
<div class="props-section" key={w.id}>
<div class="props-section-title">{w.type}</div>
{/* Widget id — editable so logic/plot refs can use a friendly name */}
<Field label="Widget ID">
<IdInput value={w.id} onRename={renameId} />
</Field>
{/* Position / size */}
<Field label="X">
<input class="prop-input prop-input-num" type="number" value={w.x}
@@ -180,7 +229,7 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
{/* Label: button and textlabel use it as display text;
signal widgets use it as header label */}
{w.type !== 'image' && w.type !== 'link' && (
{w.type !== 'image' && w.type !== 'link' && w.type !== 'container' && w.type !== 'table' && (
<Field label={LABEL_WIDGETS.has(w.type) ? 'Label override' : 'Label'}>
{LABEL_WIDGETS.has(w.type) ? (
<div class="prop-field-col">
@@ -247,6 +296,30 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
</div>
)}
{w.type === 'toggle' && (
<div>
<Field label="On value">
<TextInput value={w.options['onValue'] ?? '1'} onCommit={(v) => setOpt('onValue', v)} />
</Field>
<Field label="Off value">
<TextInput value={w.options['offValue'] ?? '0'} onCommit={(v) => setOpt('offValue', v)} />
</Field>
<Field label="On label">
<TextInput value={w.options['onLabel'] ?? ''} onCommit={(v) => setOpt('onLabel', v)} />
</Field>
<Field label="Off label">
<TextInput value={w.options['offLabel'] ?? ''} onCommit={(v) => setOpt('offLabel', v)} />
</Field>
<Field label="Confirm dialog">
<select class="prop-select" value={w.options['confirm'] ?? 'false'}
onChange={(e) => setOpt('confirm', (e.target as HTMLSelectElement).value)}>
<option value="false">No</option>
<option value="true">Yes</option>
</select>
</Field>
</div>
)}
{/* Button options (issue #2) */}
{w.type === 'button' && (
<div>
@@ -404,6 +477,115 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
</Field>
</div>
)}
{w.type === 'container' && (
<div>
<Field label="Variant">
<select class="prop-select" value={w.options['variant'] ?? 'pane'}
onChange={(e) => setOpt('variant', (e.target as HTMLSelectElement).value)}>
<option value="pane">Pane</option>
<option value="tabs">Tabs</option>
</select>
</Field>
<Field label="Accent">
<TextInput value={w.options['accent'] ?? '#3d4f6e'} onCommit={(v) => setOpt('accent', v)} />
</Field>
<Field label="Background">
<select class="prop-select" value={w.options['bg'] ?? 'true'}
onChange={(e) => setOpt('bg', (e.target as HTMLSelectElement).value)}>
<option value="true">Filled</option>
<option value="false">None</option>
</select>
</Field>
{(w.options['variant'] ?? 'pane') === 'tabs' ? (
<Field label="Tabs">
<div class="prop-field-col">
<TextInput value={w.options['tabs'] ?? 'Tab 1,Tab 2'} onCommit={(v) => setOpt('tabs', v)} />
<span class="prop-hint">Comma-separated tab names. Assign each widget to a tab via its "Tab" field.</span>
</div>
</Field>
) : (
<div>
<Field label="Title">
<TextInput value={w.options['title'] ?? ''} onCommit={(v) => setOpt('title', v)} />
</Field>
<Field label="Collapsible">
<select class="prop-select" value={w.options['collapsible'] ?? 'false'}
onChange={(e) => setOpt('collapsible', (e.target as HTMLSelectElement).value)}>
<option value="false">No</option>
<option value="true">Yes</option>
</select>
</Field>
</div>
)}
</div>
)}
{w.type === 'table' && (() => {
const allCols = ['name', 'value', 'unit', 'quality', 'time'];
const colLabels: Record<string, string> = {
name: 'Name', value: 'Value', unit: 'Unit', quality: 'Status', time: 'Time',
};
const cur = (w.options['columns'] ?? 'name,value,unit')
.split(',').map(s => s.trim()).filter(Boolean);
const toggleCol = (c: string) => {
const next = cur.includes(c)
? cur.filter(x => x !== c)
: allCols.filter(x => cur.includes(x) || x === c);
setOpt('columns', next.join(','));
};
return (
<div>
<Field label="Title">
<TextInput value={w.options['title'] ?? ''} onCommit={(v) => setOpt('title', v)} />
</Field>
<Field label="Columns">
<div class="prop-field-col">
{allCols.map(c => (
<label key={c} class="prop-check">
<input type="checkbox" checked={cur.includes(c)} onChange={() => toggleCol(c)} />
{colLabels[c]}
</label>
))}
</div>
</Field>
<Field label="Header row">
<select class="prop-select" value={w.options['header'] ?? 'true'}
onChange={(e) => setOpt('header', (e.target as HTMLSelectElement).value)}>
<option value="true">Show</option>
<option value="false">Hide</option>
</select>
</Field>
<Field label="Value format">
<div class="prop-field-col">
<TextInput value={w.options['format'] ?? ''} onCommit={(v) => setOpt('format', v)} />
<span class="prop-hint">3f · 2e · 4g · empty=auto</span>
</div>
</Field>
<Field label="Row labels">
<div class="prop-field-col">
<TextInput value={w.options['labels'] ?? ''} onCommit={(v) => setOpt('labels', v)} />
<span class="prop-hint">Comma-separated, one per signal. Empty = signal name.</span>
</div>
</Field>
</div>
);
})()}
{/* Tab assignment for a widget sitting inside a tabbed container. */}
{w.type !== 'container' && (() => {
const host = containingTabPane(w, iface.widgets);
if (!host) return null;
const tabs = tabLabels(host);
return (
<Field label="Tab">
<select class="prop-select" value={w.options['tab'] ?? '0'}
onChange={(e) => setOpt('tab', (e.target as HTMLSelectElement).value)}>
{tabs.map((t, i) => <option key={i} value={String(i)}>{t}</option>)}
</select>
</Field>
);
})()}
</div>
)}
+285 -25
View File
@@ -3,8 +3,11 @@ import { useState, useEffect, useRef } from 'preact/hooks';
import SignalPicker, { SignalOption } from './SignalPicker';
import LuaEditor from './LuaEditor';
import type { SignalDef, SynthGraph, SynthGraphNode } from './lib/types';
import { inferNodeTypes, SynthType } from './lib/synthTypes';
import { inferNodeTypes, portAccept, typesCompatible, SynthType } from './lib/synthTypes';
import { VersionTree, DiffViewer } from './VersionHistory';
import { 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<Graph>({ nodes: [], wires: [] });
const [selected, setSelected] = useState<string | null>(null);
const [selectedWire, setSelectedWire] = useState<number | null>(null);
// Multi-selection (Shift+click) and the selected group, for node grouping.
const [selSet, setSelSet] = useState<Set<string>>(new Set());
const [selectedGroup, setSelectedGroup] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [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<Record<string, SignalInfo[]>>({});
const canvasRef = useRef<HTMLDivElement>(null);
const dragNode = useRef<{ id: string; dx: number; dy: number; pushed: boolean } | null>(null);
const zoomRef = useRef(1);
const dragNode = useRef<{ ids: string[]; ox: number; oy: number; starts: Map<string, { x: number; y: number }>; pushed: boolean } | null>(null);
const [pendingWire, setPendingWire] = useState<{ from: string; x: number; y: number } | null>(null);
const 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<Graph[]>([]);
const redoStack = useRef<Graph[]>([]);
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,21 +550,93 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
.map(w => (w.to === id && w.toPort > idx ? { ...w, toPort: w.toPort - 1 } : w));
commit({ nodes, wires });
}
function moveNode(id: string, x: number, y: number, record: boolean) {
// Move several nodes at once (group / multi-select drag). `pos` maps id→{x,y}.
function moveNodes(pos: Map<string, { x: number; y: number }>, record: boolean) {
const g = graphRef.current;
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<string>());
const picked = cur.nodes.filter(n => ids.has(n.id) && n.kind !== 'output');
if (picked.length === 0) return;
const pickedIds = new Set(picked.map(n => n.id));
const innerWires = cur.wires.filter(w => pickedIds.has(w.from) && pickedIds.has(w.to));
clipboard.current = {
nodes: picked.map(n => ({ ...n, params: n.params ? JSON.parse(JSON.stringify(n.params)) : undefined })),
wires: innerWires.map(w => ({ ...w })),
};
}
function pasteClipboard() {
const clip = clipboard.current;
if (!clip || clip.nodes.length === 0) return;
const idMap = new Map<string, string>();
const cur = graphRef.current;
const newNodes = clip.nodes.map(n => {
const id = genId();
idMap.set(n.id, id);
return { ...n, id, x: n.x + 1.5 * REM, y: n.y + 1.5 * REM, params: n.params ? JSON.parse(JSON.stringify(n.params)) : undefined };
});
const newWires = clip.wires
.filter(w => idMap.has(w.from) && idMap.has(w.to))
.map(w => ({ ...w, from: idMap.get(w.from)!, to: idMap.get(w.to)! }));
for (const n of newNodes) if (n.kind === 'source' && n.ds) loadSignals(n.ds);
commit({ nodes: [...cur.nodes, ...newNodes], wires: [...cur.wires, ...newWires] });
setSelSet(new Set(newNodes.map(n => n.id)));
setSelected(newNodes.length === 1 ? newNodes[0].id : null);
setSelectedWire(null);
setSelectedGroup(null);
}
// ── Groups ──────────────────────────────────────────────────────────────────
function setGroups(next: NodeGroup[], record = true) {
const g = graphRef.current;
commit({ nodes: g.nodes, wires: g.wires, groups: next }, record);
}
function groupSelection() {
if (selSet.size < 1) return;
const members = graph.nodes.filter(n => selSet.has(n.id)).map(n => n.id);
if (members.length < 1) return;
const grp: NodeGroup = { id: genGroupId(), label: 'Group', members, collapsed: false };
setGroups([...(graph.groups ?? []), grp]);
setSelectedGroup(grp.id); setSelected(null); setSelectedWire(null);
}
function ungroup(gid: string) {
setGroups((graph.groups ?? []).filter(g => g.id !== gid));
if (selectedGroup === gid) setSelectedGroup(null);
}
function toggleCollapse(gid: string) {
setGroups((graph.groups ?? []).map(g => (g.id === gid ? { ...g, collapsed: !g.collapsed } : g)));
}
function renameGroup(gid: string, label: string) {
setGroups((graph.groups ?? []).map(g => (g.id === gid ? { ...g, label } : g)), false);
}
function addWire(from: string, to: string, toPort: number) {
if (from === to) return;
const fn = graph.nodes.find(n => n.id === from);
const tn = graph.nodes.find(n => n.id === to);
if (!fn || !tn || !hasOutput(fn.kind) || tn.kind === 'source') return;
// Reject a definitely type-incompatible link (e.g. a scalar into an array
// reduction). 'unknown'/'any' ports never block — runtime typing is final.
const { types } = inferNodeTypes(compile(graph), sourceSynthType);
const accept = tn.kind === 'op' ? portAccept(tn.op ?? '') : 'any';
if (!typesCompatible(types.get(from) ?? 'unknown', accept)) {
setError(`Incompatible connection: ${accept} port can't take a ${types.get(from)} value`);
return;
}
// One wire per input port: replace any existing wire on that port.
const wires = graph.wires.filter(w => !(w.to === to && w.toPort === toPort));
if (wires.some(w => w.from === from && w.to === to && w.toPort === toPort)) return;
@@ -564,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<string, { x: number; y: number }>();
for (const id of ids) {
const n = graphRef.current.nodes.find(x => x.id === id);
if (n) starts.set(id, { x: n.x, y: n.y });
}
dragNode.current = { ids: [...starts.keys()], ox: p.x, oy: p.y, starts, pushed: false };
}
function startNodeDrag(e: MouseEvent, node: GNode) {
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();
@@ -596,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<string, { x: number; y: number }>();
for (const [id, s] of d.starts) pos.set(id, { x: Math.max(0, s.x + dx), y: Math.max(0, s.y + dy) });
moveNodes(pos, record);
return;
}
const cur = pendingRef.current;
@@ -643,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<string>();
const boxByGroup = new Map<string, Rect>();
for (const gg of groupGeom) if (gg.g.collapsed && gg.box) {
boxByGroup.set(gg.g.id, gg.box);
for (const m of gg.g.members) hiddenNodes.add(m);
}
const collapsedBoxOf = (id: string): Rect | null => {
const g = groupContaining(groups, id);
return g && g.collapsed ? (boxByGroup.get(g.id) ?? null) : null;
};
const resolveOut = (n: GNode) => {
const box = collapsedBoxOf(n.id);
return box ? { x: box.x + box.w, y: box.y + box.h / 2 } : outAnchor(n);
};
const resolveIn = (n: GNode, port: number) => {
const box = collapsedBoxOf(n.id);
return box ? { x: box.x, y: box.y + box.h / 2 } : inAnchor(n, port);
};
// A wire is hidden when both ends collapse into the same group.
const wireHidden = (from: string, to: string) => {
const gf = groupContaining(groups, from), gt = groupContaining(groups, to);
return !!gf && gf === gt && gf.collapsed;
};
// ── Pan / zoom ───────────────────────────────────────────────────────────────
const getRects = (): Box[] => graph.nodes.map(n => ({ x: n.x, y: n.y, w: NODE_W, h: nodeMinHeight(n, graph.wires) }));
const { zoom, zoomIn, zoomOut, zoomHome, zoomFit, innerStyle, zoomStyle } =
useFlowZoom(canvasRef, zoomRef, CANVAS_W, CANVAS_H, getRects);
// ── Live / debug mode ────────────────────────────────────────────────────────
// Polls the server with the current (unsaved) graph; each node lights up with
// its computed value. Stateful DSP nodes are flagged "approx" (single-shot eval).
const debugSnap: DebugSnapshot = useFlowDebug(debug, async () => {
const res = await fetch('/api/v1/synthetic/trace', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ graph: compile(graphRef.current) }),
});
if (!res.ok) return null;
const j = await res.json();
const snap: DebugSnapshot = new Map();
for (const [id, n] of Object.entries(j.nodes ?? {})) {
const node = n as { value: any; type?: 'scalar' | 'array'; approx?: boolean };
snap.set(id, { value: node.value, type: node.type, approx: node.approx, active: node.value != null });
}
return snap;
});
// Static type propagation: infer each node's output type (scalar/array) to
// colour wires and surface type-incompatible wirings as node errors. Mirrors
// the backend dsp.OpOutputType rules (see lib/synthTypes.ts).
@@ -754,6 +938,20 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
}
}
// Port colour class. Output ports take the node's own inferred output type;
// input ports take what the op accepts (gray when it accepts either type).
function portTypeClass(t: SynthType | 'any'): string {
if (t === 'array') return ' flow-port-type-array';
if (t === 'scalar') return ' flow-port-type-scalar';
return ' flow-port-type-any';
}
function inPortTypeClass(n: GNode): string {
return portTypeClass(n.kind === 'op' ? portAccept(n.op ?? '') : 'any');
}
function outPortTypeClass(n: GNode): string {
return portTypeClass(nodeTypes.get(n.id) ?? 'unknown');
}
function nodeClass(n: GNode): string {
const cat = n.kind === 'source' ? 'trigger' : n.kind === 'output' ? 'action' : 'flow';
const err = nodeErrors.has(n.id) ? ' flow-node-error' : '';
@@ -780,6 +978,16 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
<div class="flow-palette-toolbar">
<button class="toolbar-btn" disabled={!canUndo} onClick={undo} title="Undo (Ctrl+Z)"></button>
<button class="toolbar-btn" disabled={!canRedo} onClick={redo} title="Redo (Ctrl+Shift+Z)"></button>
<button class="toolbar-btn" disabled={selSet.size < 1} onClick={groupSelection}
title="Group selected nodes (G) — Shift+click nodes to multi-select"></button>
<button class={`toolbar-btn${debug ? ' flow-debug-on' : ''}`} onClick={() => setDebug(d => !d)}
title="Live debug — evaluate the graph and show each node's value"></button>
</div>
<div class="flow-palette-toolbar">
<button class="toolbar-btn" onClick={zoomOut} title="Zoom out"></button>
<button class="toolbar-btn flow-zoom-pct" onClick={zoomHome} title="Reset zoom (100%)">{Math.round(zoom * 100)}%</button>
<button class="toolbar-btn" onClick={zoomIn} title="Zoom in"></button>
<button class="toolbar-btn" onClick={zoomFit} title="Fit graph to view"></button>
</div>
<div class="flow-palette-title">Inputs</div>
<button class="flow-palette-btn flow-palette-trigger" onClick={addSource}>+ Signal</button>
@@ -798,15 +1006,59 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
</div>
<div class="flow-canvas" ref={canvasRef}
onMouseDown={() => { setSelected(null); setSelectedWire(null); }}
onMouseDown={() => { setSelected(null); setSelectedWire(null); setSelectedGroup(null); setSelSet(new Set()); }}
onDragOver={onCanvasDragOver}
onDrop={onCanvasDrop}>
<div class="flow-canvas-inner">
<div class="flow-canvas-inner" style={innerStyle}>
<div class="flow-canvas-zoom" style={zoomStyle}>
{/* Group frames (behind nodes). Collapsed groups render a compact box. */}
{groupGeom.map(({ g, bounds, box }) => {
if (!bounds) return null;
const gsel = selectedGroup === g.id;
if (g.collapsed && box) {
return (
<div key={g.id}
class={`flow-group-box${gsel ? ' flow-group-selected' : ''}`}
style={`left:${box.x}px; top:${box.y}px; width:${box.w}px; height:${box.h}px;`}
onMouseDown={(e) => { e.stopPropagation(); setSelectedGroup(g.id); setSelected(null); setSelectedWire(null); beginDrag(e, g.members); }}>
<div class="flow-group-header">
<button class="flow-group-caret" title="Expand"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); toggleCollapse(g.id); }}></button>
<input class="flow-group-label" value={g.label}
onMouseDown={(e) => e.stopPropagation()}
onInput={(e) => renameGroup(g.id, (e.target as HTMLInputElement).value)} />
</div>
<div class="flow-group-count hint">{g.members.length} node{g.members.length === 1 ? '' : 's'}</div>
</div>
);
}
return (
<div key={g.id}
class={`flow-group-frame${gsel ? ' flow-group-selected' : ''}`}
style={`left:${bounds.x}px; top:${bounds.y}px; width:${bounds.w}px; height:${bounds.h}px;`}
onMouseDown={(e) => { e.stopPropagation(); setSelectedGroup(g.id); setSelected(null); setSelectedWire(null); beginDrag(e, g.members); }}>
<div class="flow-group-header">
<button class="flow-group-caret" title="Collapse"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); toggleCollapse(g.id); }}></button>
<input class="flow-group-label" value={g.label}
onMouseDown={(e) => e.stopPropagation()}
onInput={(e) => renameGroup(g.id, (e.target as HTMLInputElement).value)} />
<button class="flow-group-del" title="Ungroup"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); ungroup(g.id); }}></button>
</div>
</div>
);
})}
<svg class="flow-wires">
{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 (
<path key={idx}
@@ -823,15 +1075,22 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
})()}
</svg>
{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 (
<div key={node.id}
class={nodeClass(node)}
class={`${nodeClass(node)}${selSet.has(node.id) && selSet.size > 1 ? ' flow-node-multi' : ''}${dbg ? ' ' + nodeDebugClass(dbg) : ''}${grouped}`}
title={nodeErrors.get(node.id) || undefined}
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 && (
<span class={`flow-node-badge${dbg.approx ? ' approx' : ''}`} title={badgeTitle(dbg)}>
{(dbg.approx ? '~' : '') + formatBadge(dbg)}
</span>
)}
<div class="flow-node-header">
<span class="flow-node-title">{node.kind === 'source' ? 'Signal' : node.kind === 'output' ? 'Output' : opLabel(node.op ?? '')}</span>
{node.kind !== 'output' && (
@@ -846,7 +1105,7 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
const label = portLabel(node, port);
return (
<Fragment key={port}>
<div class="flow-port flow-port-in" title={label || `Input ${port + 1}`}
<div class={`flow-port flow-port-in${inPortTypeClass(node)}`} title={label || `Input ${port + 1}`}
style={`top:${PORT_TOP + port * PORT_GAP - PORT_R}px; left:${-PORT_R}px;`}
onMouseDown={(e) => e.stopPropagation()}
onMouseUp={(e) => { e.stopPropagation(); finishWire(node, port); }} />
@@ -858,7 +1117,7 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
);
})}
{hasOutput(node.kind) && (
<div class="flow-port flow-port-out" title="Output"
<div class={`flow-port flow-port-out${outPortTypeClass(node)}`} title="Output"
style={`top:${PORT_TOP - PORT_R}px; right:${-PORT_R}px;`}
onMouseDown={(e) => startWire(e, node)} />
)}
@@ -867,6 +1126,7 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
})}
</div>
</div>
</div>
<div class="flow-inspector">
{!sel && (
+55 -27
View File
@@ -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,10 +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 || me.canAdmin;
function startResize(e: MouseEvent) {
e.preventDefault();
@@ -69,6 +74,15 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) {
return unsub;
}, []);
// Close the Tools dropdown on any outside click (the dropdown itself stops
// propagation so its own button/items don't self-close).
useEffect(() => {
if (!toolsOpen) return;
const close = () => setToolsOpen(false);
window.addEventListener('mousedown', close);
return () => window.removeEventListener('mousedown', close);
}, [toolsOpen]);
// When returning from edit mode, show the edited interface
useEffect(() => {
if (initialInterface) setCurrentInterface(initialInterface);
@@ -140,14 +154,6 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) {
)}
</div>
<div class="toolbar-right">
<div
class={`status-chip ${wsStatus}`}
title={me.user ? `Signed in as ${me.user}${writable ? '' : ' (read-only)'}` : undefined}
>
<span class="status-dot"></span>
{wsStatus === 'connected' && me.user ? `connected as ${me.user}` : wsStatus}
{me.user && !writable && ' (read-only)'}
</div>
<button
class={`toolbar-btn${showTimeNav ? ' toolbar-btn-active' : ''}`}
title="Toggle historical time navigation"
@@ -163,32 +169,32 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) {
📖
</button>
<ZoomControl />
{writable && me.canEditLogic && (
{hasTools && (
<div class="toolbar-dropdown" onMouseDown={(e) => e.stopPropagation()}>
<button
class="toolbar-btn"
onClick={() => setShowControlLogic(true)}
title="Open server-side control logic"
class={`toolbar-btn${toolsOpen ? ' toolbar-btn-active' : ''}`}
title="Advanced tools"
onClick={() => setToolsOpen(o => !o)}
>
Control logic
Tools
</button>
{toolsOpen && (
<div class="toolbar-dropdown-menu toolbar-dropdown-menu-right" onClick={() => setToolsOpen(false)}>
{writable && me.canEditLogic && (
<button class="ctx-item" onClick={() => setShowControlLogic(true)}> Control logic</button>
)}
{me.canViewAudit && (
<button
class="toolbar-btn"
onClick={() => setShowAudit(true)}
title="View the audit log"
>
🛡 Audit
</button>
<button class="ctx-item" onClick={() => setShowAudit(true)}>🛡 Audit log</button>
)}
{writable && (
<button
class="toolbar-btn"
onClick={() => setShowConfig(true)}
title="Open configuration manager"
>
🗂 Config
</button>
<button class="ctx-item" onClick={() => setShowConfig(true)}>🗂 Config manager</button>
)}
{me.canAdmin && (
<button class="ctx-item" onClick={() => setShowAdmin(true)}>{'\u{1F464}\u{FE0E}'} Admin</button>
)}
</div>
)}
</div>
)}
{writable && (
<button
@@ -269,6 +275,25 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) {
</div>
</div>
<footer class="statusbar">
<div class="statusbar-left">
{currentInterface
? <span class="statusbar-iface" title={currentInterface.name}>{currentInterface.name}</span>
: <span class="statusbar-muted">No panel loaded</span>}
{!isLive && <span class="statusbar-hist" title="Viewing historical data"> history</span>}
</div>
<div class="statusbar-right">
<div
class={`status-chip ${wsStatus}`}
title={me.user ? `Signed in as ${me.user}${writable ? '' : ' (read-only)'}` : undefined}
>
<span class="status-dot"></span>
{wsStatus === 'connected' && me.user ? `connected as ${me.user}` : wsStatus}
{me.user && !writable && ' (read-only)'}
</div>
</div>
</footer>
{showHelp && (
<HelpModal initialSection={helpSection} onClose={() => setShowHelp(false)} />
)}
@@ -283,6 +308,9 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) {
{showConfig && (
<ConfigManager onClose={() => setShowConfig(false)} />
)}
{showAdmin && (
<AdminPane onClose={() => setShowAdmin(false)} />
)}
</div>
);
}
+2
View File
@@ -15,8 +15,10 @@ const WIDGET_TYPES = [
{ type: 'led', label: 'LED', desc: 'State indicator' },
{ type: 'multiled', label: 'Multi-LED', desc: 'Bitset indicator' },
{ type: 'setvalue', label: 'Set Value', desc: 'Input + write control' },
{ type: 'toggle', label: 'Toggle Switch', desc: 'On/off switch control' },
{ type: 'button', label: 'Button', desc: 'Send command on click' },
{ type: 'plot', label: 'Plot', desc: 'Time-series / charts' },
{ type: 'table', label: 'Table', desc: 'Multi-signal value table' },
{ type: 'textlabel', label: 'Label', desc: 'Static text label' },
{ type: 'image', label: 'Image', desc: 'Image from URL' },
{ type: 'link', label: 'Link', desc: 'Navigate to interface' },
+2 -1
View File
@@ -4,7 +4,7 @@ import type { Me, AccessLevel } from './types';
// Default identity used before /api/v1/me resolves (or if it fails): assume a
// 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<Me> {
groups: Array.isArray(data.groups) ? data.groups : [],
canEditLogic: data.canEditLogic !== false,
canViewAudit: data.canViewAudit !== false,
canAdmin: data.canAdmin !== false,
};
} catch {
return DEFAULT_ME;
+56
View File
@@ -0,0 +1,56 @@
import type { Widget } from './types';
// A widget is "inside" a container when its centre falls within the container's
// bounds. Containers are decorative frames that do not re-parent widgets, so
// membership is purely geometric.
export function centreInside(w: Widget, c: Widget): boolean {
const cx = w.x + w.w / 2, cy = w.y + w.h / 2;
return cx >= c.x && cx <= c.x + c.w && cy >= c.y && cy <= c.y + c.h;
}
// Expand a set of widget ids to also include every widget geometrically inside
// any container among them — transitively, so a moved container carries nested
// containers and their contents too. Used when starting a move (drag or arrow
// nudge) so dragging a container drags the widgets sitting on it. Membership is
// snapshotted by the caller at move start; widgets are never re-parented.
export function withContainedWidgets(ids: string[], widgets: Widget[]): string[] {
const byId = new Map(widgets.map(w => [w.id, w]));
const result = new Set(ids);
let changed = true;
while (changed) {
changed = false;
for (const id of [...result]) {
const c = byId.get(id);
if (!c || c.type !== 'container') continue;
for (const w of widgets) {
if (w.id !== c.id && !result.has(w.id) && centreInside(w, c)) {
result.add(w.id);
changed = true;
}
}
}
}
return [...result];
}
// The tab index a widget is assigned to (option 'tab', default 0).
export function widgetTab(w: Widget): number {
return parseInt(w.options['tab'] ?? '0', 10) || 0;
}
// Container panes operating in tabbed mode.
export function tabPanes(widgets: Widget[]): Widget[] {
return widgets.filter(w => w.type === 'container' && w.options['variant'] === 'tabs');
}
// The tabbed container that geometrically contains this widget, if any.
export function containingTabPane(w: Widget, widgets: Widget[]): Widget | null {
return tabPanes(widgets).find(c => c.id !== w.id && centreInside(w, c)) ?? null;
}
// Parse the comma-separated tab labels of a tabbed container (never empty).
export function tabLabels(c: Widget): string[] {
const t = (c.options['tabs'] ?? 'Tab 1,Tab 2')
.split(',').map(s => s.trim()).filter(s => s !== '');
return t.length ? t : ['Tab 1'];
}
+91
View File
@@ -0,0 +1,91 @@
// Shared live/debug helpers for the three visual node-graph editors (LogicEditor,
// ControlLogicEditor, SyntheticGraphEditor). When an editor's "Debug" toggle is on,
// the graph is evaluated online and each node shows a small value badge plus an
// "active" highlight, refreshed at human speed (~0.5 s). The per-node values come
// from different backends per editor (synthetic = server trace endpoint, panel =
// a client-side dry-run engine, control = a server WS push), but they all funnel
// into the same `DebugSnapshot` shape and the same rendering helpers below.
import { useState, useEffect, useRef } from 'preact/hooks';
/** Live state for a single node while debug mode is on. */
export interface NodeDebugState {
value?: any; // last computed value (scalar number, array, bool, string)
type?: 'scalar' | 'array';
approx?: boolean; // value is a rough single-shot estimate (stateful DSP nodes)
error?: string; // evaluation error for this node
active?: boolean; // node fired / produced a value this tick
}
/** Per-node debug state, keyed by node id. */
export type DebugSnapshot = Map<string, NodeDebugState>;
/** Round-trips a value into a compact label for the on-node badge. */
export function formatBadge(s: NodeDebugState | undefined): string {
if (!s) return '';
if (s.error) return '!';
const v = s.value;
if (v == null) return '—';
if (Array.isArray(v)) return `[${v.length}]`;
if (typeof v === 'number') {
if (!isFinite(v)) return String(v);
if (Number.isInteger(v)) return String(v);
return v.toPrecision(4).replace(/\.?0+$/, '');
}
if (typeof v === 'boolean') return v ? 'true' : 'false';
const str = String(v);
return str.length > 10 ? str.slice(0, 9) + '…' : str;
}
/** Full, untruncated value for the badge's `title` tooltip. */
export function badgeTitle(s: NodeDebugState | undefined): string {
if (!s) return '';
if (s.error) return s.error;
const v = s.value;
if (Array.isArray(v)) return `[${v.map(n => (typeof n === 'number' ? n.toPrecision(6) : String(n))).join(', ')}]`;
return v == null ? '' : String(v);
}
/** Extra class(es) for a node element given its current debug state. */
export function nodeDebugClass(s: NodeDebugState | undefined): string {
if (!s) return '';
if (s.error) return 'flow-node-debug-error';
if (s.active) return 'flow-node-active';
return '';
}
/**
* Polls `fetcher` every `intervalMs` while `enabled`, returning the latest
* snapshot (empty map when disabled). Used by editors whose backend is a
* request/response trace (synthetic) or any pull-based source. Overlapping
* polls are prevented; a poll that resolves after the hook is disabled/unmounted
* is discarded.
*/
export function useFlowDebug(
enabled: boolean,
fetcher: () => Promise<DebugSnapshot | null>,
intervalMs = 500,
): DebugSnapshot {
const [snap, setSnap] = useState<DebugSnapshot>(() => new Map());
const fetcherRef = useRef(fetcher);
fetcherRef.current = fetcher;
useEffect(() => {
if (!enabled) { setSnap(new Map()); return; }
let alive = true;
let inFlight = false;
const tick = async () => {
if (inFlight) return;
inFlight = true;
try {
const next = await fetcherRef.current();
if (alive && next) setSnap(next);
} catch { /* transient; next tick retries */ }
finally { inFlight = false; }
};
tick();
const h = setInterval(tick, intervalMs);
return () => { alive = false; clearInterval(h); };
}, [enabled, intervalMs]);
return snap;
}
+96
View File
@@ -0,0 +1,96 @@
// Shared pan/zoom helpers for the three visual node-graph editors (LogicEditor,
// ControlLogicEditor, SyntheticGraphEditor). Zoom is implemented as a CSS
// `transform: scale()` on an inner "zoom layer"; the outer `.flow-canvas-inner`
// is sized to the scaled dimensions so the scroll container reports the right
// scrollable area. Node coordinates stay in unscaled ("logical") space — the
// editors' `toCanvas` divides pointer offsets by the current zoom.
import { useState, useRef, useEffect, useCallback } from 'preact/hooks';
export const ZOOM_MIN = 0.3;
export const ZOOM_MAX = 2.5;
export const ZOOM_FACTOR = 1.25; // multiplier per zoom-in / -out step
export const clampZoom = (z: number) => Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, z));
export interface Box { x: number; y: number; w: number; h: number; }
/** Axis-aligned bounding box over a set of rectangles (null when empty). */
export function contentBounds(rects: Box[]): Box | null {
if (rects.length === 0) return null;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const r of rects) {
minX = Math.min(minX, r.x); minY = Math.min(minY, r.y);
maxX = Math.max(maxX, r.x + r.w); maxY = Math.max(maxY, r.y + r.h);
}
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
}
interface ZoomScroll { zoom: number; scrollX: number; scrollY: number; }
/** New zoom + scroll that keeps the viewport centre fixed while scaling. */
export function zoomAnchored(z0: number, factor: number, scrollX: number, scrollY: number, vw: number, vh: number): ZoomScroll {
const z1 = clampZoom(z0 * factor);
const cx = (scrollX + vw / 2) / z0;
const cy = (scrollY + vh / 2) / z0;
return { zoom: z1, scrollX: Math.max(0, cx * z1 - vw / 2), scrollY: Math.max(0, cy * z1 - vh / 2) };
}
/** Zoom + scroll so `content` is centred and fits within the viewport. */
export function zoomToFit(content: Box, vw: number, vh: number, pad = 48): ZoomScroll {
const z = clampZoom(Math.min(vw / (content.w + 2 * pad), vh / (content.h + 2 * pad)));
return {
zoom: z,
scrollX: Math.max(0, (content.x + content.w / 2) * z - vw / 2),
scrollY: Math.max(0, (content.y + content.h / 2) * z - vh / 2),
};
}
/**
* Zoom controller shared by all three editors. `zoomRef` is kept in sync with
* the live zoom so each editor's `toCanvas` can read it without stale closures.
* `getRects` returns the current node rectangles (logical coords) for fit.
*/
export function useFlowZoom(
canvasRef: { current: HTMLDivElement | null },
zoomRef: { current: number },
baseW: number,
baseH: number,
getRects: () => Box[],
) {
const [zoom, setZoom] = useState(1);
zoomRef.current = zoom;
// Scroll target applied after the zoomed layout commits.
const pending = useRef<{ x: number; y: number } | null>(null);
useEffect(() => {
const el = canvasRef.current;
if (el && pending.current) {
el.scrollLeft = pending.current.x;
el.scrollTop = pending.current.y;
pending.current = null;
}
}, [zoom]);
const apply = (r: ZoomScroll) => { pending.current = { x: r.scrollX, y: r.scrollY }; setZoom(r.zoom); };
const zoomIn = useCallback(() => {
const el = canvasRef.current; if (!el) return;
apply(zoomAnchored(zoomRef.current, ZOOM_FACTOR, el.scrollLeft, el.scrollTop, el.clientWidth, el.clientHeight));
}, []);
const zoomOut = useCallback(() => {
const el = canvasRef.current; if (!el) return;
apply(zoomAnchored(zoomRef.current, 1 / ZOOM_FACTOR, el.scrollLeft, el.scrollTop, el.clientWidth, el.clientHeight));
}, []);
const zoomHome = useCallback(() => { apply({ zoom: 1, scrollX: 0, scrollY: 0 }); }, []);
const zoomFit = useCallback(() => {
const el = canvasRef.current; if (!el) return;
const b = contentBounds(getRects());
if (!b) { apply({ zoom: 1, scrollX: 0, scrollY: 0 }); return; }
apply(zoomToFit(b, el.clientWidth, el.clientHeight));
}, [getRects]);
// Outer sizer reserves the scaled scroll area; inner layer carries the scale.
const innerStyle = `width:${baseW * zoom}px; height:${baseH * zoom}px;`;
const zoomStyle = `width:${baseW}px; height:${baseH}px; transform: scale(${zoom}); transform-origin: 0 0;`;
return { zoom, zoomIn, zoomOut, zoomHome, zoomFit, innerStyle, zoomStyle };
}
+71 -18
View File
@@ -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<string, LogicNode>();
// Outgoing wires grouped by source node id.
@@ -227,6 +227,37 @@ class LogicEngine {
private lastFire = new Map<string, number>();
private cleanups: Array<() => void> = [];
// Dry-run / debug mode: when true the engine performs no external side effects
// (signal writes, config mutations, CSV exports, widget commands, dialogs) and
// instead records each node's last value/firing time so an editor overlay can
// visualise the flow without touching production. Used by a throwaway engine
// instance spun up by the Logic editor; the view-mode singleton leaves it off.
private dryRun = false;
private debugStates = new Map<string, { value: any; ts: number }>();
/** Enable/disable dry-run mode. Must be set before load(). */
setDryRun(on: boolean): void { this.dryRun = on; }
// Record that a node fired this tick, optionally capturing its computed value.
private markActive(id: string, value?: any): void {
if (!this.dryRun) return;
const prev = this.debugStates.get(id);
this.debugStates.set(id, { value: value !== undefined ? value : prev?.value, ts: Date.now() });
}
/** Per-node debug snapshot: a node is "active" if it fired within ~0.8 s. */
getDebug(): Map<string, { value: any; active: boolean }> {
const now = Date.now();
const out = new Map<string, { value: any; active: boolean }>();
for (const [id, s] of this.debugStates) out.set(id, { value: s.value, active: now - s.ts < 800 });
return out;
}
// Suppress signal writes in dry-run; otherwise forward to the live client.
private dryWrite(ref: SignalRef, val: any): void {
if (!this.dryRun) wsClient.write(ref, val);
}
// Resolve an expression reference. Two built-in system signals are served
// synthetically (never subscribed): {sys:time} = current epoch seconds,
// {sys:dt} = seconds since the firing trigger last fired. Everything else
@@ -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',
+71
View File
@@ -0,0 +1,71 @@
// Node grouping & collapsing — shared model + geometry used by all three visual
// node editors (LogicEditor, ControlLogicEditor, SyntheticGraphEditor).
//
// A group is purely a cosmetic, editor-side annotation: it references member
// node ids, carries an optional label, and can be collapsed to hide its members
// behind a compact box. Groups never change how a graph evaluates — the backends
// store and round-trip them but ignore them when compiling/running the graph.
export interface NodeGroup {
id: string;
label: string;
members: string[]; // ids of member nodes
collapsed: boolean;
}
export interface Rect { x: number; y: number; w: number; h: number; }
export function genGroupId(): string {
return 'grp_' + Math.random().toString(36).slice(2, 9);
}
// The group (if any) that a node belongs to. A node lives in at most one group.
export function groupContaining(
groups: NodeGroup[] | undefined,
nodeId: string,
): NodeGroup | undefined {
return (groups ?? []).find(g => g.members.includes(nodeId));
}
// Drop ids that no longer exist (deleted nodes) and discard groups left empty.
export function pruneGroups(
groups: NodeGroup[] | undefined,
existingIds: Set<string>,
): NodeGroup[] {
return (groups ?? [])
.map(g => ({ ...g, members: g.members.filter(id => existingIds.has(id)) }))
.filter(g => g.members.length > 0);
}
// Axis-aligned bounding box around the member node rects, expanded by `pad` on
// every side and by `header` extra pixels on top (room for the title bar).
// Returns null when no member resolves to a rect.
export function groupBounds(
members: string[],
rectOf: (id: string) => Rect | null,
pad: number,
header: number,
): Rect | null {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const id of members) {
const r = rectOf(id);
if (!r) continue;
if (r.x < minX) minX = r.x;
if (r.y < minY) minY = r.y;
if (r.x + r.w > maxX) maxX = r.x + r.w;
if (r.y + r.h > maxY) maxY = r.y + r.h;
}
if (!isFinite(minX)) return null;
return {
x: minX - pad,
y: minY - pad - header,
w: (maxX - minX) + pad * 2,
h: (maxY - minY) + pad * 2 + header,
};
}
// The compact box shown in place of a collapsed group, anchored at the group's
// top-left corner.
export function collapsedRect(bounds: Rect, width: number, height: number): Rect {
return { x: bounds.x, y: bounds.y, w: width, h: height };
}
+37
View File
@@ -0,0 +1,37 @@
// Cross-plot synchronisation for time-series plot widgets.
//
// Two things are shared across all *linked* timeseries plots:
// 1. The uPlot cursor crosshair, via uPlot's native cursor.sync keyed on
// PLOT_SYNC_KEY (hovering one plot draws the matching-time crosshair on the
// others). This works in both live and historical mode.
// 2. The visible x (time) range, for zoom/pan sync in historical mode — when a
// user zooms one plot, the same [min,max] is applied to the others. Live
// mode keeps its own rolling window (auto-scroll), so range sync is only
// brokered here for historical views.
//
// A plot can opt out of both via its toolbar "link" toggle.
export const PLOT_SYNC_KEY = 'uopi-plots';
export interface XRange {
min: number;
max: number;
}
type RangeListener = (r: XRange) => void;
const rangeListeners = new Map<string, RangeListener>();
export function subscribeXRange(id: string, fn: RangeListener): () => void {
rangeListeners.set(id, fn);
return () => {
rangeListeners.delete(id);
};
}
// Broadcast a new visible x-range to every linked plot except the originator.
export function broadcastXRange(origin: string, r: XRange) {
rangeListeners.forEach((fn, id) => {
if (id !== origin) fn(r);
});
}
+19
View File
@@ -39,6 +39,25 @@ export function opOutputType(op: string, inputs: SynthType[]): { type: SynthType
return { type: 'scalar' };
}
// portAccept reports the data type an op's input ports accept:
// 'array' — reductions & array producers require an array input
// 'scalar' — stateful filters & lua require a scalar input
// 'any' — elementwise stateless ops accept either type
// Used to colour input ports and to reject definitely-incompatible wirings.
export function portAccept(op: string): SynthType | 'any' {
if (REDUCTION_OPS.has(op) || ARRAY_PRODUCER_OPS.has(op)) return 'array';
if (SCALAR_ONLY_OPS.has(op)) return 'scalar';
return 'any';
}
// typesCompatible reports whether a wire carrying `from` may terminate on a port
// accepting `accept`. 'unknown' is always allowed (runtime typing is final), as
// is an 'any' port — only a definite scalar↔array mismatch is rejected.
export function typesCompatible(from: SynthType, accept: SynthType | 'any'): boolean {
if (accept === 'any' || from === 'unknown') return true;
return from === accept;
}
// inferNodeTypes walks the DAG in dependency order and assigns each node an
// output type. Source node types come from `sourceType` (resolved from upstream
// signal metadata); unresolved sources default to 'unknown'. `errors` collects
+11
View File
@@ -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 {
+65
View File
@@ -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<string, Set<SubscriberEntry>>();
// pending history request callbacks keyed by "ds\0name"
private histCallbacks = new Map<string, Array<(pts: HistoryPoint[]) => void>>();
// single control-logic debug listener (the editor currently watching)
private debugListener: ((ev: DebugNodeEvent) => void) | null = null;
connect(url: string): void {
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
+26 -2
View File
@@ -27,6 +27,14 @@ function serializeLogic(graph: LogicGraph, lines: string[]): void {
? ` port="${xmlEsc(wire.fromPort)}"` : '';
lines.push(` <wire from="${xmlEsc(wire.from)}"${portAttr} to="${xmlEsc(wire.to)}"/>`);
}
for (const group of graph.groups ?? []) {
const collAttr = group.collapsed ? ` collapsed="1"` : '';
lines.push(` <group id="${xmlEsc(group.id)}" label="${xmlEsc(group.label)}"${collAttr}>`);
for (const member of group.members) {
lines.push(` <member id="${xmlEsc(member)}"/>`);
}
lines.push(` </group>`);
}
lines.push(` </logic>`);
}
@@ -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<LogicGraph['groups']> = [];
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<string, string> = {};
@@ -146,7 +170,7 @@ function parseLogic(logicEl: Element): LogicGraph {
}
}
return { nodes, wires };
return { nodes, wires, ...(groups.length ? { groups } : {}) };
}
export function parseInterface(xml: string): Interface {
+720 -3
View File
@@ -197,6 +197,27 @@ body {
min-height: 0;
}
/* ── Bottom status bar (connection chip + panel context) ─────────────────── */
.statusbar {
display: flex;
align-items: center;
justify-content: space-between;
height: 1.6rem;
padding: 0 0.75rem;
background: #141925;
border-top: 1px solid #2d3748;
flex-shrink: 0;
gap: 1rem;
font-size: 0.72rem;
color: #94a3b8;
}
.statusbar-left,
.statusbar-right { display: flex; align-items: center; gap: 0.6rem; min-width: 0; }
.statusbar-iface { color: #cbd5e1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.statusbar-muted { color: #64748b; }
.statusbar-hist { color: #fbbf24; }
.statusbar .status-chip { padding: 0.05rem 0.5rem; font-size: 0.7rem; }
/* ── Panel resize handle ─────────────────────────────────────────────────── */
.panel-resize-handle {
@@ -386,7 +407,9 @@ body {
.canvas-view-bare .led-widget,
.canvas-view-bare .multiled-widget,
.canvas-view-bare .setvalue,
.canvas-view-bare .plot-widget {
.canvas-view-bare .toggle-widget,
.canvas-view-bare .plot-widget,
.canvas-view-bare .table-widget {
background: transparent;
border-color: transparent;
box-shadow: none;
@@ -435,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 {
@@ -945,6 +1140,74 @@ body {
}
.sv-btn:disabled:hover { background: #2563eb; }
/* ── Toggle switch widget ──────────────────────────────────────────────── */
.toggle-widget {
position: absolute;
display: flex;
align-items: center;
gap: 8px;
padding: 0 8px;
background: #1a1f2e;
border: 1px solid #2d3748;
border-radius: 6px;
box-sizing: border-box;
overflow: hidden;
font-family: system-ui, sans-serif;
white-space: nowrap;
}
.toggle-label {
color: #94a3b8;
font-size: 0.8rem;
flex-shrink: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.toggle-switch {
display: flex;
align-items: center;
gap: 7px;
margin-left: auto;
background: none;
border: none;
padding: 0;
cursor: pointer;
flex-shrink: 0;
}
.toggle-track {
position: relative;
width: 34px;
height: 18px;
border-radius: 9999px;
background: #3d4f6e;
transition: background 0.15s;
flex-shrink: 0;
}
.toggle-knob {
position: absolute;
top: 2px;
left: 2px;
width: 14px;
height: 14px;
border-radius: 50%;
background: #e2e8f0;
transition: transform 0.15s;
}
.toggle-on .toggle-track { background: #22c55e; }
.toggle-on .toggle-knob { transform: translateX(16px); }
.toggle-unknown .toggle-track { background: #4b5563; }
.toggle-state {
font-size: 0.72rem;
font-weight: 600;
color: #94a3b8;
min-width: 1.6rem;
text-align: left;
}
.toggle-on .toggle-state { color: #86efac; }
.toggle-switch:disabled {
cursor: not-allowed;
opacity: 0.5;
}
/* ── Button widget ─────────────────────────────────────────────────────── */
.button-widget {
@@ -1066,6 +1329,85 @@ body {
overflow: visible;
}
/* ── Plot hover toolbar (pause / window / clear / screenshot) ─────────────── */
.plot-toolbar {
position: absolute;
top: 4px;
right: 4px;
z-index: 12;
display: flex;
align-items: center;
gap: 3px;
padding: 2px 3px;
background: rgba(15, 17, 23, 0.85);
border: 1px solid #2d3748;
border-radius: 5px;
opacity: 0;
transition: opacity 0.12s;
pointer-events: none;
}
.plot-widget:hover .plot-toolbar { opacity: 1; pointer-events: auto; }
.plot-tb-btn {
background: none;
border: none;
color: #cbd5e1;
font-size: 0.8rem;
line-height: 1;
padding: 2px 5px;
border-radius: 4px;
cursor: pointer;
}
.plot-tb-btn:hover { background: #2d3748; }
.plot-tb-active { background: #2563eb; color: #fff; }
.plot-tb-active:hover { background: #1d4ed8; }
.plot-tb-select {
background: #0f1117;
border: 1px solid #3d4f6e;
border-radius: 4px;
color: #e2e8f0;
font-size: 0.72rem;
padding: 1px 3px;
cursor: pointer;
}
.plot-tb-select:focus { outline: none; border-color: #4a9eff; }
/* ── Plot measurement readout (cursor A/B + Δt + per-series Δ) ─────────────── */
.measure-readout {
position: absolute;
top: 30px;
left: 4px;
z-index: 12;
max-width: 70%;
padding: 4px 6px;
background: rgba(15, 17, 23, 0.9);
border: 1px solid #2d3748;
border-radius: 5px;
font-size: 0.7rem;
line-height: 1.35;
color: #cbd5e1;
pointer-events: auto;
}
.measure-row {
display: flex;
gap: 8px;
justify-content: space-between;
white-space: nowrap;
}
.measure-head {
font-weight: 600;
color: #e2e8f0;
border-bottom: 1px solid #2d3748;
margin-bottom: 2px;
padding-bottom: 2px;
}
.measure-name {
overflow: hidden;
text-overflow: ellipsis;
max-width: 9rem;
}
.measure-vals { color: #e2e8f0; }
.measure-delta { color: #94a3b8; }
/* uPlot dark overrides */
.chart-area .u-wrap { background: transparent; }
.chart-area .u-legend { color: #94a3b8; font-size: 0.75rem; }
@@ -1239,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;
@@ -1290,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;
@@ -1330,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;
@@ -1370,6 +1826,14 @@ body {
.flow-port-body { border-color: #10b981; }
.flow-port-else { border-color: #ef4444; }
/* Synthetic-editor port data-type colouring (scoped to .synth-graph so the
Logic / ControlLogic editors that share .flow-port are unaffected). An 'any'
port (accepts either type) keeps the default slate; scalar is blue, array
purple mirroring the wire palette so a port previews what it carries/accepts. */
.synth-graph .flow-port-type-scalar { border-color: #3b82f6; }
.synth-graph .flow-port-type-array { border-color: #a855f7; }
.synth-graph .flow-port-type-any { border-color: #64748b; }
.flow-port-label {
position: absolute;
right: 10px;
@@ -2397,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 ─────────────────────────────────────────────────────── */
@@ -2578,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;
@@ -2713,6 +3245,10 @@ body {
padding: 4px 0;
}
/* Right-aligned variant (toolbar-right dropdowns expand leftward to stay
on-screen instead of overflowing the viewport edge). */
.toolbar-dropdown-menu-right { left: auto; right: 0; }
/* Align toolbar */
.align-toolbar {
display: flex;
@@ -3903,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;
@@ -3962,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;
+84
View File
@@ -0,0 +1,84 @@
import { h } from 'preact';
import type { Widget } from '../lib/types';
import { tabLabels } from '../lib/containers';
interface Props {
widget: Widget;
onContextMenu?: (e: MouseEvent) => void;
// View-mode collapse state: supplied (with a toggle) only by the live Canvas.
// In the editor these are undefined, so the pane always renders expanded.
collapsed?: boolean;
onToggleCollapse?: () => void;
// Tabbed-mode active tab + selector. Supplied by both Canvas (view) and
// EditCanvas (edit) so tabs can be switched in either mode.
activeTab?: number;
onSelectTab?: (i: number) => void;
}
const TITLE_H = 24;
// A purely decorative grouping frame placed behind other widgets on the
// free-form canvas (it does not re-parent them). Two variants:
// - 'pane': a titled / collapsible frame; collapsing in view mode hides the
// widgets geometrically inside it and shrinks the pane to its title bar.
// - 'tabs': a tab bar; only widgets assigned to the active tab are shown.
export default function Container({ widget, onContextMenu, collapsed, onToggleCollapse, activeTab, onSelectTab }: Props) {
const o = widget.options;
const variant = o['variant'] === 'tabs' ? 'tabs' : 'pane';
const showBg = o['bg'] !== 'false';
const accent = o['accent'] || '#3d4f6e';
if (variant === 'tabs') {
const tabs = tabLabels(widget);
const active = Math.min(Math.max(activeTab ?? 0, 0), tabs.length - 1);
return (
<div
class={`container-pane${showBg ? '' : ' container-pane-nobg'}`}
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;border-color:${accent};`}
onContextMenu={onContextMenu}
>
<div class="container-tab-bar" style={`border-color:${accent};`}>
{tabs.map((t, i) => (
<button
key={i}
class={`container-tab${i === active ? ' container-tab-active' : ''}`}
style={i === active ? `border-bottom-color:${accent};` : ''}
onClick={(e) => { e.stopPropagation(); onSelectTab && onSelectTab(i); }}
onMouseDown={(e) => e.stopPropagation()}
>{t}</button>
))}
</div>
</div>
);
}
const title = o['title'] ?? '';
const collapsible = o['collapsible'] === 'true';
// Collapse only applies in view mode (onToggleCollapse present) and when enabled.
const canCollapse = collapsible && !!onToggleCollapse;
const isCollapsed = canCollapse && !!collapsed;
const height = isCollapsed ? TITLE_H : widget.h;
const hasTitleBar = title !== '' || canCollapse;
return (
<div
class={`container-pane${showBg && !isCollapsed ? '' : ' container-pane-nobg'}`}
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${height}px;border-color:${accent};`}
onContextMenu={onContextMenu}
>
{hasTitleBar && (
<div class="container-pane-title" style={`border-color:${accent};`}>
{canCollapse && (
<button
class="container-collapse-btn"
title={isCollapsed ? 'Expand' : 'Collapse'}
onClick={(e) => { e.stopPropagation(); onToggleCollapse!(); }}
onMouseDown={(e) => e.stopPropagation()}
>{isCollapsed ? '▸' : '▾'}</button>
)}
<span class="container-pane-label">{title}</span>
</div>
)}
</div>
);
}
+285 -7
View File
@@ -7,6 +7,7 @@ import { getWidgetCmdStore } from '../lib/widgetCommands';
import { wsClient } from '../lib/ws';
import { formatValue } from '../lib/format';
import { fftMag, resampleUniform } from '../lib/fft';
import { PLOT_SYNC_KEY, subscribeXRange, broadcastXRange } from '../lib/plotSync';
import type { Widget, SignalRef } from '../lib/types';
interface Props {
@@ -20,6 +21,19 @@ interface SeriesBuffer {
values: number[];
}
interface MeasureRow {
name: string;
color: string;
va: number | null;
vb: number | null;
}
interface MeasureReadout {
a: number; // cursor A time (seconds)
b: number | null; // cursor B time (seconds), once placed
rows: MeasureRow[];
}
const RING_MAX = 200_000;
const COLORS = ['#4a9eff', '#22c55e', '#f59e0b', '#ef4444', '#a78bfa', '#f472b6', '#34d399', '#fb923c'];
@@ -47,6 +61,39 @@ function windowedSlice(buf: SeriesBuffer, windowSec: number) {
return { ts: buf.timestamps.slice(start), vals: buf.values.slice(start) };
}
// Compact display of a measured value (null → em-dash).
function fmtNum(v: number | null): string {
if (v == null || !isFinite(v)) return '—';
const a = Math.abs(v);
if (a !== 0 && (a < 1e-3 || a >= 1e6)) return v.toExponential(3);
return v.toFixed(Math.abs(v) >= 100 ? 1 : 3);
}
// Human-readable Δt between two cursors (seconds), with rate where useful.
function formatDt(dt: number): string {
const a = Math.abs(dt);
if (a < 1) return `${(dt * 1000).toFixed(1)} ms`;
if (a < 60) return `${dt.toFixed(3)} s (${(1 / dt).toFixed(2)} Hz)`;
if (a < 3600) return `${(dt / 60).toFixed(2)} min`;
return `${(dt / 3600).toFixed(2)} h`;
}
// Step-hold value of a series at time t (seconds): the most recent sample whose
// timestamp is <= t. Used by the measurement cursors. Binary search over the
// (sorted) timestamp buffer.
function valueAt(buf: SeriesBuffer, t: number): number | null {
const ts = buf.timestamps;
if (ts.length === 0) return null;
let lo = 0, hi = ts.length - 1, res = -1;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
if (ts[mid] <= t) { res = mid; lo = mid + 1; }
else hi = mid - 1;
}
if (res === -1) return null; // t is before the first sample
return buf.values[res];
}
function buildHistogram(allVals: number[][]): { labels: string[]; counts: number[][] } {
const BUCKETS = 20;
let lo = Infinity, hi = -Infinity;
@@ -67,6 +114,108 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
const chartRef = useRef<HTMLDivElement>(null);
const [histStatus, setHistStatus] = useState<'idle' | 'loading' | 'empty' | 'error'>('idle');
// Live chart instances + sample buffers held in refs so the hover toolbar can
// pause/clear/screenshot without tearing down and recreating the chart. The
// buffers persist across effect re-runs (plot-type / window / legend changes),
// keyed by widget+signals+time-range so only a genuine data-source change
// resets them.
const uplotRef = useRef<uPlot | null>(null);
const echartRef = useRef<echarts.EChartsType | null>(null);
const buffersRef = useRef<{ key: string; bufs: SeriesBuffer[]; waves: number[][] } | null>(null);
// Toolbar state: local pause (OR'd with panel-logic pause) and a view-time
// rolling-window override (null = use the configured timeWindow option).
const [paused, setPaused] = useState(false);
const pausedRef = useRef(false);
useEffect(() => { pausedRef.current = paused; }, [paused]);
const [winOverride, setWinOverride] = useState<number | null>(null);
// Cross-plot link: when on, this plot joins the shared cursor + x-range sync
// group (see lib/plotSync). Toggling re-inits the chart (cursor.sync is an
// init-time uPlot option); buffers persist via buffersRef.
const [linked, setLinked] = useState(true);
// Measurement cursors: marksRef holds two x-times (A/B) on the time axis; the
// readout panel shows Δt plus each series' value at A, B and ΔV. Toggling
// measure mode does NOT re-init the chart — the draw hook and click handler
// read live refs and a redraw() is enough.
const [measureOn, setMeasureOn] = useState(false);
const measureOnRef = useRef(false);
const marksRef = useRef<{ a: number | null; b: number | null }>({ a: null, b: null });
const [readout, setReadout] = useState<MeasureReadout | null>(null);
// Guards a feedback loop when applying a synced x-range back into uPlot.
const applyingSyncRef = useRef(false);
// Recompute the measurement readout from the current marks + buffers.
function refreshReadout() {
const { a, b } = marksRef.current;
const b0 = buffersRef.current?.bufs;
if (a == null || !b0) { setReadout(null); return; }
const rows: MeasureRow[] = widget.signals.map((s, i) => ({
name: s.name,
color: s.color ?? COLORS[i % COLORS.length],
va: b0[i] ? valueAt(b0[i], a) : null,
vb: b != null && b0[i] ? valueAt(b0[i], b) : null,
}));
setReadout({ a, b, rows });
}
// Place / move measurement cursors on click while in measure mode.
function onMeasureClick() {
const u = uplotRef.current;
const left = u ? (u as any).cursor?.left : null;
if (!measureOnRef.current || !u || left == null || left < 0) return;
const val = (u as any).posToVal(left, 'x');
const m = marksRef.current;
// 1st click (or restart after both set) → A; 2nd click → B.
if (m.a == null || m.b != null) { m.a = val; m.b = null; }
else m.b = val;
refreshReadout();
(u as any).redraw();
}
function toggleMeasure() {
const on = !measureOnRef.current;
measureOnRef.current = on;
setMeasureOn(on);
if (!on) { marksRef.current = { a: null, b: null }; setReadout(null); }
(uplotRef.current as any)?.redraw();
}
// ── Toolbar handlers (operate on the live ref instances) ──────────────────
function clearPlot() {
const b = buffersRef.current;
if (!b) return;
b.bufs.forEach(buf => { buf.timestamps.length = 0; buf.values.length = 0; });
b.waves.forEach(w => { w.length = 0; });
uplotRef.current?.setData([[], ...b.bufs.map(() => [])] as uPlot.AlignedData);
}
function screenshot() {
let dataUrl: string | undefined;
const canvas = chartRef.current?.querySelector('canvas');
if (uplotRef.current && canvas) {
dataUrl = canvas.toDataURL('image/png');
} else if (echartRef.current) {
dataUrl = (echartRef.current as any).getDataURL({ pixelRatio: 2, backgroundColor: '#1a1f2e' });
}
if (!dataUrl) return;
const name = (widget.signals[0]?.name ?? 'plot').replace(/[^\w.-]+/g, '_');
const a = document.createElement('a');
a.href = dataUrl;
a.download = `${name}-${new Date().toISOString().replace(/[:.]/g, '-')}.png`;
a.click();
}
// Rolling-window presets (seconds); null = use the widget's configured window.
const WINDOW_PRESETS: { label: string; val: number | null }[] = [
{ label: 'Auto', val: null },
{ label: '15s', val: 15 },
{ label: '30s', val: 30 },
{ label: '1m', val: 60 },
{ label: '5m', val: 300 },
{ label: '15m', val: 900 },
{ label: '1h', val: 3600 },
];
// Stable primitive deps so the effect re-runs when plotType or signals change.
const timeRangeKey = timeRange ? `${timeRange.start}|${timeRange.end}` : 'live';
const plotType = widget.options['plotType'] ?? 'timeseries';
@@ -77,15 +226,26 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
if (!chartRef.current) return;
const signals = widget.signals;
const timeWindow = parseFloat(widget.options['timeWindow'] ?? '60');
const timeWindow = winOverride ?? parseFloat(widget.options['timeWindow'] ?? '60');
const yMin = widget.options['yMin'];
const yMax = widget.options['yMax'];
const legendPos = widget.options['legend'] ?? 'bottom';
const showLegend = legendPos !== 'none';
const buffers: SeriesBuffer[] = signals.map(() => ({ timestamps: [], values: [] }));
// Reuse persisted buffers across effect re-runs (plot-type / window / legend
// changes) so the toolbar and a window switch don't drop live history; reset
// only when the widget, its signals or the time-range change.
const bufKey = `${widget.id}|${signalsKey}|${timeRangeKey}`;
if (!buffersRef.current || buffersRef.current.key !== bufKey) {
buffersRef.current = {
key: bufKey,
bufs: signals.map(() => ({ timestamps: [], values: [] })),
waves: signals.map(() => []),
};
}
const buffers: SeriesBuffer[] = buffersRef.current.bufs;
// Latest waveform (array) value per signal — used only by plotType 'waveform'.
const waveforms: number[][] = signals.map(() => []);
const waveforms: number[][] = buffersRef.current.waves;
const unsubs: (() => void)[] = [];
const fmt = widget.options['format'] ?? '';
@@ -139,6 +299,29 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
] as uPlot.AlignedData;
}
// Draw the measurement cursor lines (A=amber, B=green) onto the uPlot canvas
// so they track the data on zoom / pan / resize. Runs in the 'draw' hook.
function drawMeasureMarks(u: uPlot) {
const { a, b } = marksRef.current;
if (a == null && b == null) return;
const ctx = (u as any).ctx as CanvasRenderingContext2D;
const bb = (u as any).bbox as { top: number; height: number };
ctx.save();
ctx.lineWidth = 1;
ctx.setLineDash([4, 3]);
const line = (val: number, color: string) => {
const x = (u as any).valToPos(val, 'x', true);
ctx.strokeStyle = color;
ctx.beginPath();
ctx.moveTo(x, bb.top);
ctx.lineTo(x, bb.top + bb.height);
ctx.stroke();
};
if (a != null) line(a, '#f59e0b');
if (b != null) line(b, '#22c55e');
ctx.restore();
}
function makeUplotOpts(): uPlot.Options {
const yAxis: uPlot.Axis = {
stroke: '#94a3b8',
@@ -194,6 +377,21 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
},
y: { ...scaleY, range: scaleYRange },
},
// Cross-plot crosshair sync (x only) for all linked plots.
cursor: linked ? { sync: { key: PLOT_SYNC_KEY, scales: ['x', null] } } : {},
hooks: {
draw: [drawMeasureMarks],
// Broadcast zoom/pan to other linked plots (historical mode only — live
// mode auto-scrolls its own rolling window). Guard against the echo of
// an applied sync to avoid a feedback loop.
setScale: [(u: uPlot, key: string) => {
if (key !== 'x' || !linked || !timeRange || applyingSyncRef.current) return;
const sc = (u as any).scales.x as { min: number | null; max: number | null };
if (sc.min != null && sc.max != null) {
broadcastXRange(widget.id, { min: sc.min, max: sc.max });
}
}],
},
};
}
@@ -373,6 +571,24 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
if (plotType !== 'timeseries') {
echart = echarts.init(el);
echart.setOption(echartsOption());
echartRef.current = echart;
}
// Wire a freshly-created uPlot into the refs + measurement click handler.
function mountUplot(u: uPlot) {
uplotRef.current = u;
(u as any).over.addEventListener('click', onMeasureClick);
}
// Apply a synced x-range pushed from a peer plot (historical zoom/pan sync).
if (linked && plotType === 'timeseries') {
unsubs.push(subscribeXRange(widget.id, r => {
const u = uplotRef.current;
if (!u || !timeRange) return;
applyingSyncRef.current = true;
(u as any).setScale('x', { min: r.min, max: r.max });
applyingSyncRef.current = false;
}));
}
// ── Historical mode (timeseries only) ──────────────────────────────────
@@ -403,20 +619,25 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
setHistStatus(totalPoints === 0 ? 'empty' : 'idle');
// Create uPlot now — with real data — so the x-axis is correct from the start.
uplot = new uPlot(makeUplotOpts(), uplotData(), el);
mountUplot(uplot);
}).catch(() => {
if (!cancelled) setHistStatus('error');
});
return () => {
cancelled = true;
unsubs.forEach(u => u());
uplot?.destroy();
echart?.dispose();
uplotRef.current = null;
echartRef.current = null;
};
}
// ── Live mode: init uPlot immediately ──────────────────────────────────
if (plotType === 'timeseries') {
uplot = new uPlot(makeUplotOpts(), uplotData(), el);
mountUplot(uplot);
}
// ── Live streaming mode ────────────────────────────────────────────────
@@ -425,10 +646,10 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
// Honour action.widget logic commands: `paused` suspends live ingestion and
// a bumped `clearNonce` empties the buffers + redraws once.
const cmdStore = getWidgetCmdStore(widget.id);
let paused = cmdStore.get().paused;
let cmdPaused = cmdStore.get().paused;
let lastClear = cmdStore.get().clearNonce;
unsubs.push(cmdStore.subscribe(cmd => {
paused = cmd.paused;
cmdPaused = cmd.paused;
if (cmd.clearNonce !== lastClear) {
lastClear = cmd.clearNonce;
buffers.forEach(b => { b.timestamps.length = 0; b.values.length = 0; });
@@ -439,7 +660,7 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
signals.forEach((sig: SignalRef & { color?: string }, i) => {
const unsub = getSignalStore(sig).subscribe(sv => {
if (paused) return;
if (cmdPaused || pausedRef.current) return;
if (sv.value === null || sv.value === undefined || sv.ts === null) return;
const ts = new Date(sv.ts).getTime() / 1000;
@@ -487,8 +708,10 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
unsubs.forEach(u => u());
if (uplot) uplot.destroy();
if (echart) echart.dispose();
uplotRef.current = null;
echartRef.current = null;
};
}, [widget.id, timeRangeKey, plotType, signalsKey, widget.options['timeWindow'] ?? '60', widget.options['format'] ?? '', widget.options['yMin'] ?? '', widget.options['yMax'] ?? '', widget.options['legend'] ?? 'bottom']);
}, [widget.id, timeRangeKey, plotType, signalsKey, winOverride, linked, widget.options['timeWindow'] ?? '60', widget.options['format'] ?? '', widget.options['yMin'] ?? '', widget.options['yMax'] ?? '', widget.options['legend'] ?? 'bottom']);
return (
<div
@@ -497,6 +720,61 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
onContextMenu={onContextMenu}
>
<div class="plot-toolbar" onMouseDown={(e) => e.stopPropagation()} onContextMenu={(e) => e.stopPropagation()}>
<button
class={`plot-tb-btn${paused ? ' plot-tb-active' : ''}`}
title={paused ? 'Resume live updates' : 'Pause live updates'}
onClick={() => setPaused(p => !p)}
>{paused ? '▶' : '⏸'}</button>
{!timeRange && (plotType === 'timeseries' || plotType === 'fft' || plotType === 'logic') && (
<select
class="plot-tb-select"
title="Rolling time window"
value={winOverride === null ? 'auto' : String(winOverride)}
onChange={(e) => {
const v = (e.target as HTMLSelectElement).value;
setWinOverride(v === 'auto' ? null : parseFloat(v));
}}
>
{WINDOW_PRESETS.map(p => (
<option key={p.label} value={p.val === null ? 'auto' : String(p.val)}>{p.label}</option>
))}
</select>
)}
{plotType === 'timeseries' && (
<button
class={`plot-tb-btn${linked ? ' plot-tb-active' : ''}`}
title={linked ? 'Linked: cursor / zoom synced with other plots — click to unlink' : 'Unlinked — click to sync cursor / zoom with other plots'}
onClick={() => setLinked(v => !v)}
>{linked ? '🔗' : '⛓️‍💥'}</button>
)}
{plotType === 'timeseries' && (
<button
class={`plot-tb-btn${measureOn ? ' plot-tb-active' : ''}`}
title="Measure: click to place cursor A then B; read Δt and per-signal Δ"
onClick={toggleMeasure}
>📏</button>
)}
<button class="plot-tb-btn" title="Clear buffered data" onClick={clearPlot}>🗑</button>
<button class="plot-tb-btn" title="Save screenshot (PNG)" onClick={screenshot}>📷</button>
</div>
{readout && plotType === 'timeseries' && (
<div class="measure-readout" onMouseDown={(e) => e.stopPropagation()}>
<div class="measure-row measure-head">
<span>Δt</span>
<span>{readout.b != null ? formatDt(readout.b - readout.a) : '—'}</span>
</div>
{readout.rows.map(r => (
<div class="measure-row" key={r.name}>
<span class="measure-name" style={`color:${r.color}`}>{r.name}</span>
<span class="measure-vals">
{fmtNum(r.va)}
{readout.b != null && <span class="measure-delta"> {fmtNum(r.vb)} (Δ {fmtNum(r.va != null && r.vb != null ? r.vb - r.va : null)})</span>}
</span>
</div>
))}
</div>
)}
<div class="chart-area" ref={chartRef} style={`width:${widget.w}px;height:${widget.h}px;`} />
{histStatus === 'loading' && (
<div class="hist-overlay">Loading history</div>
+133
View File
@@ -0,0 +1,133 @@
import { h } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import { getSignalStore, getMetaStore } from '../lib/stores';
import { formatValue } from '../lib/format';
import type { Widget, SignalRef, SignalValue, SignalMeta } from '../lib/types';
const DEFAULT_VALUE: SignalValue = { value: null, quality: 'unknown', ts: null };
const COLS = ['name', 'value', 'unit', 'quality', 'time'] as const;
type Col = typeof COLS[number];
const COL_LABELS: Record<Col, string> = {
name: 'Name', value: 'Value', unit: 'Unit', quality: 'Status', time: 'Time',
};
function qualityColor(q: string): string {
switch (q) {
case 'good': return '#4ade80';
case 'uncertain': return '#fbbf24';
case 'bad': return '#f87171';
default: return '#6b7280';
}
}
function isCol(c: string): c is Col {
return (COLS as readonly string[]).includes(c);
}
interface RowProps {
sig: SignalRef;
label: string;
cols: Col[];
fmt: string;
unitOverride: string;
}
// One subscribed row. Each row owns its own value/meta subscription so the
// table can carry an arbitrary number of unrelated signals.
function TableRow({ sig, label, cols, fmt, unitOverride }: RowProps) {
const [sv, setSv] = useState<SignalValue>(DEFAULT_VALUE);
const [meta, setMeta] = useState<SignalMeta | null>(null);
useEffect(() => {
const uv = getSignalStore(sig).subscribe(setSv);
const um = getMetaStore(sig).subscribe(setMeta);
return () => { uv(); um(); };
}, [sig.ds, sig.name]);
function valStr(): string {
const v = sv.value;
if (v === null || v === undefined) return '---';
if (typeof v === 'number') return formatValue(v, fmt);
if (Array.isArray(v)) return `[${v.length}]`;
return String(v);
}
const unit = unitOverride === 'none' ? '' : (unitOverride || meta?.unit || '');
const time = sv.ts ? new Date(sv.ts).toLocaleTimeString() : '';
return (
<tr>
{cols.map(c => {
switch (c) {
case 'name':
return <td key={c} class="tw-name" title={`${sig.ds}:${sig.name}`}>{label}</td>;
case 'value':
return <td key={c} class="tw-value">{valStr()}</td>;
case 'unit':
return <td key={c} class="tw-unit">{unit}</td>;
case 'quality':
return (
<td key={c} class="tw-quality">
<span class="quality-dot" style={`background:${qualityColor(sv.quality)};`} title={`Quality: ${sv.quality}`} />
</td>
);
case 'time':
return <td key={c} class="tw-time">{time}</td>;
}
})}
</tr>
);
}
interface Props { widget: Widget; onContextMenu?: (e: MouseEvent) => void; }
// A tabular multi-signal readout: each bound signal is one row, with a
// configurable set of columns (name / value / unit / status / time).
export default function TableWidget({ widget, onContextMenu }: Props) {
const o = widget.options;
const parsed = (o['columns'] ?? 'name,value,unit')
.split(',').map(s => s.trim()).filter(isCol);
const cols: Col[] = parsed.length ? parsed : ['name', 'value'];
const showHeader = o['header'] !== 'false';
const title = o['title'] ?? '';
const fmt = o['format'] ?? '';
const unitOverride = o['unit'] ?? '';
const labels = (o['labels'] ?? '').split(',');
return (
<div
class="table-widget"
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
onContextMenu={onContextMenu}
>
{title && <div class="tw-title">{title}</div>}
<div class="tw-scroll">
<table class="tw-table">
{showHeader && (
<thead>
<tr>{cols.map(c => <th key={c} class={`tw-th-${c}`}>{COL_LABELS[c]}</th>)}</tr>
</thead>
)}
<tbody>
{widget.signals.length === 0 ? (
<tr><td class="tw-empty" colSpan={cols.length}>No signals drop signals here</td></tr>
) : (
widget.signals.map((s, i) => (
<TableRow
key={`${s.ds}:${s.name}`}
sig={s}
label={labels[i]?.trim() || s.name}
cols={cols}
fmt={fmt}
unitOverride={unitOverride}
/>
))
)}
</tbody>
</table>
</div>
</div>
);
}
+81
View File
@@ -0,0 +1,81 @@
import { h } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import { getSignalStore, getMetaStore } from '../lib/stores';
import { wsClient } from '../lib/ws';
import { useAuth, canWrite } from '../lib/auth';
import type { Widget, SignalValue, SignalMeta } from '../lib/types';
const DEFAULT_VALUE: SignalValue = { value: null, quality: 'unknown', ts: null };
interface Props { widget: Widget; onContextMenu?: (e: MouseEvent) => void; }
// Compare a live signal value against a configured on/off target. Numeric when
// both parse as finite numbers (tolerant compare), otherwise string equality.
function matches(value: unknown, target: string): boolean {
if (value === null || value === undefined) return false;
const vn = typeof value === 'number' ? value : parseFloat(String(value));
const tn = parseFloat(target);
if (Number.isFinite(vn) && Number.isFinite(tn)) return Math.abs(vn - tn) < 1e-9;
return String(value) === target;
}
export default function Toggle({ widget, onContextMenu }: Props) {
const sigRef = widget.signals[0];
const me = useAuth();
const [sv, setSv] = useState<SignalValue>(DEFAULT_VALUE);
const [meta, setMeta] = useState<SignalMeta | null>(null);
useEffect(() => {
if (!sigRef) return;
const unsubV = getSignalStore(sigRef).subscribe(setSv);
const unsubM = getMetaStore(sigRef).subscribe(setMeta);
return () => { unsubV(); unsubM(); };
}, [sigRef?.ds, sigRef?.name]);
const label = widget.options['label'] || sigRef?.name || '';
const onValue = widget.options['onValue'] ?? '1';
const offValue = widget.options['offValue'] ?? '0';
const onLabel = widget.options['onLabel'] || 'ON';
const offLabel = widget.options['offLabel'] || 'OFF';
const confirm = widget.options['confirm'] === 'true';
const isLocal = sigRef?.ds === 'local';
const writeAllowed = isLocal || (canWrite(me.level) && meta?.writable !== false);
// "On" when the value matches the configured on-target; unknown until a value
// arrives. A value matching neither target is treated as off.
const known = sv.value !== null && sv.value !== undefined;
const isOn = known && matches(sv.value, onValue);
function toggle() {
if (!sigRef || !writeAllowed) return;
const target = isOn ? offValue : onValue;
const numeric = Number.isFinite(parseFloat(target)) && meta?.type !== 'string';
const write = () => wsClient.write(sigRef, numeric ? parseFloat(target) : target);
if (confirm) {
if (window.confirm(`Set ${label} to ${isOn ? offLabel : onLabel}?`)) write();
} else {
write();
}
}
return (
<div
class="toggle-widget"
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
onContextMenu={onContextMenu}
>
{label && <span class="toggle-label">{label}</span>}
<button
type="button"
class={`toggle-switch${isOn ? ' toggle-on' : ''}${!known ? ' toggle-unknown' : ''}`}
disabled={!writeAllowed}
onClick={toggle}
title={writeAllowed ? '' : 'You do not have permission to write this signal'}
>
<span class="toggle-track"><span class="toggle-knob" /></span>
<span class="toggle-state">{!known ? '—' : isOn ? onLabel : offLabel}</span>
</button>
</div>
);
}
Binary file not shown.
Binary file not shown.
View File
@@ -0,0 +1,13 @@
{
"id": "new-config-set-snapshot-2026-06-21-23-53-12",
"name": "New config set snapshot 2026-06-21 23:53:12",
"owner": "martino",
"setId": "new-config-set",
"values": {
"param1": 0,
"param2": 42.60427155337898,
"param3": "System nominal",
"param4": "Idle"
},
"version": 1
}
@@ -0,0 +1,8 @@
{
"id": "new-rule",
"name": "New rule",
"owner": "martino",
"setId": "new-config-set",
"source": "// CUE rule: constrain or derive parameter values.\nparam1: number | *0 \nparam2: number\nif param1 != _|_ \u0026\u0026 param1 \u003c 10 {\n param2: number \u0026 \u003e 10\n} \nif param1 != _|_ \u0026\u0026 param1 \u003e= 10 {\n param2: number \u0026 \u003c 10\n}\n",
"version": 2
}
@@ -0,0 +1,8 @@
{
"id": "new-rule",
"name": "New rule",
"owner": "martino",
"setId": "new-config-set",
"source": "// CUE rule: constrain or derive parameter values.\n",
"version": 1
}
@@ -0,0 +1,7 @@
{
"id": "new-config-set-1782117410757",
"name": "New config set 2",
"owner": "martino",
"parameters": [],
"version": 2
}
@@ -0,0 +1,7 @@
{
"id": "new-config-set-1782117410757",
"name": "New config set",
"owner": "martino",
"parameters": [],
"version": 1
}
@@ -42,5 +42,6 @@
"type": "enum"
}
],
"version": 3
"tag": "restored from v3",
"version": 5
}
@@ -0,0 +1,46 @@
{
"id": "new-config-set",
"name": "New config set",
"owner": "martino",
"parameters": [
{
"ds": "epics",
"key": "param1",
"max": 1,
"min": 0,
"signal": "UOPI:INTERLOCK",
"type": "float64"
},
{
"ds": "epics",
"key": "param2",
"max": 100,
"min": 0,
"signal": "UOPI:FLOW",
"type": "float64",
"unit": "L/min"
},
{
"default": "CIAO",
"ds": "epics",
"key": "param3",
"signal": "UOPI:MESSAGE",
"subgroup": "Casa",
"type": "string"
},
{
"ds": "epics",
"enumValues": [
"Idle",
"Running",
"Fault",
"Maintenance"
],
"key": "param4",
"signal": "UOPI:MODE",
"subgroup": "Casa",
"type": "enum"
}
],
"version": 3
}
@@ -0,0 +1,44 @@
{
"id": "new-config-set",
"name": "New config set",
"owner": "martino",
"parameters": [
{
"ds": "epics",
"key": "param1",
"max": 1,
"min": 0,
"signal": "UOPI:INTERLOCK",
"type": "float64"
},
{
"ds": "epics",
"key": "param2",
"max": 100,
"min": 0,
"signal": "UOPI:FLOW",
"type": "float64",
"unit": "L/min"
},
{
"default": "CIAO",
"ds": "epics",
"key": "param3",
"signal": "UOPI:MESSAGE",
"type": "string"
},
{
"ds": "epics",
"enumValues": [
"Idle",
"Running",
"Fault",
"Maintenance"
],
"key": "param4",
"signal": "UOPI:MODE",
"type": "enum"
}
],
"version": 4
}
+56 -3
View File
@@ -2,8 +2,61 @@
{
"id": "cl-8c72c8bd37407d7f",
"name": "New control logic",
"enabled": false,
"nodes": [],
"wires": []
"enabled": true,
"version": 5,
"nodes": [
{
"id": "n_303sue0",
"kind": "trigger.cron",
"x": 117.24583129882814,
"y": 208.7500061035156,
"params": {
"spec": "2 11 * * 1-5"
}
},
{
"id": "n_u4u46iy",
"kind": "action.write",
"x": 357.2458312988281,
"y": 223.7500061035156,
"params": {
"expr": "1",
"target": "epics:UOPI:BEAM_ON"
}
},
{
"id": "n_djkxc14",
"kind": "action.delay",
"x": 634.2458312988281,
"y": 233.7500061035156,
"params": {
"ms": "5000"
}
},
{
"id": "n_aurpu41",
"kind": "action.write",
"x": 914.2458312988281,
"y": 195.75000610351563,
"params": {
"expr": "0",
"target": "epics:UOPI:BEAM_ON"
}
}
],
"wires": [
{
"from": "n_303sue0",
"to": "n_u4u46iy"
},
{
"from": "n_u4u46iy",
"to": "n_djkxc14"
},
{
"from": "n_djkxc14",
"to": "n_aurpu41"
}
]
}
]
@@ -0,0 +1,8 @@
{
"id": "cl-8c72c8bd37407d7f",
"name": "New control logic",
"enabled": false,
"version": 1,
"nodes": [],
"wires": []
}
@@ -0,0 +1,60 @@
{
"id": "cl-8c72c8bd37407d7f",
"name": "New control logic",
"enabled": false,
"version": 2,
"nodes": [
{
"id": "n_303sue0",
"kind": "trigger.cron",
"x": 118.24583129882814,
"y": 205.7500061035156,
"params": {
"spec": "0 11 * * 1-5"
}
},
{
"id": "n_u4u46iy",
"kind": "action.write",
"x": 357.2458312988281,
"y": 223.7500061035156,
"params": {
"expr": "1",
"target": "epics:UOPI:BEAM_ON"
}
},
{
"id": "n_djkxc14",
"kind": "action.delay",
"x": 634.2458312988281,
"y": 233.7500061035156,
"params": {
"ms": "5000"
}
},
{
"id": "n_aurpu41",
"kind": "action.write",
"x": 914.2458312988281,
"y": 195.75000610351563,
"params": {
"expr": "0",
"target": "epics:UOPI:BEAM_ON"
}
}
],
"wires": [
{
"from": "n_303sue0",
"to": "n_u4u46iy"
},
{
"from": "n_u4u46iy",
"to": "n_djkxc14"
},
{
"from": "n_djkxc14",
"to": "n_aurpu41"
}
]
}
@@ -0,0 +1,60 @@
{
"id": "cl-8c72c8bd37407d7f",
"name": "New control logic",
"enabled": true,
"version": 3,
"nodes": [
{
"id": "n_303sue0",
"kind": "trigger.cron",
"x": 117.24583129882814,
"y": 208.7500061035156,
"params": {
"spec": "0 11:01 * * 1-5"
}
},
{
"id": "n_u4u46iy",
"kind": "action.write",
"x": 357.2458312988281,
"y": 223.7500061035156,
"params": {
"expr": "1",
"target": "epics:UOPI:BEAM_ON"
}
},
{
"id": "n_djkxc14",
"kind": "action.delay",
"x": 634.2458312988281,
"y": 233.7500061035156,
"params": {
"ms": "5000"
}
},
{
"id": "n_aurpu41",
"kind": "action.write",
"x": 914.2458312988281,
"y": 195.75000610351563,
"params": {
"expr": "0",
"target": "epics:UOPI:BEAM_ON"
}
}
],
"wires": [
{
"from": "n_303sue0",
"to": "n_u4u46iy"
},
{
"from": "n_u4u46iy",
"to": "n_djkxc14"
},
{
"from": "n_djkxc14",
"to": "n_aurpu41"
}
]
}
@@ -0,0 +1,60 @@
{
"id": "cl-8c72c8bd37407d7f",
"name": "New control logic",
"enabled": true,
"version": 4,
"nodes": [
{
"id": "n_303sue0",
"kind": "trigger.cron",
"x": 117.24583129882814,
"y": 208.7500061035156,
"params": {
"spec": "2 11 * * *"
}
},
{
"id": "n_u4u46iy",
"kind": "action.write",
"x": 357.2458312988281,
"y": 223.7500061035156,
"params": {
"expr": "1",
"target": "epics:UOPI:BEAM_ON"
}
},
{
"id": "n_djkxc14",
"kind": "action.delay",
"x": 634.2458312988281,
"y": 233.7500061035156,
"params": {
"ms": "5000"
}
},
{
"id": "n_aurpu41",
"kind": "action.write",
"x": 914.2458312988281,
"y": 195.75000610351563,
"params": {
"expr": "0",
"target": "epics:UOPI:BEAM_ON"
}
}
],
"wires": [
{
"from": "n_303sue0",
"to": "n_u4u46iy"
},
{
"from": "n_u4u46iy",
"to": "n_djkxc14"
},
{
"from": "n_djkxc14",
"to": "n_aurpu41"
}
]
}
@@ -0,0 +1,61 @@
{
"name": "flow_temperature",
"ds": "",
"signal": "",
"inputs": null,
"pipeline": null,
"graph": {
"nodes": [
{
"id": "g_ina0z66",
"kind": "output",
"inputs": [
"g_i8jh2kn"
],
"x": 759.75,
"y": 137.15
},
{
"id": "g_6iio15u",
"kind": "source",
"ds": "epics",
"signal": "UOPI:TEMP",
"x": 60.1,
"y": 60.099999999999994
},
{
"id": "g_i8jh2kn",
"kind": "op",
"op": "expr",
"params": {
"expr": "a * b",
"vars": [
"a",
"b"
]
},
"inputs": [
"g_6iio15u",
"g_v7p0lvm"
],
"x": 369.8666687011719,
"y": 131.59834045410156
},
{
"id": "g_v7p0lvm",
"kind": "source",
"ds": "epics",
"signal": "UOPI:FLOW",
"x": 56.099999999999994,
"y": 170.35000000000002
}
],
"output": "g_ina0z66"
},
"meta": {
"displayHigh": 1000
},
"visibility": "user",
"owner": "martino",
"version": 1
}