Compare commits

...

3 Commits

Author SHA1 Message Date
Martino Ferrari 914108e575 Plot fix 2026-06-19 07:57:42 +02:00
Martino Ferrari ba836f00e6 Add widget-control logic action and multi-column CSV / multi-field dialogs
Extend the panel logic editor with two capabilities:

- action.widget: drive a panel widget by id from a flow — enable/disable,
  show/hide, and (for plot widgets) clear, pause, or resume the live plot.
  Wired via a per-widget command store consumed by a Canvas WidgetView wrapper
  (disable overlay / hide) and PlotWidget (pause/clear), reset on panel unmount.
- action.export now emits multiple named arrays as CSV columns with per-column
  labels and an alignment mode (common/any/interpolate); dialogs support asking
  for and displaying multiple values per dialog.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-19 07:51:18 +02:00
Martino Ferrari afefba3184 Add control logic engine, panel logic dialogs, logic-edit restriction
Introduce server-side control-logic flow graphs (cron/alarm triggers,
Lua blocks) with CRUD endpoints, panel-logic lifecycle triggers and
user-interaction dialog nodes, and a synthetic node-graph editor.

Add an optional logic-editor allowlist (server.logic_editors) gating who
may add/edit panel logic and control logic, surfaced via /api/v1/me and
enforced in the API; hide logic affordances in the UI accordingly.

Update README, example config, and functional/technical specs to cover
all current features (plot panels, panel/control logic, local variables,
access control) and refresh the in-app manual and contextual help.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-19 07:27:35 +02:00
37 changed files with 5241 additions and 501 deletions
+31 -4
View File
@@ -10,12 +10,17 @@ uopi runs as a **single portable binary** with no runtime dependencies. Point an
| Feature | Details | | Feature | Details |
|---------|---------| |---------|---------|
| **Live data** | EPICS Channel Access (CA) and user-defined synthetic signals | | **Live data** | EPICS Channel Access (CA) / PVAccess and user-defined synthetic signals |
| **HMI editor** | Drag-and-drop widgets, resize, align/distribute, undo/redo, saved as XML | | **HMI editor** | Drag-and-drop widgets, resize, align/distribute, undo/redo, saved as XML |
| **Widget library** | Text view, gauge, LED, bar, set-point control, button, plots | | **Widget library** | Text view/label, gauge, LED, multi-LED, bar, set-point control, button, image, link, plots |
| **Plot types** | Time-series (uPlot), FFT, waterfall, histogram, bar chart, logic analyser | | **Plot types** | Time-series (uPlot), FFT, waterfall, histogram, bar chart, logic analyser |
| **Plot panels** | Dedicated chart panels with a recursive split layout (tmux/IDE-style) where plots fill the viewport |
| **Panel logic** | In-editor node-graph flow editor (triggers → actions, dialogs) that runs client-side in view mode |
| **Control logic** | Server-side always-on flow graphs with cron/alarm triggers and Lua blocks |
| **Local variables** | Panel-scoped state variables for set-points, toggles and counters used by logic |
| **Historical data** | EPICS Archive Appliance integration via REST; time range picker in UI | | **Historical data** | EPICS Archive Appliance integration via REST; time range picker in UI |
| **Synthetic signals** | Compose, filter, and transform signals with a DSP pipeline (gain, offset, moving average, lowpass, highpass, derivative, integral, clamp, formula) | | **Synthetic signals** | Compose, filter, and transform signals via a wizard or visual node-graph editor (gain, offset, moving average, lowpass, highpass, derivative, integral, clamp, formula); panel/user/global visibility scopes |
| **Access control** | Identity via trusted proxy header; per-user global level (write/read-only/no-access); per-panel ownership + sharing; nested folders; optional logic-editor allowlist |
| **Multi-client** | Subscriptions are multiplexed — N clients share one upstream connection per signal | | **Multi-client** | Subscriptions are multiplexed — N clients share one upstream connection per signal |
| **Signal discovery** | Filter/search, CSV import, manual add, Channel Finder proxy | | **Signal discovery** | Filter/search, CSV import, manual add, Channel Finder proxy |
| **Observability** | Prometheus-format metrics at `/metrics` | | **Observability** | Prometheus-format metrics at `/metrics` |
@@ -64,7 +69,20 @@ Create `uopi.toml` (all fields are optional — shown values are defaults):
```toml ```toml
[server] [server]
listen = ":8080" listen = ":8080"
storage_dir = "./data" # where interface XML files and synthetic.json are stored storage_dir = "./interfaces" # where interface XML, ACL and logic data are stored
# Identity & access control (all optional)
trusted_user_header = "" # HTTP header carrying the proxy-authenticated user
default_user = "" # identity used when the header is absent (LAN/dev)
# logic_editors = [] # restrict who may edit panel/control logic (users or groups)
# [[server.blacklist]] # downgrade a user: level = "readonly" or "noaccess"
# user = "guest"
# level = "readonly"
# [[groups]] # named user sets, referenced by panel sharing
# name = "operators"
# members = ["alice", "bob"]
[datasource.epics] [datasource.epics]
enabled = false # requires build with -tags epics enabled = false # requires build with -tags epics
@@ -118,7 +136,16 @@ The REST API is available under `/api/v1`. Key endpoints:
| `GET/POST` | `/api/v1/interfaces` | List or create HMI interfaces | | `GET/POST` | `/api/v1/interfaces` | List or create HMI interfaces |
| `GET/PUT/DELETE` | `/api/v1/interfaces/{id}` | Read, update, or delete an interface | | `GET/PUT/DELETE` | `/api/v1/interfaces/{id}` | Read, update, or delete an interface |
| `POST` | `/api/v1/interfaces/{id}/clone` | Duplicate an interface | | `POST` | `/api/v1/interfaces/{id}/clone` | Duplicate an interface |
| `POST` | `/api/v1/interfaces/reorder` | Reorder panels / move them between folders |
| `GET/PUT` | `/api/v1/interfaces/{id}/acl` | Read or set a panel's sharing rules |
| `GET` | `/api/v1/interfaces/{id}/versions` | List saved versions of a panel |
| `GET/POST` | `/api/v1/folders` | List or create panel folders |
| `PUT/DELETE` | `/api/v1/folders/{id}` | Rename/reparent or delete a folder |
| `GET/POST/DELETE` | `/api/v1/synthetic` | Manage synthetic signal definitions | | `GET/POST/DELETE` | `/api/v1/synthetic` | Manage synthetic signal definitions |
| `GET/POST` | `/api/v1/controllogic` | List or create server-side control-logic graphs |
| `GET/PUT/DELETE` | `/api/v1/controllogic/{id}` | Read, update, or delete a control-logic graph |
| `GET` | `/api/v1/me` | Caller identity, access level, groups, logic-edit permission |
| `GET` | `/api/v1/usergroups` | List configured users and groups (for sharing) |
| `GET` | `/metrics` | Prometheus-format server metrics | | `GET` | `/metrics` | Prometheus-format server metrics |
| `GET` | `/healthz` | Health check | | `GET` | `/healthz` | Health check |
+14 -2
View File
@@ -14,6 +14,7 @@ import (
"github.com/uopi/uopi/internal/access" "github.com/uopi/uopi/internal/access"
"github.com/uopi/uopi/internal/broker" "github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/config" "github.com/uopi/uopi/internal/config"
"github.com/uopi/uopi/internal/controllogic"
"github.com/uopi/uopi/internal/datasource/epics" "github.com/uopi/uopi/internal/datasource/epics"
"github.com/uopi/uopi/internal/datasource/pva" "github.com/uopi/uopi/internal/datasource/pva"
"github.com/uopi/uopi/internal/datasource/stub" "github.com/uopi/uopi/internal/datasource/stub"
@@ -132,9 +133,20 @@ func main() {
for _, g := range cfg.Groups { for _, g := range cfg.Groups {
groups[g.Name] = g.Members groups[g.Name] = g.Members
} }
policy := access.New(cfg.Server.DefaultUser, blacklist, groups) policy := access.New(cfg.Server.DefaultUser, blacklist, groups, cfg.Server.LogicEditors)
srv := server.New(cfg.Server.Listen, webFS, brk, synthDS, store, policy, aclStore, cfg.Datasource.EPICS.ChannelFinderURL, cfg.Datasource.EPICS.ArchiveURL, cfg.Server.TrustedUserHeader, log) // Server-side control logic: flow graphs that run continuously under the root
// context, independent of any panel. The store is loaded from disk and the
// engine starts all enabled graphs.
ctrlStore, err := controllogic.NewStore(cfg.Server.StorageDir)
if err != nil {
log.Error("failed to open control logic store", "err", err)
os.Exit(1)
}
ctrlEngine := controllogic.NewEngine(ctx, brk, ctrlStore, log)
ctrlEngine.Reload()
srv := server.New(cfg.Server.Listen, webFS, brk, synthDS, store, policy, aclStore, ctrlStore, ctrlEngine, cfg.Datasource.EPICS.ChannelFinderURL, cfg.Datasource.EPICS.ArchiveURL, cfg.Server.TrustedUserHeader, log)
if err := srv.Start(ctx); err != nil { if err := srv.Start(ctx); err != nil {
fmt.Fprintf(os.Stderr, "server error: %v\n", err) fmt.Fprintf(os.Stderr, "server error: %v\n", err)
+133 -46
View File
@@ -8,13 +8,23 @@ uopi is a web-based HMI (Human-Machine Interface) for monitoring and controlling
## 2. Users and Roles ## 2. Users and Roles
| Role | Description | User identity is established from a header set by a trusted authenticating reverse
|------|-------------| proxy (`server.trusted_user_header`), with a configurable `default_user` fallback for
| Operator | Uses interfaces in View mode; can interact with controls but cannot edit layouts | unproxied/LAN deployments. There is no login page inside the application.
| Engineer | Creates and edits interfaces in Edit mode; manages signal lists |
| Administrator | Manages server configuration, data sources, and saved interfaces |
In the initial version all users share the same access level. Role-based access control is deferred to a future release. **Global access levels.** Every user is trusted with full **write** access by default. A
configuration blacklist can downgrade specific users to **read-only** (view only, no
writes) or **no access** (denied). Named **groups** are defined in configuration and
referenced by per-panel sharing.
**Per-panel access.** Panels are owned by their creator and private by default. Owners
share a panel with specific users/groups (read or write) or make it public; the owner's
global level always caps the per-panel permission. Panels are organised into nested
folders whose permissions inherit down the chain. See §9.
**Logic-edit restriction.** Adding/editing panel logic and server-side control logic can
optionally be limited to an allowlist of users/groups (`server.logic_editors`); when
unset, any writer may edit logic.
--- ---
@@ -30,27 +40,27 @@ The default mode when opening the application.
- "New interface" button opens Edit mode with a blank canvas. - "New interface" button opens Edit mode with a blank canvas.
- The pane width can be adjusted by dragging the resize handle on its right edge. - The pane width can be adjusted by dragging the resize handle on its right edge.
**Tabs**
- **HMI tab**: the live widget canvas (described below).
- **Plot tab**: a dedicated live multi-signal plot panel (see §3.3).
- **Info tab**: signal info and metadata display for the last right-clicked signal.
**HMI canvas (center)** **HMI canvas (center)**
- Renders the selected interface as a live, interactive panel. - Renders the selected interface as a live, interactive panel.
- Widgets display real-time data; controls (set-value, buttons) are active. - Widgets display real-time data; controls (set-value, buttons) are active.
- Panel logic (if any) runs while the panel is open (see §6).
- No drag, resize, or layout operations are possible in this mode. - No drag, resize, or layout operations are possible in this mode.
- Right-clicking any widget opens a context menu: - Right-clicking any widget opens a context menu:
- **Signal info** — switches to the Info tab showing DS name, type, unit, range, current value and timestamp. - **Signal info** — shows DS name, type, unit, range, current value and timestamp.
- **Copy signal name** — copies the signal identifier to the clipboard. - **Copy signal name** — copies the signal identifier to the clipboard.
- **Export data to CSV** — downloads buffered data for the signal(s) used by the widget. - **Export data to CSV** — downloads buffered data for the signal(s) used by the widget.
- **Plot** — adds the signal(s) to the Plot tab.
> Dedicated multi-signal plotting is provided by **plot panels** — a special interface
> kind whose plots fill the viewport (see §3.3) — rather than a separate live "Plot tab".
**Top toolbar** **Top toolbar**
- Show/hide interface list pane. - Show/hide interface list pane.
- **⏱ History** button: toggle historical time navigation bar. - **⏱ History** button: toggle historical time navigation bar.
- **⚙ Control logic** button: open the server-side control-logic editor (see §7).
Shown only to users permitted to edit logic.
- Zoom control (A / % / A+): adjust the UI scale (see §3.4). - Zoom control (A / % / A+): adjust the UI scale (see §3.4).
- Edit button: switch to Edit mode for the current interface. - Edit button: switch to Edit mode for the current interface.
- Connection status indicator. - Connection status indicator and signed-in user chip.
**Historical time navigation bar** (shown when History is active) **Historical time navigation bar** (shown when History is active)
- Date/time range pickers (start and end). - Date/time range pickers (start and end).
@@ -71,9 +81,17 @@ Activated via the "New interface" button or by clicking Edit in the toolbar.
- Filter/search box to narrow the list. - Filter/search box to narrow the list.
- Synthetic signals show an edit (✎) button to reopen the wizard. - Synthetic signals show an edit (✎) button to reopen the wizard.
**Widget canvas (center)** **Center area — Layout / Logic tabs**
- Free-form canvas where widgets can be placed at arbitrary pixel positions. - **Layout**: free-form canvas where widgets can be placed at arbitrary pixel positions,
- Background grid with snap-to-grid. over a background grid with snap-to-grid. (For plot panels this is replaced by the
split-layout editor; see §3.3.)
- **Logic**: a node-graph flow editor for panel behaviour (see §6). Hidden when the user
is not permitted to edit logic.
**Local variables**
- A panel may declare panel-scoped variables (data source `local`) with initial values.
- They are written by set-value widgets, buttons and logic actions, and referenced
anywhere a signal is. Added from the signal tree's *Local* group / the Logic palette.
**Properties pane (right, resizable and collapsible)** **Properties pane (right, resizable and collapsible)**
- Appears when one or more widgets are selected. - Appears when one or more widgets are selected.
@@ -88,25 +106,27 @@ Activated via the "New interface" button or by clicking Edit in the toolbar.
- Close (return to View mode). - Close (return to View mode).
- Zoom control (A / % / A+). - Zoom control (A / % / A+).
### 3.3 Plot Tab (Live Multi-Signal Plot) ### 3.3 Plot Panels (Split Layout)
A dedicated panel for live time-series plotting of any signals, independent of the interface layout. A **plot panel** is a special interface kind dedicated to charts. Rather than free-form
widget boxes, plots **fill the viewport** and the user divides the space between them with
a recursive split layout (tmux/IDE-style tiling). Created via **+ Plot** in the interface
list, opening with a single full-viewport empty plot.
**Adding signals:** right-click any widget in the HMI tab → **Plot**. The signal is added to the Plot tab immediately. **Editing (Edit mode):**
- Hover a pane and use its split buttons (**⬌** vertical / **⬍** horizontal) to divide it;
a new empty plot fills the freed half. Nesting is unlimited.
- Drag the divider between two panes to resize them.
- Click a pane to select it and configure its plot in the Properties pane (plot sub-type,
Y range, time window, legend, per-signal colour). Drag a signal onto a pane to add it.
- A pane's **✕** removes it; the layout collapses onto its sibling.
**Time window selector:** 10s / 30s / 1m / 5m / 15m / 1h buttons control how much history is displayed. **View mode:** the saved split layout fills the screen with live, streaming plots; the
per-widget right-click menu (signal info / copy / CSV) and historical time navigation
apply as for any time-series plot.
**Per-signal legend:** Per-plot configuration reuses the standard Plot widget (§4.4), so all plot sub-types and
- Color swatch and signal name. options are available within a pane.
- Statistics table: Last / Min / Max / Mean over the current window.
- ✎ button opens an inline style editor:
- **Color**: color picker.
- **Width**: line width (none, 1, 1.5, 2, 3 px).
- **Line**: solid / dashed / dotted.
- **Markers**: none, S (3 px), M (5 px), L (8 px).
- ✕ button removes the signal from the plot.
**Chart area:** rendered with uPlot; auto-scaled Y axis; time axis tracks the rolling window in real time.
### 3.4 UI Zoom ### 3.4 UI Zoom
@@ -210,16 +230,18 @@ When the signal's metadata reports enum strings (e.g. EPICS mbbi/mbbo records):
A signal defined by composing one or more input signals through a chain of processing nodes. A signal defined by composing one or more input signals through a chain of processing nodes.
**New Synthetic Signal Wizard:** **Two authoring surfaces:**
1. Click "New synthetic signal" in the signal tree. - **Wizard** — for the common single-input case: name the signal, pick an input and a
2. Name the signal and optionally set a unit. processing node, set parameters, Create.
3. Add processing nodes from the node type dropdown and connect them. - **Node-graph editor** — a visual editor that wires one or more inputs through a chain of
4. Configure each node's parameters inline. DSP blocks, for multi-input pipelines. It compiles to the same inputs + pipeline model.
5. Click Create — the signal appears in the tree and updates live.
The wizard dialog is resizable (drag its corner) and defaults to 600 px wide to accommodate the Lua editor. **Visibility scope:** each synthetic signal is scoped as *panel* (visible only to the panel
that created it), *user*, or *global* (shared with everyone).
**Editing an existing synthetic signal:** click the ✎ button next to the signal in the tree to reopen the wizard. The dialogs are resizable and default to a width that accommodates the Lua editor.
**Editing an existing synthetic signal:** click the ✎ button next to the signal in the tree to reopen the editor.
**Built-in node types:** **Built-in node types:**
@@ -237,15 +259,80 @@ The wizard dialog is resizable (drag its corner) and defaults to 600 px wide to
--- ---
## 6. Interface Persistence ## 6. Panel Logic
- Interfaces are saved to the server in XML format and are available to all connected clients. The **Logic** tab in the panel editor is a node-graph (Node-RED-style) flow editor that
- Export/Import allows local file exchange of XML files. gives a panel interactive behaviour. The flow runs **client-side** while the panel is open
- The XML schema records: widget type, position, size, signal bindings, and all property values. in View mode; it is saved as part of the interface XML. The tab and any logic editing are
gated by the logic-edit restriction (§2).
**Authoring:** drag blocks from the palette (or click to add), connect output ports to
input ports, and edit the selected node in the inspector. Expression fields reference a
signal as `{ds:name}` and a panel-local variable by its bare name, and support arithmetic,
comparisons, boolean logic and common math functions. The editor has its own undo/redo and
copy/paste.
**Node categories:**
| Category | Nodes |
|----------|-------|
| Triggers | Button press, threshold crossing, value change, timer/interval, panel loop, On-open / On-close lifecycle |
| Logic | AND gate, If (then/else), Loop (count or while) |
| Actions | Write to signal/variable, Delay, Log; Accumulate / Export-CSV / Clear for in-memory data arrays |
| Dialogs | Info and Error pop-ups; Set-point prompt (asks the user for a number and writes it) |
**System helpers in expressions:** `{sys:time}` (epoch seconds) and `{sys:dt}` (seconds
since the firing trigger last fired).
--- ---
## 7. Historical Data Navigation ## 7. Control Logic
**Control logic** is server-side automation: flow graphs that run continuously on the
server, independent of any connected client (unlike panel logic, which only runs while a
panel is open). Opened with the **⚙ Control logic** button in the View-mode toolbar and
managed through the REST API.
- Triggers include *cron* schedules and signal *alarm*/threshold conditions.
- A **Lua** block provides custom logic; results are written back to signals.
- Each graph can be enabled/disabled independently; saving reloads the engine live.
- Editing is gated by the logic-edit restriction (§2).
---
## 8. Interface Persistence
- Interfaces are saved to the server in XML format and are available to all connected clients.
- Per-panel access rules and panel-folder placement are stored server-side (sidecar JSON).
- Saved versions are retained; a panel's version history can be listed, tagged and promoted.
- Export/Import allows local file exchange of XML files.
- The XML schema records: interface kind (panel/plot) and split layout, widget type,
position, size, signal bindings, all property values, local variables, and panel logic.
---
## 9. Access Control, Sharing & Folders
**Identity** is resolved per request from the trusted proxy header, falling back to
`default_user`. The `/api/v1/me` endpoint reports the caller's identity, global level,
group memberships and whether they may edit logic; the UI hides affordances accordingly.
**Global levels** (§2): write (default), read-only, or no-access via the config blacklist.
**Per-panel sharing:** the **Share** dialog on a panel grants read or write to specific
users or groups, or marks the panel public. New panels are private to their owner. The
owner's global level caps any per-panel grant.
**Folders:** panels are organised into nested folders; permissions inherit down the folder
chain. Panels can be dragged to reorder or move between folders.
**Logic-editor allowlist:** when `server.logic_editors` is set, only the listed users/
groups may add or change panel logic and control logic; everyone else keeps full access to
the rest of the application.
---
## 10. Historical Data Navigation
When the server has archive access configured: When the server has archive access configured:
- The **⏱ History** button in the View mode toolbar reveals a time range bar. - The **⏱ History** button in the View mode toolbar reveals a time range bar.
@@ -257,7 +344,7 @@ When the server has archive access configured:
--- ---
## 8. Non-functional Requirements ## 11. Non-functional Requirements
| Requirement | Target | | Requirement | Target |
|-------------|--------| |-------------|--------|
+102 -21
View File
@@ -187,16 +187,34 @@ Framing: JSON messages over a single persistent WebSocket connection per client.
Base path: `/api/v1` Base path: `/api/v1`
| Method | Path | Description | | Method | Path | Description |
| ------ | ------------------------- | -------------------------------------------- | | --------------- | ------------------------------------- | ------------------------------------------------- |
| GET | `/me` | Caller identity, global level, groups, `canEditLogic` |
| GET | `/datasources` | List connected data sources and their status | | GET | `/datasources` | List connected data sources and their status |
| GET | `/signals?ds=epics` | List signals for a data source | | GET | `/signals?ds=epics` | List signals for a data source |
| GET | `/signals/:ds/:name/meta` | Get full metadata for a signal | | GET | `/signals/search?q=` | Search signals across all sources |
| GET | `/interfaces` | List saved interfaces | | GET | `/channel-finder?q=` | Proxy to EPICS Channel Finder |
| POST | `/interfaces` | Create a new interface (body: XML) | | GET | `/archiver/search?q=` | Search the EPICS archiver for PV names |
| GET | `/interfaces/:id` | Download interface XML | | GET, POST | `/interfaces` | List saved interfaces / create one (body: XML) |
| PUT | `/interfaces/:id` | Update interface XML | | POST | `/interfaces/reorder` | Reorder panels / move between folders |
| DELETE | `/interfaces/:id` | Delete interface | | GET, PUT, DELETE| `/interfaces/{id}` | Download, update, or delete an interface |
| POST | `/interfaces/:id/clone` | Clone an interface | | POST | `/interfaces/{id}/clone` | Clone an interface |
| GET | `/interfaces/{id}/versions` | List saved versions; `…/{version}` to fetch one |
| PUT | `/interfaces/{id}/versions/{v}/tag` | Tag a version |
| POST | `/interfaces/{id}/versions/{v}/promote` | Promote a version to current |
| POST | `/interfaces/{id}/versions/{v}/fork` | Fork a version into a new panel |
| GET, PUT | `/interfaces/{id}/acl` | Read or set a panel's sharing rules |
| GET, POST | `/folders` | List or create panel folders |
| PUT, DELETE | `/folders/{id}` | Rename/reparent or delete a folder |
| GET | `/usergroups` | List configured users and groups (for sharing) |
| 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 |
| GET, POST | `/controllogic` | List or create server-side control-logic graphs |
| GET, PUT, DELETE| `/controllogic/{id}` | Read, update, or delete a control-logic graph |
Mutating requests are gated by the access middleware (§8): global level for writes,
per-panel ACL for interface endpoints, and the logic-editor allowlist for control-logic
endpoints and for any change to a panel's `<logic>` block.
### 3.5 EPICS Data Source ### 3.5 EPICS Data Source
@@ -232,7 +250,15 @@ Base path: `/api/v1`
### 3.7 Interface Storage ### 3.7 Interface Storage
Interfaces are stored as XML files in a configurable directory on the server. Interfaces are stored as XML files in a configurable directory on the server. The
`<interface>` element carries an optional `kind` attribute (`panel` default, or `plot`);
plot panels add a nested `<layout>` split tree referencing plot widgets by id. Beyond
widgets, an interface may also contain panel-local variables and a `<logic>` flow graph
(`<node>` + `<param>` + `<wire>`), all round-tripping through the same XML.
Per-panel access rules and folder placement are kept in a sidecar `acl.json` in the same
directory (not in the interface XML), and prior versions of each panel are retained for the
version history endpoints.
```xml ```xml
<interface name="My Panel" version="1" created="2026-04-24T12:00:00Z"> <interface name="My Panel" version="1" created="2026-04-24T12:00:00Z">
@@ -255,6 +281,39 @@ Interfaces are stored as XML files in a configurable directory on the server.
</interface> </interface>
``` ```
### 3.8 Panel Logic Engine
Panel logic is a client-side flow engine (`web/src/lib/logic.ts`, singleton `logicEngine`).
The graph is built/edited in the panel editor's Logic tab (`LogicEditor.tsx`) and serialized
into the interface XML. In View mode, `Canvas` calls `logicEngine.load(iface.logic)` on
mount and `clear()` on unmount. The engine subscribes to every referenced signal into a live
cache, then on each trigger activation walks the wired graph (gates, if/loop, actions),
evaluating expression fields via a small safe recursive-descent evaluator (no `eval`).
Triggers include button/threshold/change/timer/loop and On-open/On-close lifecycle; actions
include signal writes, delay, log, in-memory data arrays (accumulate/export-CSV/clear) and
user dialogs (info/error/set-point). The engine runs entirely in the browser — no backend
component.
### 3.9 Control Logic Engine
Control logic is server-side (`internal/controllogic`): always-on flow graphs that run under
the root context, independent of any client. The engine subscribes to the broker, fires on
cron schedules and signal alarm/threshold conditions, executes a Lua block for custom logic,
and writes results back to signals. Graphs are persisted by a store and managed via
`/api/v1/controllogic`; each mutation calls `Engine.Reload()` to apply changes live. Graphs
can be individually enabled/disabled.
### 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.
--- ---
## 4. Frontend Architecture ## 4. Frontend Architecture
@@ -298,16 +357,17 @@ View mode renders widgets as absolutely positioned Preact components on a scroll
- Plot widgets maintain a rolling ring buffer of 200,000 samples per signal for smooth long-window display. - Plot widgets maintain a rolling ring buffer of 200,000 samples per signal for smooth long-window display.
- Step-hold interpolation: when multiple signals at different update rates share a plot, the most recent value is carried forward to fill the shared time axis correctly. - Step-hold interpolation: when multiple signals at different update rates share a plot, the most recent value is carried forward to fill the shared time axis correctly.
### 4.5 Plot Panel (`PlotPanel.tsx`) ### 4.5 Plot Panels (`SplitLayout.tsx`, `PlotPanelCanvas.tsx`)
A live multi-signal plot panel accessible from the "Plot" tab in View mode: Plot panels are interfaces of `kind === 'plot'` whose plots fill the viewport in a recursive
split layout (`lib/plotLayout.ts` holds the pure tree helpers; `SplitLayout.tsx` is the
shared presentational renderer with draggable dividers):
- Signals are added by right-clicking any widget and choosing "Plot". - In View mode, `Canvas` renders the saved layout read-only with live `PlotWidget`s.
- Supports configurable time window (10s to 1h). - In Edit mode, `PlotPanelCanvas.tsx` adds per-pane overlays: split (⬌/⬍), close (✕), click
- Per-signal style editor: color picker, line width (0 = hidden), line dash (solid/dashed/dotted), marker size (none/S/M/L). to select, and signal-drop to add a signal to that pane's plot.
- Statistics table per signal: last, min, max, mean over the current time window. - Each pane reuses the standard `PlotWidget`, so all plot sub-types and options apply.
- Uses a `requestAnimationFrame` loop limited to ≤1 redraw/second when data is not changing. - Layout edits (split/close/resize) participate in the editor's undo/redo.
- Chart fills its container; `ResizeObserver` keeps the uPlot canvas sized correctly.
### 4.6 Resizable Panels ### 4.6 Resizable Panels
@@ -407,17 +467,31 @@ Server is configured via a TOML file (default: `uopi.toml`, overridable via `--c
listen = ":8080" listen = ":8080"
storage_dir = "./interfaces" 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"]
[datasource.epics] [datasource.epics]
enabled = true enabled = true
ca_addr_list = "" # EPICS_CA_ADDR_LIST override ca_addr_list = "" # EPICS_CA_ADDR_LIST override
archive_url = "" # EPICS Archive Appliance URL archive_url = "" # EPICS Archive Appliance URL
channel_finder_url = "" # EPICS Channel Finder URL
[datasource.synthetic] [datasource.synthetic]
enabled = true enabled = true
definitions_file = "./synthetic.toml"
``` ```
All settings can also be overridden with `UOPI_*` environment variables (e.g. `UOPI_SERVER_LISTEN`, `UOPI_EPICS_CA_ADDR_LIST`). All settings can also be overridden with `UOPI_*` environment variables (e.g.
`UOPI_SERVER_LISTEN`, `UOPI_SERVER_LOGIC_EDITORS`, `UOPI_EPICS_CA_ADDR_LIST`).
--- ---
@@ -440,13 +514,20 @@ All settings can also be overridden with `UOPI_*` environment variables (e.g. `U
- Lua sandbox: disable `os`, `io`, `package`, `debug` libraries; restrict `math` and `string` to safe subsets. - Lua sandbox: disable `os`, `io`, `package`, `debug` libraries; restrict `math` and `string` to safe subsets.
- WebSocket write operations: validate that the target signal is writable before forwarding to the data source. - WebSocket write operations: validate that the target signal is writable before forwarding to the data source.
- Interface XML parsing: use strict schema validation to prevent XXE. - Interface XML parsing: use strict schema validation to prevent XXE.
- No authentication in v1; intended for trusted LAN / SSH-tunnel deployment. - **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.
--- ---
## 9. Non-goals (v1) ## 9. Non-goals (v1)
- User authentication and authorisation. - Built-in user authentication (a login page / credential store) — identity is delegated to
the front-end reverse proxy; authorisation (levels, ACLs, logic allowlist) is implemented.
- TLS termination (expected to be handled by SSH tunnel or a reverse proxy). - TLS termination (expected to be handled by SSH tunnel or a reverse proxy).
- Windows or macOS server binary. - Windows or macOS server binary.
- Mobile-optimised frontend layout. - Mobile-optimised frontend layout.
+41 -2
View File
@@ -60,15 +60,25 @@ type Policy struct {
blacklist map[string]Level // user → downgraded level blacklist map[string]Level // user → downgraded level
userGroups map[string][]string // user → groups they belong to userGroups map[string][]string // user → groups they belong to
groupNames []string // all configured group names (sorted) groupNames []string // all configured group names (sorted)
logicEditors map[string]bool // users + group names allowed to edit logic
} }
// New builds a Policy. blacklist maps a username to a config level string; // New builds a Policy. blacklist maps a username to a config level string;
// groups maps a group name to its member usernames. // groups maps a group name to its member usernames. logicEditors optionally
func New(defaultUser string, blacklist map[string]string, groups map[string][]string) *Policy { // restricts who may edit panel/control logic (usernames or group names); empty
// means no restriction.
func New(defaultUser string, blacklist map[string]string, groups map[string][]string, logicEditors []string) *Policy {
p := &Policy{ p := &Policy{
defaultUser: strings.TrimSpace(defaultUser), defaultUser: strings.TrimSpace(defaultUser),
blacklist: make(map[string]Level), blacklist: make(map[string]Level),
userGroups: make(map[string][]string), userGroups: make(map[string][]string),
logicEditors: make(map[string]bool),
}
for _, e := range logicEditors {
e = strings.TrimSpace(e)
if e != "" {
p.logicEditors[e] = true
}
} }
for user, lvl := range blacklist { for user, lvl := range blacklist {
u := strings.TrimSpace(user) u := strings.TrimSpace(user)
@@ -126,6 +136,35 @@ func (p *Policy) Level(user string) Level {
return LevelWrite return LevelWrite
} }
// 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
}
// 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.
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
}
// GroupsOf returns a copy of the groups a user belongs to. // GroupsOf returns a copy of the groups a user belongs to.
func (p *Policy) GroupsOf(user string) []string { func (p *Policy) GroupsOf(user string) []string {
src := p.userGroups[strings.TrimSpace(user)] src := p.userGroups[strings.TrimSpace(user)]
+35
View File
@@ -0,0 +1,35 @@
package access
import "testing"
func TestCanEditLogic(t *testing.T) {
groups := map[string][]string{"ops": {"carol"}}
// No allowlist configured: everyone with write access may edit logic.
open := New("", nil, groups, nil)
for _, u := range []string{"", "alice", "carol"} {
if !open.CanEditLogic(u) {
t.Errorf("unrestricted: CanEditLogic(%q) = false, want true", u)
}
}
if open.LogicRestricted() {
t.Error("LogicRestricted() = true with no allowlist")
}
// Allowlist by username and by group name.
p := New("", nil, groups, []string{"alice", "ops"})
if !p.LogicRestricted() {
t.Error("LogicRestricted() = false with allowlist set")
}
cases := map[string]bool{
"": true, // anonymous / trusted LAN
"alice": true, // listed user
"carol": true, // member of listed group "ops"
"bob": false, // not listed
}
for u, want := range cases {
if got := p.CanEditLogic(u); got != want {
t.Errorf("CanEditLogic(%q) = %v, want %v", u, got, want)
}
}
}
+181 -1
View File
@@ -15,6 +15,7 @@ import (
"github.com/uopi/uopi/internal/access" "github.com/uopi/uopi/internal/access"
"github.com/uopi/uopi/internal/broker" "github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/controllogic"
"github.com/uopi/uopi/internal/datasource" "github.com/uopi/uopi/internal/datasource"
"github.com/uopi/uopi/internal/datasource/synthetic" "github.com/uopi/uopi/internal/datasource/synthetic"
"github.com/uopi/uopi/internal/panelacl" "github.com/uopi/uopi/internal/panelacl"
@@ -28,19 +29,23 @@ type Handler struct {
store *storage.Store store *storage.Store
policy *access.Policy policy *access.Policy
acl *panelacl.Store acl *panelacl.Store
ctrlLogic *controllogic.Store
ctrlEngine *controllogic.Engine
channelFinderURL string // empty if not configured channelFinderURL string // empty if not configured
archiverURL string // empty if not configured archiverURL string // empty if not configured
log *slog.Logger log *slog.Logger
} }
// New creates an API Handler. synth may be nil if the synthetic DS is disabled. // New creates an API Handler. synth may be nil if the synthetic DS is disabled.
func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, policy *access.Policy, acl *panelacl.Store, channelFinderURL, archiverURL string, log *slog.Logger) *Handler { func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, policy *access.Policy, acl *panelacl.Store, ctrlLogic *controllogic.Store, ctrlEngine *controllogic.Engine, channelFinderURL, archiverURL string, log *slog.Logger) *Handler {
return &Handler{ return &Handler{
broker: b, broker: b,
synthetic: synth, synthetic: synth,
store: store, store: store,
policy: policy, policy: policy,
acl: acl, acl: acl,
ctrlLogic: ctrlLogic,
ctrlEngine: ctrlEngine,
channelFinderURL: channelFinderURL, channelFinderURL: channelFinderURL,
archiverURL: archiverURL, archiverURL: archiverURL,
log: log, log: log,
@@ -87,6 +92,13 @@ func (h *Handler) Register(mux *http.ServeMux, prefix string) {
mux.HandleFunc("GET "+prefix+"/synthetic/{name}", h.getSynthetic) mux.HandleFunc("GET "+prefix+"/synthetic/{name}", h.getSynthetic)
mux.HandleFunc("PUT "+prefix+"/synthetic/{name}", h.updateSynthetic) mux.HandleFunc("PUT "+prefix+"/synthetic/{name}", h.updateSynthetic)
mux.HandleFunc("DELETE "+prefix+"/synthetic/{name}", h.deleteSynthetic) mux.HandleFunc("DELETE "+prefix+"/synthetic/{name}", h.deleteSynthetic)
// Server-side control logic CRUD (mutations are write-gated by the access
// middleware; each mutation reloads the running engine).
mux.HandleFunc("GET "+prefix+"/controllogic", h.listControlLogic)
mux.HandleFunc("POST "+prefix+"/controllogic", h.createControlLogic)
mux.HandleFunc("GET "+prefix+"/controllogic/{id}", h.getControlLogic)
mux.HandleFunc("PUT "+prefix+"/controllogic/{id}", h.updateControlLogic)
mux.HandleFunc("DELETE "+prefix+"/controllogic/{id}", h.deleteControlLogic)
} }
// ── /me ───────────────────────────────────────────────────────────────────── // ── /me ─────────────────────────────────────────────────────────────────────
@@ -105,6 +117,7 @@ func (h *Handler) getMe(w http.ResponseWriter, r *http.Request) {
"user": user, "user": user,
"level": h.policy.Level(user).String(), "level": h.policy.Level(user).String(),
"groups": groups, "groups": groups,
"canEditLogic": h.policy.CanEditLogic(user),
}) })
} }
@@ -422,6 +435,7 @@ type interfaceListItem struct {
storage.InterfaceMeta storage.InterfaceMeta
Owner string `json:"owner,omitempty"` Owner string `json:"owner,omitempty"`
Folder string `json:"folder,omitempty"` Folder string `json:"folder,omitempty"`
Order float64 `json:"order,omitempty"`
Perm string `json:"perm"` Perm string `json:"perm"`
} }
@@ -442,6 +456,7 @@ func (h *Handler) listInterfaces(w http.ResponseWriter, r *http.Request) {
if acl := h.acl.GetPanel(m.ID); acl != nil { if acl := h.acl.GetPanel(m.ID); acl != nil {
item.Owner = acl.Owner item.Owner = acl.Owner
item.Folder = acl.Folder item.Folder = acl.Folder
item.Order = acl.Order
} }
out = append(out, item) out = append(out, item)
} }
@@ -455,6 +470,12 @@ func (h *Handler) createInterface(w http.ResponseWriter, r *http.Request) {
jsonError(w, http.StatusBadRequest, "read body: "+err.Error()) jsonError(w, http.StatusBadRequest, "read body: "+err.Error())
return return
} }
// Editing panel logic may be restricted to an allowlist. A brand-new panel
// carrying a non-empty <logic> block requires that permission.
if !h.policy.CanEditLogic(caller(r)) && extractLogicBlock(body) != "" {
jsonError(w, http.StatusForbidden, "you are not permitted to edit panel logic")
return
}
id, err := h.store.Create(body, r.URL.Query().Get("tag")) id, err := h.store.Create(body, r.URL.Query().Get("tag"))
if err != nil { if err != nil {
h.log.Error("create interface", "err", err) h.log.Error("create interface", "err", err)
@@ -474,6 +495,44 @@ func (h *Handler) createInterface(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]string{"id": id}) _ = json.NewEncoder(w).Encode(map[string]string{"id": id})
} }
// reorderInterfaces places a set of panels into a folder (or the root when folder
// is empty) in the given order, stamping each id with order=index. The caller must
// have write access to the destination folder (if any) and to each panel.
func (h *Handler) reorderInterfaces(w http.ResponseWriter, r *http.Request) {
var req struct {
Folder string `json:"folder"`
IDs []string `json:"ids"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "decode body: "+err.Error())
return
}
if req.Folder != "" {
if _, err := h.acl.GetFolder(req.Folder); err != nil {
jsonError(w, http.StatusNotFound, "folder not found: "+req.Folder)
return
}
if h.folderPerm(r, req.Folder) < panelacl.PermWrite {
jsonError(w, http.StatusForbidden, "you do not have write access to this folder")
return
}
}
for _, id := range req.IDs {
if h.panelPerm(r, id) < panelacl.PermWrite {
jsonError(w, http.StatusForbidden, "you do not have write access to panel: "+id)
return
}
}
for i, id := range req.IDs {
if err := h.acl.PlacePanel(id, req.Folder, float64(i)); err != nil {
h.log.Error("reorder interface", "id", id, "err", err)
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) getInterface(w http.ResponseWriter, r *http.Request) { func (h *Handler) getInterface(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id") id := r.PathValue("id")
if h.panelPerm(r, id) < panelacl.PermRead { if h.panelPerm(r, id) < panelacl.PermRead {
@@ -504,6 +563,14 @@ func (h *Handler) updateInterface(w http.ResponseWriter, r *http.Request) {
jsonError(w, http.StatusBadRequest, "read body: "+err.Error()) jsonError(w, http.StatusBadRequest, "read body: "+err.Error())
return return
} }
// Editing panel logic may be restricted to an allowlist. Permit unrestricted
// edits to the rest of the panel as long as the <logic> block is unchanged.
if !h.policy.CanEditLogic(caller(r)) {
if cur, err := h.store.Get(id); err != nil || extractLogicBlock(body) != extractLogicBlock(cur) {
jsonError(w, http.StatusForbidden, "you are not permitted to edit panel logic")
return
}
}
if err := h.store.Update(id, body, r.URL.Query().Get("tag")); err != nil { if err := h.store.Update(id, body, r.URL.Query().Get("tag")); err != nil {
if errors.Is(err, storage.ErrNotFound) { if errors.Is(err, storage.ErrNotFound) {
jsonError(w, http.StatusNotFound, "interface not found: "+id) jsonError(w, http.StatusNotFound, "interface not found: "+id)
@@ -1009,8 +1076,121 @@ func (h *Handler) deleteSynthetic(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
// ── Control logic ──────────────────────────────────────────────────────────────
func (h *Handler) listControlLogic(w http.ResponseWriter, _ *http.Request) {
if h.ctrlLogic == nil {
jsonOK(w, []any{})
return
}
graphs := h.ctrlLogic.List()
if graphs == nil {
graphs = []controllogic.Graph{}
}
jsonOK(w, graphs)
}
func (h *Handler) getControlLogic(w http.ResponseWriter, r *http.Request) {
if h.ctrlLogic == nil {
jsonError(w, http.StatusServiceUnavailable, "control logic not enabled")
return
}
g, err := h.ctrlLogic.Get(r.PathValue("id"))
if err != nil {
jsonError(w, http.StatusNotFound, "control logic graph not found: "+r.PathValue("id"))
return
}
jsonOK(w, g)
}
func (h *Handler) createControlLogic(w http.ResponseWriter, r *http.Request) {
if h.ctrlLogic == nil {
jsonError(w, http.StatusServiceUnavailable, "control logic not enabled")
return
}
if !h.policy.CanEditLogic(caller(r)) {
jsonError(w, http.StatusForbidden, "you are not permitted to edit logic")
return
}
var g controllogic.Graph
if err := json.NewDecoder(r.Body).Decode(&g); err != nil {
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
g.ID = genID("cl")
if err := h.ctrlLogic.Save(g); err != nil {
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
h.ctrlEngine.Reload()
w.WriteHeader(http.StatusCreated)
jsonOK(w, g)
}
func (h *Handler) updateControlLogic(w http.ResponseWriter, r *http.Request) {
if h.ctrlLogic == nil {
jsonError(w, http.StatusServiceUnavailable, "control logic not enabled")
return
}
if !h.policy.CanEditLogic(caller(r)) {
jsonError(w, http.StatusForbidden, "you are not permitted to edit logic")
return
}
id := r.PathValue("id")
if _, err := h.ctrlLogic.Get(id); err != nil {
jsonError(w, http.StatusNotFound, "control logic graph not found: "+id)
return
}
var g controllogic.Graph
if err := json.NewDecoder(r.Body).Decode(&g); err != nil {
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
g.ID = id
if err := h.ctrlLogic.Save(g); err != nil {
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
h.ctrlEngine.Reload()
jsonOK(w, g)
}
func (h *Handler) deleteControlLogic(w http.ResponseWriter, r *http.Request) {
if h.ctrlLogic == nil {
jsonError(w, http.StatusServiceUnavailable, "control logic not enabled")
return
}
if !h.policy.CanEditLogic(caller(r)) {
jsonError(w, http.StatusForbidden, "you are not permitted to edit logic")
return
}
if err := h.ctrlLogic.Delete(r.PathValue("id")); err != nil {
jsonError(w, http.StatusNotFound, "control logic graph not found: "+r.PathValue("id"))
return
}
h.ctrlEngine.Reload()
w.WriteHeader(http.StatusNoContent)
}
// ── Helpers ─────────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────────
// extractLogicBlock returns the verbatim <logic>…</logic> section of an
// interface XML document, or "" when absent. The frontend serializes the block
// only when it holds nodes/wires and uses a stable format, so comparing the
// extracted substrings reliably detects whether the panel logic changed.
func extractLogicBlock(xml []byte) string {
s := string(xml)
start := strings.Index(s, "<logic>")
if start < 0 {
return ""
}
end := strings.Index(s[start:], "</logic>")
if end < 0 {
return ""
}
return s[start : start+end+len("</logic>")]
}
func metaToSignalInfo(m datasource.Metadata) signalInfo { func metaToSignalInfo(m datasource.Metadata) signalInfo {
return signalInfo{ return signalInfo{
Name: m.Name, Name: m.Name,
+8 -1
View File
@@ -15,6 +15,7 @@ import (
"github.com/uopi/uopi/internal/access" "github.com/uopi/uopi/internal/access"
"github.com/uopi/uopi/internal/api" "github.com/uopi/uopi/internal/api"
"github.com/uopi/uopi/internal/broker" "github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/controllogic"
"github.com/uopi/uopi/internal/datasource/stub" "github.com/uopi/uopi/internal/datasource/stub"
"github.com/uopi/uopi/internal/panelacl" "github.com/uopi/uopi/internal/panelacl"
"github.com/uopi/uopi/internal/storage" "github.com/uopi/uopi/internal/storage"
@@ -45,8 +46,14 @@ func setup(t *testing.T) (*httptest.Server, func()) {
t.Fatal("panelacl.New:", err) t.Fatal("panelacl.New:", err)
} }
clStore, err := controllogic.NewStore(dir)
if err != nil {
t.Fatal("controllogic.NewStore:", err)
}
clEngine := controllogic.NewEngine(ctx, brk, clStore, log)
mux := http.NewServeMux() mux := http.NewServeMux()
api.New(brk, nil, store, access.New("", nil, nil), acl, "", "", log).Register(mux, "/api/v1") api.New(brk, nil, store, access.New("", nil, nil, nil), acl, clStore, clEngine, "", "", log).Register(mux, "/api/v1")
srv := httptest.NewServer(mux) srv := httptest.NewServer(mux)
return srv, func() { return srv, func() {
+10
View File
@@ -50,6 +50,13 @@ type ServerConfig struct {
// Blacklist downgrades specific users' global access level. Everyone not // Blacklist downgrades specific users' global access level. Everyone not
// listed is trusted with full write access. // listed is trusted with full write access.
Blacklist []BlacklistEntry `toml:"blacklist"` Blacklist []BlacklistEntry `toml:"blacklist"`
// LogicEditors optionally restricts who may add or edit panel logic (the
// <logic> block of interfaces) and server-side control logic. Entries are
// usernames or group names. When empty, no restriction applies (any user
// with write access may edit logic). Anonymous/trusted-LAN callers are
// always permitted.
LogicEditors []string `toml:"logic_editors"`
} }
type DatasourceConfig struct { type DatasourceConfig struct {
@@ -131,6 +138,9 @@ func applyEnv(cfg *Config) {
if v := env("UOPI_SERVER_DEFAULT_USER"); v != "" { if v := env("UOPI_SERVER_DEFAULT_USER"); v != "" {
cfg.Server.DefaultUser = v cfg.Server.DefaultUser = v
} }
if v := env("UOPI_SERVER_LOGIC_EDITORS"); v != "" {
cfg.Server.LogicEditors = strings.Fields(v)
}
if v := env("UOPI_EPICS_CA_ADDR_LIST"); v != "" { if v := env("UOPI_EPICS_CA_ADDR_LIST"); v != "" {
cfg.Datasource.EPICS.CAAddrList = v cfg.Datasource.EPICS.CAAddrList = v
} }
+161
View File
@@ -0,0 +1,161 @@
// Minimal 5-field cron parser for control-logic cron triggers.
//
// Fields, in order: minute hour day-of-month month day-of-week.
// Each field supports:
//
// * any value
// */n every n (step over the whole range)
// a-b inclusive range
// a-b/n range with step
// a,b,c comma-separated list of the above
// N a single value
//
// Day-of-week is 0-6 with 0 = Sunday (7 is also accepted as Sunday). When both
// day-of-month and day-of-week are restricted (neither is "*"), the schedule
// matches when EITHER matches, following Vixie cron semantics.
package controllogic
import (
"fmt"
"strconv"
"strings"
"time"
)
// Schedule is a parsed 5-field cron specification.
type Schedule struct {
minute uint64 // bitmask 0..59
hour uint64 // bitmask 0..23
dom uint64 // bitmask 1..31
month uint64 // bitmask 1..12
dow uint64 // bitmask 0..6
domStar bool // day-of-month field was "*"
dowStar bool // day-of-week field was "*"
}
// ParseSchedule parses a 5-field cron spec. Extra whitespace is tolerated.
func ParseSchedule(spec string) (*Schedule, error) {
fields := strings.Fields(spec)
if len(fields) != 5 {
return nil, fmt.Errorf("cron spec must have 5 fields, got %d", len(fields))
}
s := &Schedule{}
var err error
if s.minute, err = parseField(fields[0], 0, 59); err != nil {
return nil, fmt.Errorf("minute: %w", err)
}
if s.hour, err = parseField(fields[1], 0, 23); err != nil {
return nil, fmt.Errorf("hour: %w", err)
}
if s.dom, err = parseField(fields[2], 1, 31); err != nil {
return nil, fmt.Errorf("day-of-month: %w", err)
}
if s.month, err = parseField(fields[3], 1, 12); err != nil {
return nil, fmt.Errorf("month: %w", err)
}
if s.dow, err = parseDOW(fields[4]); err != nil {
return nil, fmt.Errorf("day-of-week: %w", err)
}
s.domStar = strings.TrimSpace(fields[2]) == "*"
s.dowStar = strings.TrimSpace(fields[4]) == "*"
return s, nil
}
// Match reports whether t (truncated to the minute) satisfies the schedule.
func (s *Schedule) Match(t time.Time) bool {
if s.minute&bit(t.Minute()) == 0 {
return false
}
if s.hour&bit(t.Hour()) == 0 {
return false
}
if s.month&bit(int(t.Month())) == 0 {
return false
}
domMatch := s.dom&bit(t.Day()) != 0
dowMatch := s.dow&bit(int(t.Weekday())) != 0
// Vixie semantics: when both day fields are restricted, match either.
switch {
case s.domStar && s.dowStar:
return true
case s.domStar:
return dowMatch
case s.dowStar:
return domMatch
default:
return domMatch || dowMatch
}
}
func bit(n int) uint64 { return uint64(1) << uint(n) }
func parseDOW(field string) (uint64, error) {
// Normalise 7 → 0 (both mean Sunday) by parsing then folding.
mask, err := parseField(field, 0, 7)
if err != nil {
return 0, err
}
if mask&bit(7) != 0 {
mask = (mask &^ bit(7)) | bit(0)
}
return mask, nil
}
func parseField(field string, lo, hi int) (uint64, error) {
var mask uint64
for _, part := range strings.Split(field, ",") {
m, err := parsePart(strings.TrimSpace(part), lo, hi)
if err != nil {
return 0, err
}
mask |= m
}
if mask == 0 {
return 0, fmt.Errorf("empty field %q", field)
}
return mask, nil
}
func parsePart(part string, lo, hi int) (uint64, error) {
if part == "" {
return 0, fmt.Errorf("empty term")
}
step := 1
rangePart := part
if idx := strings.IndexByte(part, '/'); idx >= 0 {
rangePart = part[:idx]
st, err := strconv.Atoi(part[idx+1:])
if err != nil || st < 1 {
return 0, fmt.Errorf("bad step %q", part[idx+1:])
}
step = st
}
start, end := lo, hi
if rangePart != "*" {
if idx := strings.IndexByte(rangePart, '-'); idx >= 0 {
a, err1 := strconv.Atoi(rangePart[:idx])
b, err2 := strconv.Atoi(rangePart[idx+1:])
if err1 != nil || err2 != nil {
return 0, fmt.Errorf("bad range %q", rangePart)
}
start, end = a, b
} else {
v, err := strconv.Atoi(rangePart)
if err != nil {
return 0, fmt.Errorf("bad value %q", rangePart)
}
start, end = v, v
}
}
if start < lo || end > hi || start > end {
return 0, fmt.Errorf("value out of range [%d,%d] in %q", lo, hi, part)
}
var mask uint64
for v := start; v <= end; v += step {
mask |= bit(v)
}
return mask, nil
}
+92
View File
@@ -0,0 +1,92 @@
package controllogic
import (
"testing"
"time"
)
func mustSched(t *testing.T, spec string) *Schedule {
t.Helper()
s, err := ParseSchedule(spec)
if err != nil {
t.Fatalf("ParseSchedule(%q): %v", spec, err)
}
return s
}
func TestCronEveryMinute(t *testing.T) {
s := mustSched(t, "* * * * *")
if !s.Match(time.Date(2026, 6, 18, 12, 34, 0, 0, time.UTC)) {
t.Error("* * * * * should match any time")
}
}
func TestCronSpecificTime(t *testing.T) {
s := mustSched(t, "30 9 * * *")
if !s.Match(time.Date(2026, 6, 18, 9, 30, 0, 0, time.UTC)) {
t.Error("should match 09:30")
}
if s.Match(time.Date(2026, 6, 18, 9, 31, 0, 0, time.UTC)) {
t.Error("should not match 09:31")
}
}
func TestCronStep(t *testing.T) {
s := mustSched(t, "*/15 * * * *")
for _, m := range []int{0, 15, 30, 45} {
if !s.Match(time.Date(2026, 6, 18, 1, m, 0, 0, time.UTC)) {
t.Errorf("*/15 should match minute %d", m)
}
}
if s.Match(time.Date(2026, 6, 18, 1, 7, 0, 0, time.UTC)) {
t.Error("*/15 should not match minute 7")
}
}
func TestCronRangeAndList(t *testing.T) {
s := mustSched(t, "0 9-17 * * 1,2,3,4,5")
// 2026-06-18 is a Thursday (weekday 4).
if !s.Match(time.Date(2026, 6, 18, 10, 0, 0, 0, time.UTC)) {
t.Error("should match Thursday 10:00")
}
if s.Match(time.Date(2026, 6, 18, 18, 0, 0, 0, time.UTC)) {
t.Error("should not match 18:00 (out of 9-17)")
}
// 2026-06-20 is a Saturday (weekday 6).
if s.Match(time.Date(2026, 6, 20, 10, 0, 0, 0, time.UTC)) {
t.Error("should not match Saturday")
}
}
func TestCronDOWSunday7(t *testing.T) {
s := mustSched(t, "0 0 * * 7")
// 2026-06-21 is a Sunday.
if !s.Match(time.Date(2026, 6, 21, 0, 0, 0, 0, time.UTC)) {
t.Error("7 should mean Sunday")
}
}
func TestCronDomOrDow(t *testing.T) {
// When both day fields restricted, match either (Vixie semantics).
s := mustSched(t, "0 0 1 * 5")
// 2026-06-01 is a Monday — matches via day-of-month=1.
if !s.Match(time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)) {
t.Error("should match day-of-month 1")
}
// 2026-06-19 is a Friday (weekday 5) — matches via day-of-week.
if !s.Match(time.Date(2026, 6, 19, 0, 0, 0, 0, time.UTC)) {
t.Error("should match Friday")
}
// 2026-06-18 Thursday, not the 1st — no match.
if s.Match(time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC)) {
t.Error("should not match Thursday the 18th")
}
}
func TestCronInvalid(t *testing.T) {
for _, bad := range []string{"* * * *", "60 * * * *", "* 24 * * *", "* * 0 * *", "a * * * *", "*/0 * * * *"} {
if _, err := ParseSchedule(bad); err == nil {
t.Errorf("ParseSchedule(%q) should error", bad)
}
}
}
+688
View File
@@ -0,0 +1,688 @@
package controllogic
import (
"context"
"log/slog"
"math"
"regexp"
"strconv"
"strings"
"sync"
"time"
"github.com/uopi/uopi/internal/broker"
)
// Guards against runaway flows (cycles / pathological loops).
const (
maxSteps = 100000
maxLoop = 100000
)
// Engine runs all enabled control-logic graphs continuously under a root
// context. Reload tears down the current generation (subscriptions, timers,
// in-flight flows) and rebuilds from the store's enabled graphs.
type Engine struct {
broker *broker.Broker
store *Store
log *slog.Logger
root context.Context
mu sync.Mutex
cancel context.CancelFunc // cancels the current generation
wg *sync.WaitGroup // tracks the current generation's goroutines
// Shared live signal cache for the current generation (key "ds\0name").
liveMu sync.RWMutex
live map[string]float64
}
// NewEngine creates an engine bound to root. Call Reload to start it.
func NewEngine(root context.Context, brk *broker.Broker, store *Store, log *slog.Logger) *Engine {
return &Engine{
broker: brk,
store: store,
log: log,
root: root,
live: map[string]float64{},
}
}
// ── reference / value helpers ──────────────────────────────────────────────────
func refKey(ds, name string) string { return ds + "\x00" + name }
// parseRef splits a "ds:name" target on the FIRST ':' (EPICS PV names contain
// ':'). A bare name (no ':') is a graph-local variable in data source "local".
func parseRef(target string) (ds, name string, ok bool) {
t := strings.TrimSpace(target)
if t == "" {
return "", "", false
}
i := strings.IndexByte(t, ':')
if i < 0 {
return "local", t, true
}
return t[:i], t[i+1:], true
}
func toNum(v any) float64 {
switch x := v.(type) {
case float64:
return x
case float32:
return float64(x)
case int64:
return float64(x)
case int:
return float64(x)
case bool:
if x {
return 1
}
return 0
case string:
f, err := strconv.ParseFloat(strings.TrimSpace(x), 64)
if err != nil {
return math.NaN()
}
return f
default:
return math.NaN()
}
}
// ── lifecycle ───────────────────────────────────────────────────────────────
// Reload rebuilds the engine from the store. Safe to call repeatedly (after any
// graph mutation). It is a no-op-safe full restart of the running generation.
func (e *Engine) Reload() {
e.mu.Lock()
defer e.mu.Unlock()
// Tear down the previous generation and wait for its goroutines to exit.
if e.cancel != nil {
e.cancel()
e.cancel = nil
}
if e.wg != nil {
e.wg.Wait()
e.wg = nil
}
e.liveMu.Lock()
e.live = map[string]float64{}
e.liveMu.Unlock()
graphs := e.store.List()
var compiled []*compiledGraph
refs := map[string]RefLite{}
for i := range graphs {
g := graphs[i]
if !g.Enabled {
continue
}
cg := compile(g)
compiled = append(compiled, cg)
for k, r := range cg.refs {
refs[k] = r
}
}
if len(compiled) == 0 {
return
}
genCtx, cancel := context.WithCancel(e.root)
wg := &sync.WaitGroup{}
e.cancel = cancel
e.wg = wg
for _, cg := range compiled {
cg.engine = e
cg.genCtx = genCtx
cg.wg = wg
}
// One shared updates channel feeds a single dispatch goroutine; every
// subscription delivers into it. Subscriptions are released on teardown.
updates := make(chan broker.Update, 128)
var unsubs []func()
for _, r := range refs {
unsub, err := e.broker.Subscribe(broker.SignalRef{DS: r.DS, Name: r.Name}, updates)
if err != nil {
e.log.Warn("control logic: subscribe failed", "ds", r.DS, "signal", r.Name, "err", err)
continue
}
unsubs = append(unsubs, unsub)
}
wg.Add(1)
go func() {
defer wg.Done()
<-genCtx.Done()
for _, u := range unsubs {
u()
}
}()
// Dispatch goroutine: keep the live cache fresh and drive level/edge triggers.
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()
for _, cg := range compiled {
cg.onSignal(key, val)
}
case <-genCtx.Done():
return
}
}
}()
// Start timer and cron triggers.
for _, cg := range compiled {
cg.startTriggers()
}
e.log.Info("control logic engine reloaded", "graphs", len(compiled), "signals", len(refs))
}
// liveGet reads the current value of a signal from the shared cache.
func (e *Engine) liveGet(ds, name string) float64 {
if ds == "sys" {
if name == "time" {
return float64(time.Now().UnixNano()) / 1e9
}
return math.NaN() // sys:dt handled per-activation
}
e.liveMu.RLock()
defer e.liveMu.RUnlock()
v, ok := e.live[refKey(ds, name)]
if !ok {
return math.NaN()
}
return v
}
// write applies an action.write/lua-set to a target: a bare name updates a
// graph-local var; a ds:name target writes to the data source.
func (e *Engine) write(cg *compiledGraph, target string, val float64) {
ds, name, ok := parseRef(target)
if !ok || math.IsNaN(val) {
return
}
if ds == "local" {
cg.setLocal(name, val)
return
}
src, ok := e.broker.Source(ds)
if !ok {
e.log.Warn("control logic: write to unknown data source", "ds", ds, "signal", name)
return
}
if err := src.Write(e.root, name, val); err != nil {
e.log.Warn("control logic: write failed", "ds", ds, "signal", name, "err", err)
}
}
// ── compiled graph ─────────────────────────────────────────────────────────────
type wireOut struct {
to string
port string
}
// compiledGraph holds the runtime state for one enabled graph.
type compiledGraph struct {
engine *Engine
genCtx context.Context
wg *sync.WaitGroup
name string
byId map[string]Node
out map[string][]wireOut
inc map[string][]string // incoming source ids per node (for gates)
refs map[string]RefLite // unique signals to subscribe (excl. sys/local)
watchers map[string][]string // signal key → trigger node ids
luaNodes map[string]*luaRuntime
stateMu sync.Mutex
levelState map[string]bool // current truth of level triggers (threshold/alarm)
prevBool map[string]bool // edge detection for threshold/alarm
prevVal map[string]float64 // last value for change triggers
hasVal map[string]bool
lastFire map[string]int64 // ns wall clock each trigger last fired
locals map[string]float64
}
func compile(g Graph) *compiledGraph {
cg := &compiledGraph{
name: g.Name,
byId: map[string]Node{},
out: map[string][]wireOut{},
inc: map[string][]string{},
refs: map[string]RefLite{},
watchers: map[string][]string{},
luaNodes: map[string]*luaRuntime{},
levelState: map[string]bool{},
prevBool: map[string]bool{},
prevVal: map[string]float64{},
hasVal: map[string]bool{},
lastFire: map[string]int64{},
locals: map[string]float64{},
}
for _, n := range g.Nodes {
cg.byId[n.ID] = n
}
for _, w := range g.Wires {
port := w.FromPort
if port == "" {
port = "out"
}
cg.out[w.From] = append(cg.out[w.From], wireOut{to: w.To, port: port})
cg.inc[w.To] = append(cg.inc[w.To], w.From)
}
want := func(ds, name string) {
if ds == "sys" || ds == "local" {
return
}
if name == "" {
return
}
cg.refs[refKey(ds, name)] = RefLite{DS: ds, Name: name}
}
wantExpr := func(expr string) {
for _, r := range CollectRefs(expr) {
want(r.DS, r.Name)
}
}
for _, n := range g.Nodes {
switch n.Kind {
case "trigger.threshold", "trigger.change", "trigger.alarm":
if ds, name, ok := parseRef(n.param("signal")); ok {
want(ds, name)
key := refKey(ds, name)
cg.watchers[key] = append(cg.watchers[key], n.ID)
}
case "flow.if":
wantExpr(n.param("cond"))
case "flow.loop":
if n.param("mode") == "while" {
wantExpr(n.param("cond"))
}
case "action.write", "action.log":
wantExpr(n.param("expr"))
case "action.lua":
cg.luaNodes[n.ID] = newLuaRuntime(n.param("script"))
for _, r := range luaGetRefs(n.param("script")) {
want(r.DS, r.Name)
}
}
}
return cg
}
func (cg *compiledGraph) setLocal(name string, v float64) {
cg.stateMu.Lock()
cg.locals[name] = v
cg.stateMu.Unlock()
}
func (cg *compiledGraph) getLocal(name string) float64 {
cg.stateMu.Lock()
defer cg.stateMu.Unlock()
v, ok := cg.locals[name]
if !ok {
return 0
}
return v
}
// startTriggers launches timer and cron trigger goroutines for the generation.
func (cg *compiledGraph) startTriggers() {
hasCron := false
for _, n := range cg.byId {
switch n.Kind {
case "trigger.timer":
node := n
cg.wg.Add(1)
go func() {
defer cg.wg.Done()
d := intervalOf(node)
t := time.NewTicker(d)
defer t.Stop()
for {
select {
case <-t.C:
cg.activate(node.ID)
case <-cg.genCtx.Done():
return
}
}
}()
case "trigger.cron":
hasCron = true
}
}
if hasCron {
cg.startCron()
}
}
func (cg *compiledGraph) startCron() {
type cronNode struct {
id string
sched *Schedule
}
var crons []cronNode
for _, n := range cg.byId {
if n.Kind != "trigger.cron" {
continue
}
sched, err := ParseSchedule(n.param("spec"))
if err != nil {
cg.engine.log.Warn("control logic: bad cron spec", "graph", cg.name, "spec", n.param("spec"), "err", err)
continue
}
crons = append(crons, cronNode{id: n.ID, sched: sched})
}
if len(crons) == 0 {
return
}
cg.wg.Add(1)
go func() {
defer cg.wg.Done()
t := time.NewTicker(time.Second)
defer t.Stop()
lastMinute := -1
for {
select {
case now := <-t.C:
minute := now.Hour()*60 + now.Minute()
if minute == lastMinute {
continue // fire at most once per minute
}
lastMinute = minute
for _, c := range crons {
if c.sched.Match(now) {
cg.activate(c.id)
}
}
case <-cg.genCtx.Done():
return
}
}
}()
}
func intervalOf(n Node) time.Duration {
ms, err := strconv.Atoi(strings.TrimSpace(n.param("interval")))
if err != nil || ms < 50 {
ms = 1000
}
return time.Duration(ms) * time.Millisecond
}
// ── trigger evaluation ─────────────────────────────────────────────────────────
// onSignal drives threshold/alarm (rising edge) and change triggers when a
// watched signal updates.
func (cg *compiledGraph) onSignal(key string, value float64) {
for _, id := range cg.watchers[key] {
node, ok := cg.byId[id]
if !ok {
continue
}
switch node.Kind {
case "trigger.threshold":
cur := testThreshold(value, node.param("op"), parseFloat(node.param("value")))
cg.stateMu.Lock()
cg.levelState[id] = cur
prev := cg.prevBool[id]
cg.prevBool[id] = cur
cg.stateMu.Unlock()
if cur && !prev {
cg.activate(id)
}
case "trigger.alarm":
lo := parseFloat(node.param("min"))
hi := parseFloat(node.param("max"))
cur := !math.IsNaN(value) && (value < lo || value > hi)
cg.stateMu.Lock()
cg.levelState[id] = cur
prev := cg.prevBool[id]
cg.prevBool[id] = cur
cg.stateMu.Unlock()
if cur && !prev {
cg.activate(id)
}
case "trigger.change":
cg.stateMu.Lock()
had := cg.hasVal[id]
prev := cg.prevVal[id]
cg.prevVal[id] = value
cg.hasVal[id] = true
cg.stateMu.Unlock()
if had && value != prev {
cg.activate(id)
}
}
}
}
func testThreshold(val float64, op string, cmp float64) bool {
if math.IsNaN(val) {
return false
}
switch op {
case "<":
return val < cmp
case ">=":
return val >= cmp
case "<=":
return val <= cmp
case "==":
return val == cmp
case "!=":
return val != cmp
default: // ">"
return val > cmp
}
}
func parseFloat(s string) float64 {
f, err := strconv.ParseFloat(strings.TrimSpace(s), 64)
if err != nil {
return 0
}
return f
}
// ── execution ──────────────────────────────────────────────────────────────────
type runCtx struct {
fired string
steps int
resolve Resolver
}
// activate spawns a flow run for a trigger on its own goroutine so that
// action.delay does not block signal dispatch or other flows.
func (cg *compiledGraph) activate(triggerID string) {
now := time.Now().UnixNano()
cg.stateMu.Lock()
last, had := cg.lastFire[triggerID]
cg.lastFire[triggerID] = now
cg.stateMu.Unlock()
dt := 0.0
if had {
dt = float64(now-last) / 1e9
}
resolve := func(ds, name string) float64 {
switch ds {
case "sys":
if name == "dt" {
return dt
}
return cg.engine.liveGet("sys", name)
case "local":
return cg.getLocal(name)
default:
return cg.engine.liveGet(ds, name)
}
}
cg.wg.Add(1)
go func() {
defer cg.wg.Done()
ctx := &runCtx{fired: triggerID, resolve: resolve}
cg.follow(triggerID, "out", ctx)
}()
}
func (cg *compiledGraph) follow(fromID, port string, ctx *runCtx) {
for _, w := range cg.out[fromID] {
if w.port == port {
cg.run(w.to, ctx)
}
}
}
func (cg *compiledGraph) run(nodeID string, ctx *runCtx) {
if ctx.steps > maxSteps {
return
}
ctx.steps++
node, ok := cg.byId[nodeID]
if !ok {
return
}
select {
case <-cg.genCtx.Done():
return
default:
}
switch node.Kind {
case "gate.and":
if cg.gateSatisfied(node.ID, ctx.fired) {
cg.follow(node.ID, "out", ctx)
}
case "flow.if":
branch := "else"
if EvalBool(node.param("cond"), ctx.resolve) {
branch = "then"
}
cg.follow(node.ID, branch, ctx)
case "flow.loop":
if node.param("mode") == "while" {
for i := 0; i < maxLoop && ctx.steps <= maxSteps && EvalBool(node.param("cond"), ctx.resolve); i++ {
cg.follow(node.ID, "body", ctx)
}
} else {
n := int(EvalExpr(node.param("count"), ctx.resolve))
if n < 0 {
n = 0
}
if n > maxLoop {
n = maxLoop
}
for i := 0; i < n && ctx.steps <= maxSteps; i++ {
cg.follow(node.ID, "body", ctx)
}
}
cg.follow(node.ID, "done", ctx)
case "action.write":
val := EvalExpr(node.param("expr"), ctx.resolve)
cg.engine.write(cg, node.param("target"), val)
cg.follow(node.ID, "out", ctx)
case "action.delay":
ms := 0
if v, err := strconv.Atoi(strings.TrimSpace(node.param("ms"))); err == nil && v > 0 {
ms = v
}
if ms > 0 {
t := time.NewTimer(time.Duration(ms) * time.Millisecond)
select {
case <-t.C:
case <-cg.genCtx.Done():
t.Stop()
return
}
}
cg.follow(node.ID, "out", ctx)
case "action.log":
val := EvalExpr(node.param("expr"), ctx.resolve)
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)
case "action.lua":
cg.runLua(node.ID, ctx)
cg.follow(node.ID, "out", ctx)
default:
cg.follow(node.ID, "out", ctx)
}
}
// gateSatisfied: every incoming trigger must currently be satisfied. The firing
// trigger counts as satisfied; level triggers (threshold/alarm) use their truth.
func (cg *compiledGraph) gateSatisfied(gateID, fired string) bool {
inputs := cg.inc[gateID]
if len(inputs) == 0 {
return false
}
cg.stateMu.Lock()
defer cg.stateMu.Unlock()
for _, src := range inputs {
if src == fired || cg.levelState[src] {
continue
}
return false
}
return true
}
// luaGetRefs scans a Lua script for get("ds:name") / get('ds:name') literals so
// the engine can subscribe to the signals the script reads.
var luaGetRe = regexp.MustCompile(`get\s*\(\s*["']([^"']+)["']`)
func luaGetRefs(script string) []RefLite {
var out []RefLite
for _, m := range luaGetRe.FindAllStringSubmatch(script, -1) {
if ds, name, ok := parseRef(m[1]); ok {
out = append(out, RefLite{DS: ds, Name: name})
}
}
return out
}
func (cg *compiledGraph) runLua(nodeID string, ctx *runCtx) {
lr := cg.luaNodes[nodeID]
if lr == nil {
return
}
lr.run(ctx.resolve, func(target string, val float64) {
cg.engine.write(cg, target, val)
}, func(msg string) {
cg.engine.log.Info("control logic lua", "graph", cg.name, "msg", msg)
})
}
+527
View File
@@ -0,0 +1,527 @@
// Small, safe expression evaluator — a Go port of web/src/lib/expr.ts.
//
// Supports numbers, booleans (true/false → 1/0), arithmetic (+ - * / %),
// comparison (< <= > >= == !=), boolean (&& || !), ternary (a ? b : c),
// parentheses, and a handful of math functions. Two kinds of variable
// reference are resolved live at evaluation time:
//
// {ds:name} a data-source signal value (the brace content is split on the
// FIRST ':' so EPICS PV names like "MY:PV:NAME" work).
// bareIdent a graph-local state variable (data source "local").
//
// Booleans are represented as numbers: comparisons / logical ops yield 1 or 0,
// and any nonzero value is truthy. The evaluator never uses reflection or eval;
// it walks a parsed AST against a caller-supplied Resolver.
package controllogic
import (
"fmt"
"math"
"strconv"
"strings"
"sync"
)
// Resolver returns the current numeric value of a signal/local reference.
type Resolver func(ds, name string) float64
// RefLite identifies one signal/local reference read by an expression.
type RefLite struct {
DS string
Name string
}
// ── AST ──────────────────────────────────────────────────────────────────────
type exprNode interface{ eval(R Resolver) float64 }
type numNode struct{ v float64 }
type sigNode struct{ ds, name string }
type varNode struct{ name string }
type unNode struct {
op string
a exprNode
}
type binNode struct {
op string
a, b exprNode
}
type ternNode struct{ c, a, b exprNode }
type callNode struct {
fn string
args []exprNode
}
func (n numNode) eval(R Resolver) float64 { return n.v }
func (n sigNode) eval(R Resolver) float64 { return R(n.ds, n.name) }
func (n varNode) eval(R Resolver) float64 { return R("local", n.name) }
func (n unNode) eval(R Resolver) float64 {
if n.op == "-" {
return -n.a.eval(R)
}
if n.a.eval(R) == 0 {
return 1
}
return 0
}
func (n ternNode) eval(R Resolver) float64 {
if n.c.eval(R) != 0 {
return n.a.eval(R)
}
return n.b.eval(R)
}
func (n callNode) eval(R Resolver) float64 {
args := make([]float64, len(n.args))
for i, a := range n.args {
args[i] = a.eval(R)
}
return funcs[n.fn](args)
}
func (n binNode) eval(R Resolver) float64 {
a, b := n.a.eval(R), n.b.eval(R)
switch n.op {
case "+":
return a + b
case "-":
return a - b
case "*":
return a * b
case "/":
return a / b
case "%":
return math.Mod(a, b)
case "<":
return boolf(a < b)
case "<=":
return boolf(a <= b)
case ">":
return boolf(a > b)
case ">=":
return boolf(a >= b)
case "==":
return boolf(a == b)
case "!=":
return boolf(a != b)
case "&&":
return boolf(a != 0 && b != 0)
case "||":
return boolf(a != 0 || b != 0)
}
return math.NaN()
}
func boolf(b bool) float64 {
if b {
return 1
}
return 0
}
var funcs = map[string]func([]float64) float64{
"abs": func(a []float64) float64 { return math.Abs(a[0]) },
"min": func(a []float64) float64 { return minSlice(a) },
"max": func(a []float64) float64 { return maxSlice(a) },
"sqrt": func(a []float64) float64 { return math.Sqrt(a[0]) },
"floor": func(a []float64) float64 { return math.Floor(a[0]) },
"ceil": func(a []float64) float64 { return math.Ceil(a[0]) },
"round": func(a []float64) float64 { return math.Round(a[0]) },
"sign": func(a []float64) float64 { return float64(sign(a[0])) },
"pow": func(a []float64) float64 { return math.Pow(a[0], a[1]) },
"log": func(a []float64) float64 { return math.Log(a[0]) },
"exp": func(a []float64) float64 { return math.Exp(a[0]) },
"sin": func(a []float64) float64 { return math.Sin(a[0]) },
"cos": func(a []float64) float64 { return math.Cos(a[0]) },
}
func minSlice(a []float64) float64 {
if len(a) == 0 {
return math.Inf(1)
}
m := a[0]
for _, x := range a[1:] {
m = math.Min(m, x)
}
return m
}
func maxSlice(a []float64) float64 {
if len(a) == 0 {
return math.Inf(-1)
}
m := a[0]
for _, x := range a[1:] {
m = math.Max(m, x)
}
return m
}
func sign(x float64) int {
switch {
case x > 0:
return 1
case x < 0:
return -1
default:
return 0
}
}
// ── Tokenizer ────────────────────────────────────────────────────────────────
type tok struct {
k string
v string
}
func tokenize(src string) ([]tok, error) {
var toks []tok
two := map[string]bool{"<=": true, ">=": true, "==": true, "!=": true, "&&": true, "||": true}
r := []rune(src)
i := 0
for i < len(r) {
c := r[i]
switch {
case c == ' ' || c == '\t' || c == '\n' || c == '\r':
i++
continue
case c == '{':
end := -1
for j := i + 1; j < len(r); j++ {
if r[j] == '}' {
end = j
break
}
}
if end < 0 {
return nil, fmt.Errorf("unterminated { in expression")
}
toks = append(toks, tok{k: "sig", v: string(r[i+1 : end])})
i = end + 1
continue
}
if isDigit(c) || (c == '.' && i+1 < len(r) && isDigit(r[i+1])) {
j := i + 1
for j < len(r) && (isDigit(r[j]) || r[j] == '.') {
j++
}
toks = append(toks, tok{k: "num", v: string(r[i:j])})
i = j
continue
}
if isIdentStart(c) {
j := i + 1
for j < len(r) && isIdentPart(r[j]) {
j++
}
toks = append(toks, tok{k: "ident", v: string(r[i:j])})
i = j
continue
}
if i+1 < len(r) {
pair := string(r[i : i+2])
if two[pair] {
toks = append(toks, tok{k: pair})
i += 2
continue
}
}
if strings.ContainsRune("+-*/%<>!()?:,", c) {
toks = append(toks, tok{k: string(c)})
i++
continue
}
return nil, fmt.Errorf("unexpected character %q in expression", string(c))
}
return toks, nil
}
func isDigit(c rune) bool { return c >= '0' && c <= '9' }
func isIdentStart(c rune) bool { return c == '_' || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') }
func isIdentPart(c rune) bool { return isIdentStart(c) || isDigit(c) }
// ── Parser (recursive descent) ────────────────────────────────────────────────
type parser struct {
toks []tok
p int
}
func (ps *parser) peek() (tok, bool) {
if ps.p < len(ps.toks) {
return ps.toks[ps.p], true
}
return tok{}, false
}
func (ps *parser) eat(k string) (tok, error) {
if ps.p >= len(ps.toks) {
return tok{}, fmt.Errorf("unexpected end of expression")
}
t := ps.toks[ps.p]
if k != "" && t.k != k {
return tok{}, fmt.Errorf("expected %q in expression", k)
}
ps.p++
return t, nil
}
func parse(src string) (exprNode, error) {
toks, err := tokenize(src)
if err != nil {
return nil, err
}
ps := &parser{toks: toks}
root, err := ps.ternary()
if err != nil {
return nil, err
}
if ps.p < len(ps.toks) {
return nil, fmt.Errorf("trailing tokens in expression")
}
return root, nil
}
func (ps *parser) primary() (exprNode, error) {
t, ok := ps.peek()
if !ok {
return nil, fmt.Errorf("unexpected end of expression")
}
switch t.k {
case "num":
ps.eat("")
v, err := strconv.ParseFloat(t.v, 64)
if err != nil {
return nil, fmt.Errorf("bad number %q", t.v)
}
return numNode{v: v}, nil
case "sig":
ps.eat("")
idx := strings.IndexByte(t.v, ':')
if idx < 0 {
return sigNode{ds: t.v, name: ""}, nil
}
return sigNode{ds: t.v[:idx], name: t.v[idx+1:]}, nil
case "ident":
ps.eat("")
id := t.v
if id == "true" {
return numNode{v: 1}, nil
}
if id == "false" {
return numNode{v: 0}, nil
}
if nx, ok := ps.peek(); ok && nx.k == "(" {
ps.eat("(")
var args []exprNode
if nx2, ok := ps.peek(); ok && nx2.k != ")" {
a, err := ps.ternary()
if err != nil {
return nil, err
}
args = append(args, a)
for {
nx3, ok := ps.peek()
if !ok || nx3.k != "," {
break
}
ps.eat(",")
a, err := ps.ternary()
if err != nil {
return nil, err
}
args = append(args, a)
}
}
if _, err := ps.eat(")"); err != nil {
return nil, err
}
if _, ok := funcs[id]; !ok {
return nil, fmt.Errorf("unknown function %q", id)
}
return callNode{fn: id, args: args}, nil
}
return varNode{name: id}, nil
case "(":
ps.eat("(")
e, err := ps.ternary()
if err != nil {
return nil, err
}
if _, err := ps.eat(")"); err != nil {
return nil, err
}
return e, nil
}
return nil, fmt.Errorf("unexpected token %q in expression", t.k)
}
func (ps *parser) unary() (exprNode, error) {
if t, ok := ps.peek(); ok && (t.k == "-" || t.k == "!") {
ps.eat("")
a, err := ps.unary()
if err != nil {
return nil, err
}
return unNode{op: t.k, a: a}, nil
}
return ps.primary()
}
func (ps *parser) binLevel(next func() (exprNode, error), ops ...string) (exprNode, error) {
a, err := next()
if err != nil {
return nil, err
}
for {
t, ok := ps.peek()
if !ok || !contains(ops, t.k) {
return a, nil
}
op, _ := ps.eat("")
b, err := next()
if err != nil {
return nil, err
}
a = binNode{op: op.k, a: a, b: b}
}
}
func (ps *parser) mul() (exprNode, error) { return ps.binLevel(ps.unary, "*", "/", "%") }
func (ps *parser) add() (exprNode, error) { return ps.binLevel(ps.mul, "+", "-") }
func (ps *parser) cmp() (exprNode, error) { return ps.binLevel(ps.add, "<", "<=", ">", ">=") }
func (ps *parser) eq() (exprNode, error) { return ps.binLevel(ps.cmp, "==", "!=") }
func (ps *parser) and() (exprNode, error) { return ps.binLevel(ps.eq, "&&") }
func (ps *parser) or() (exprNode, error) { return ps.binLevel(ps.and, "||") }
func (ps *parser) ternary() (exprNode, error) {
c, err := ps.or()
if err != nil {
return nil, err
}
if t, ok := ps.peek(); ok && t.k == "?" {
ps.eat("?")
a, err := ps.ternary()
if err != nil {
return nil, err
}
if _, err := ps.eat(":"); err != nil {
return nil, err
}
b, err := ps.ternary()
if err != nil {
return nil, err
}
return ternNode{c: c, a: a, b: b}, nil
}
return c, nil
}
func contains(s []string, v string) bool {
for _, x := range s {
if x == v {
return true
}
}
return false
}
// ── Cache + public API ─────────────────────────────────────────────────────────
type cacheEntry struct {
node exprNode
err error
}
var (
cacheMu sync.Mutex
cache = map[string]cacheEntry{}
)
func parseCached(src string) (exprNode, error) {
cacheMu.Lock()
e, ok := cache[src]
cacheMu.Unlock()
if ok {
return e.node, e.err
}
n, err := parse(src)
cacheMu.Lock()
cache[src] = cacheEntry{node: n, err: err}
cacheMu.Unlock()
return n, err
}
// EvalExpr evaluates an expression string, returning NaN on parse/eval failure.
func EvalExpr(src string, resolve Resolver) float64 {
n, err := parseCached(src)
if err != nil {
return math.NaN()
}
return safeEval(n, resolve)
}
func safeEval(n exprNode, resolve Resolver) (out float64) {
defer func() {
if recover() != nil {
out = math.NaN()
}
}()
return n.eval(resolve)
}
// EvalBool reports whether the expression evaluates to a nonzero, non-NaN value.
func EvalBool(src string, resolve Resolver) bool {
v := EvalExpr(src, resolve)
return !math.IsNaN(v) && v != 0
}
// CollectRefs returns every signal/local reference an expression reads, for
// subscription. Returns nil for an unparseable expression.
func CollectRefs(src string) []RefLite {
root, err := parseCached(src)
if err != nil {
return nil
}
var out []RefLite
seen := map[string]bool{}
add := func(ds, name string) {
k := ds + "\x00" + name
if !seen[k] {
seen[k] = true
out = append(out, RefLite{DS: ds, Name: name})
}
}
var walk func(n exprNode)
walk = func(n exprNode) {
switch t := n.(type) {
case sigNode:
add(t.ds, t.name)
case varNode:
add("local", t.name)
case unNode:
walk(t.a)
case binNode:
walk(t.a)
walk(t.b)
case ternNode:
walk(t.c)
walk(t.a)
walk(t.b)
case callNode:
for _, a := range t.args {
walk(a)
}
}
}
walk(root)
return out
}
// CheckExpr validates an expression; returns an error message or "" if it parses.
func CheckExpr(src string) string {
if strings.TrimSpace(src) == "" {
return ""
}
if _, err := parse(src); err != nil {
return err.Error()
}
return ""
}
+86
View File
@@ -0,0 +1,86 @@
package controllogic
import (
"math"
"testing"
)
func TestEvalExpr(t *testing.T) {
resolve := func(ds, name string) float64 {
switch {
case ds == "stub" && name == "x":
return 10
case ds == "local" && name == "y":
return 3
case ds == "sys" && name == "dt":
return 0.5
}
return math.NaN()
}
cases := []struct {
expr string
want float64
}{
{"1 + 2 * 3", 7},
{"(1 + 2) * 3", 9},
{"10 % 3", 1},
{"{stub:x} + y", 13},
{"{stub:x} > 5 ? 1 : 0", 1},
{"min(4, 2, 8)", 2},
{"max(4, 2, 8)", 8},
{"abs(-5)", 5},
{"sqrt(16)", 4},
{"2 < 3 && 3 < 4", 1},
{"!0", 1},
{"-{stub:x}", -10},
{"{sys:dt} * 2", 1},
{"true + false", 1},
}
for _, c := range cases {
got := EvalExpr(c.expr, resolve)
if math.Abs(got-c.want) > 1e-9 {
t.Errorf("EvalExpr(%q) = %v, want %v", c.expr, got, c.want)
}
}
}
func TestEvalExprErrors(t *testing.T) {
r := func(ds, name string) float64 { return 0 }
for _, bad := range []string{"1 +", "(1", "1 2", "{unterminated"} {
if v := EvalExpr(bad, r); !math.IsNaN(v) {
t.Errorf("EvalExpr(%q) = %v, want NaN", bad, v)
}
}
}
func TestCollectRefs(t *testing.T) {
refs := CollectRefs("{stub:x} + y + {epics:MY:PV} * z")
got := map[string]bool{}
for _, r := range refs {
got[r.DS+":"+r.Name] = true
}
for _, want := range []string{"stub:x", "local:y", "epics:MY:PV", "local:z"} {
if !got[want] {
t.Errorf("CollectRefs missing %q (got %v)", want, got)
}
}
}
func TestCheckExpr(t *testing.T) {
if msg := CheckExpr("1 + 2"); msg != "" {
t.Errorf("CheckExpr valid returned %q", msg)
}
if msg := CheckExpr("1 +"); msg == "" {
t.Errorf("CheckExpr invalid returned empty")
}
if msg := CheckExpr(""); msg != "" {
t.Errorf("CheckExpr empty returned %q", msg)
}
}
func TestEpicsRefSplitFirstColon(t *testing.T) {
refs := CollectRefs("{epics:SR:BPM:01:X}")
if len(refs) != 1 || refs[0].DS != "epics" || refs[0].Name != "SR:BPM:01:X" {
t.Errorf("got %+v, want epics / SR:BPM:01:X", refs)
}
}
+115
View File
@@ -0,0 +1,115 @@
package controllogic
import (
"sync"
lua "github.com/yuin/gopher-lua"
)
// luaRuntime holds a sandboxed Lua VM for one action.lua node. The VM is created
// lazily and reused; access is serialised so concurrent flow activations of the
// same node don't share VM state unsafely. Host functions exposed to scripts:
//
// get("ds:name") → number read a signal / sys / local value (NaN if absent)
// set("ds:name", v) write a value (bare name → graph-local var)
// log(msg) append to the server log
//
// The os, io, package, debug and code-loading globals are removed, mirroring the
// dsp LuaNode sandbox.
type luaRuntime struct {
script string
mu sync.Mutex
L *lua.LState
// Bound to the current activation while run() holds mu.
curResolve Resolver
curSet func(target string, val float64)
curLog func(msg string)
}
func newLuaRuntime(script string) *luaRuntime {
return &luaRuntime{script: script}
}
func (lr *luaRuntime) ensure() error {
if lr.L != nil {
return nil
}
L := lua.NewState(lua.Options{SkipOpenLibs: true})
for _, pair := range []struct {
name string
fn lua.LGFunction
}{
{lua.LoadLibName, lua.OpenPackage},
{lua.BaseLibName, lua.OpenBase},
{lua.MathLibName, lua.OpenMath},
{lua.StringLibName, lua.OpenString},
{lua.TabLibName, lua.OpenTable},
} {
if err := L.CallByParam(lua.P{Fn: L.NewFunction(pair.fn), NRet: 0, Protect: true}, lua.LString(pair.name)); err != nil {
L.Close()
return err
}
}
L.SetGlobal("load", lua.LNil)
L.SetGlobal("loadfile", lua.LNil)
L.SetGlobal("dofile", lua.LNil)
L.SetGlobal("require", lua.LNil)
L.SetGlobal("get", L.NewFunction(func(s *lua.LState) int {
target := s.CheckString(1)
ds, name, ok := parseRef(target)
var v float64
if ok && lr.curResolve != nil {
v = lr.curResolve(ds, name)
}
s.Push(lua.LNumber(v))
return 1
}))
L.SetGlobal("set", L.NewFunction(func(s *lua.LState) int {
target := s.CheckString(1)
val := float64(s.CheckNumber(2))
if lr.curSet != nil {
lr.curSet(target, val)
}
return 0
}))
L.SetGlobal("log", L.NewFunction(func(s *lua.LState) int {
if lr.curLog != nil {
lr.curLog(s.CheckString(1))
}
return 0
}))
lr.L = L
return nil
}
// run executes the script once with the given host hooks bound.
func (lr *luaRuntime) run(resolve Resolver, set func(string, float64), logf func(string)) {
lr.mu.Lock()
defer lr.mu.Unlock()
if err := lr.ensure(); err != nil {
logf("lua init error: " + err.Error())
return
}
lr.curResolve = resolve
lr.curSet = set
lr.curLog = logf
defer func() {
lr.curResolve = nil
lr.curSet = nil
lr.curLog = nil
if r := recover(); r != nil {
logf("lua panic")
}
}()
lr.L.SetTop(0)
if err := lr.L.DoString(lr.script); err != nil {
logf("lua error: " + err.Error())
}
}
+65
View File
@@ -0,0 +1,65 @@
// Package controllogic implements a server-side flow-graph engine. It mirrors
// the client-side panel logic engine (web/src/lib/logic.ts) but runs
// continuously on the server under the root context, independent of any panel.
//
// A control-logic graph is a Node-RED-style flow of trigger, gate, control-flow
// and action nodes connected by wires. Triggers are flow entry points; when one
// activates the engine follows the outgoing wires executing downstream nodes.
//
// Compared with the panel engine, control logic has no button trigger (buttons
// are panel-UI driven) and gains two headless triggers — cron (5-field schedule)
// and alarm (signal out of an allowed range) — plus a Lua script action node
// with get/set/log host functions.
//
// Graphs are persisted as JSON in {storageDir}/controllogic.json and CRUD'd via
// the REST API; the engine rebuilds itself (Reload) whenever they change.
package controllogic
// Node kinds. Triggers begin flows; the rest execute downstream.
//
// trigger.threshold — fires on the rising edge of cmp(signal, value).
// trigger.change — fires whenever a watched signal's value changes.
// trigger.timer — fires every `interval` ms.
// trigger.cron — fires when the wall clock matches a 5-field cron `spec`.
// trigger.alarm — fires on the rising edge of signal leaving [min,max].
// gate.and — passes only when all incoming triggers are satisfied.
// flow.if — evaluates `cond`, continues on the 'then'/'else' port.
// flow.loop — repeats the 'body' port (count / while, capped) then 'done'.
// action.write — evaluates `expr`, writes the result to `target`.
// action.delay — waits `ms` before continuing.
// action.log — logs an expression value to the server log.
// action.lua — runs a sandboxed Lua script with get/set/log host funcs.
// Node is a single node in a control-logic graph. Params are stored as strings
// (matching the panel logic model) and parsed per-kind by the engine.
type Node struct {
ID string `json:"id"`
Kind string `json:"kind"`
X float64 `json:"x"`
Y float64 `json:"y"`
Params map[string]string `json:"params,omitempty"`
}
// Wire connects an output port of one node to the input of another.
// FromPort defaults to "out" when empty.
type Wire struct {
From string `json:"from"`
FromPort string `json:"fromPort,omitempty"`
To string `json:"to"`
}
// Graph is a named, independently-enableable control-logic flow.
type Graph struct {
ID string `json:"id"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
Nodes []Node `json:"nodes"`
Wires []Wire `json:"wires"`
}
func (n Node) param(key string) string {
if n.Params == nil {
return ""
}
return n.Params[key]
}
+111
View File
@@ -0,0 +1,111 @@
package controllogic
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sync"
)
const definitionsFile = "controllogic.json"
// ErrNotFound is returned when a graph id does not exist.
var ErrNotFound = errors.New("control logic graph not found")
// Store persists control-logic graphs as a single JSON file in the storage dir.
// Writes are atomic (tmp file + rename); all access is mutex-guarded.
type Store struct {
mu sync.RWMutex
path string
items map[string]Graph
}
// NewStore opens (or initialises) the control-logic store under storageDir.
func NewStore(storageDir string) (*Store, error) {
s := &Store{
path: filepath.Join(storageDir, definitionsFile),
items: map[string]Graph{},
}
if err := s.load(); err != nil {
return nil, err
}
return s, nil
}
func (s *Store) load() error {
data, err := os.ReadFile(s.path)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return err
}
var graphs []Graph
if err := json.Unmarshal(data, &graphs); err != nil {
return fmt.Errorf("parse %s: %w", s.path, err)
}
for _, g := range graphs {
s.items[g.ID] = g
}
return nil
}
// saveLocked writes the current set atomically. Caller must hold s.mu.
func (s *Store) saveLocked() error {
graphs := make([]Graph, 0, len(s.items))
for _, g := range s.items {
graphs = append(graphs, g)
}
data, err := json.MarshalIndent(graphs, "", " ")
if err != nil {
return err
}
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, data, 0o644); err != nil {
return err
}
return os.Rename(tmp, s.path)
}
// List returns all graphs.
func (s *Store) List() []Graph {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]Graph, 0, len(s.items))
for _, g := range s.items {
out = append(out, g)
}
return out
}
// Get returns a single graph by id.
func (s *Store) Get(id string) (Graph, error) {
s.mu.RLock()
defer s.mu.RUnlock()
g, ok := s.items[id]
if !ok {
return Graph{}, ErrNotFound
}
return g, nil
}
// Save inserts or replaces a graph and persists the store.
func (s *Store) Save(g Graph) error {
s.mu.Lock()
defer s.mu.Unlock()
s.items[g.ID] = g
return s.saveLocked()
}
// Delete removes a graph by id.
func (s *Store) Delete(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.items[id]; !ok {
return ErrNotFound
}
delete(s.items, id)
return s.saveLocked()
}
+3 -2
View File
@@ -11,6 +11,7 @@ import (
"github.com/uopi/uopi/internal/access" "github.com/uopi/uopi/internal/access"
"github.com/uopi/uopi/internal/api" "github.com/uopi/uopi/internal/api"
"github.com/uopi/uopi/internal/broker" "github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/controllogic"
"github.com/uopi/uopi/internal/datasource/synthetic" "github.com/uopi/uopi/internal/datasource/synthetic"
"github.com/uopi/uopi/internal/metrics" "github.com/uopi/uopi/internal/metrics"
"github.com/uopi/uopi/internal/panelacl" "github.com/uopi/uopi/internal/panelacl"
@@ -26,7 +27,7 @@ type Server struct {
// New creates the HTTP server, registers all routes, and returns a ready-to-start Server. // New creates the HTTP server, registers all routes, and returns a ready-to-start Server.
// synth may be nil if the synthetic data source is not enabled. // synth may be nil if the synthetic data source is not enabled.
func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, policy *access.Policy, acl *panelacl.Store, channelFinderURL, archiverURL, trustedUserHeader string, log *slog.Logger) *Server { func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, policy *access.Policy, acl *panelacl.Store, ctrlLogic *controllogic.Store, ctrlEngine *controllogic.Engine, channelFinderURL, archiverURL, trustedUserHeader string, log *slog.Logger) *Server {
mux := http.NewServeMux() mux := http.NewServeMux()
// Health check // Health check
@@ -44,7 +45,7 @@ func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Syntheti
// REST API — registered on a dedicated mux so it can be wrapped with the // REST API — registered on a dedicated mux so it can be wrapped with the
// access-control middleware (identity resolution + global level enforcement). // access-control middleware (identity resolution + global level enforcement).
apiMux := http.NewServeMux() apiMux := http.NewServeMux()
api.New(brk, synth, store, policy, acl, channelFinderURL, archiverURL, log).Register(apiMux, apiPrefix) api.New(brk, synth, store, policy, acl, ctrlLogic, ctrlEngine, channelFinderURL, archiverURL, log).Register(apiMux, apiPrefix)
mux.Handle(apiPrefix+"/", accessMiddleware(policy, trustedUserHeader, apiMux)) mux.Handle(apiPrefix+"/", accessMiddleware(policy, trustedUserHeader, apiMux))
// Embedded frontend — must be last (catch-all) // Embedded frontend — must be last (catch-all)
+7 -2
View File
@@ -18,11 +18,17 @@ default_user = ""
# user = "guest" # user = "guest"
# level = "readonly" # level = "readonly"
# Named sets of users, referenced by per-panel sharing rules (future phases). # Named sets of users, referenced by per-panel sharing rules.
# [[groups]] # [[groups]]
# name = "operators" # name = "operators"
# members = ["alice", "bob"] # 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"]
[datasource.epics] [datasource.epics]
enabled = true enabled = true
ca_addr_list = "" # overrides EPICS_CA_ADDR_LIST if set ca_addr_list = "" # overrides EPICS_CA_ADDR_LIST if set
@@ -32,4 +38,3 @@ auto_sync_filter = "" # e.g. area=StorageRing or tags=production
[datasource.synthetic] [datasource.synthetic]
enabled = true enabled = true
definitions_file = "./synthetic.json"
+43 -4
View File
@@ -1,7 +1,8 @@
import { h } from 'preact'; import { h, Fragment } from 'preact';
import { useState, useEffect } from 'preact/hooks'; import { useState, useEffect } from 'preact/hooks';
import { initLocalState } from './lib/localstate'; import { initLocalState } from './lib/localstate';
import { logicEngine } from './lib/logic'; import { logicEngine } from './lib/logic';
import { getWidgetCmdStore, type WidgetCmd } from './lib/widgetCommands';
import type { Interface, Widget, SignalRef } from './lib/types'; import type { Interface, Widget, SignalRef } from './lib/types';
import TextView from './widgets/TextView'; import TextView from './widgets/TextView';
import TextLabel from './widgets/TextLabel'; import TextLabel from './widgets/TextLabel';
@@ -18,6 +19,7 @@ import LinkWidget from './widgets/LinkWidget';
import ContextMenu from './ContextMenu'; import ContextMenu from './ContextMenu';
import InfoPanel from './InfoPanel'; import InfoPanel from './InfoPanel';
import SplitLayout from './SplitLayout'; import SplitLayout from './SplitLayout';
import LogicDialogs from './LogicDialogs';
const COMPONENTS: Record<string, any> = { const COMPONENTS: Record<string, any> = {
textview: TextView, textview: TextView,
@@ -41,6 +43,32 @@ interface CtxState {
signal: SignalRef | null; signal: SignalRef | null;
} }
// Wraps a single view-mode widget so panel logic (action.widget) can drive it:
// `hidden` removes it from the view, `disabled` dims it and blocks interaction
// via a transparent overlay (which still surfaces the right-click menu). Plot
// pause/clear are handled inside PlotWidget itself.
function WidgetView({ widget, children, onContextMenu }: {
widget: Widget;
children: any;
onContextMenu: (e: MouseEvent) => void;
key?: string;
}) {
const [cmd, setCmd] = useState<WidgetCmd>(() => getWidgetCmdStore(widget.id).get());
useEffect(() => getWidgetCmdStore(widget.id).subscribe(setCmd), [widget.id]);
if (cmd.hidden) return null;
if (!cmd.disabled) return children;
return (
<Fragment>
{children}
<div
class="widget-disable-overlay"
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
onContextMenu={onContextMenu}
/>
</Fragment>
);
}
interface Props { interface Props {
iface: Interface | null; iface: Interface | null;
onNavigate?: (interfaceId: string) => void; onNavigate?: (interfaceId: string) => void;
@@ -119,6 +147,8 @@ export default function Canvas({ iface, onNavigate, timeRange }: Props) {
{infoSignal && ( {infoSignal && (
<InfoPanel signal={infoSignal} onClose={() => setInfoSignal(null)} /> <InfoPanel signal={infoSignal} onClose={() => setInfoSignal(null)} />
)} )}
<LogicDialogs />
</div> </div>
); );
} }
@@ -128,9 +158,8 @@ export default function Canvas({ iface, onNavigate, timeRange }: Props) {
<div class="canvas-area" style={`width:${iface.w}px;height:${iface.h}px;`}> <div class="canvas-area" style={`width:${iface.w}px;height:${iface.h}px;`}>
{iface.widgets.map(widget => { {iface.widgets.map(widget => {
const Comp = COMPONENTS[widget.type]; const Comp = COMPONENTS[widget.type];
return Comp const inner = Comp
? <Comp ? <Comp
key={widget.id}
widget={widget} widget={widget}
onContextMenu={(e: MouseEvent) => onCtxMenu(e, widget)} onContextMenu={(e: MouseEvent) => onCtxMenu(e, widget)}
onNavigate={onNavigate} onNavigate={onNavigate}
@@ -138,7 +167,6 @@ export default function Canvas({ iface, onNavigate, timeRange }: Props) {
/> />
: ( : (
<div <div
key={widget.id}
class="unknown-widget" class="unknown-widget"
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`} style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
title={`Unknown widget type: ${widget.type}`} title={`Unknown widget type: ${widget.type}`}
@@ -147,6 +175,15 @@ export default function Canvas({ iface, onNavigate, timeRange }: Props) {
<span class="unknown-label">{widget.type}</span> <span class="unknown-label">{widget.type}</span>
</div> </div>
); );
return (
<WidgetView
key={widget.id}
widget={widget}
onContextMenu={(e: MouseEvent) => onCtxMenu(e, widget)}
>
{inner}
</WidgetView>
);
})} })}
</div> </div>
@@ -159,6 +196,8 @@ export default function Canvas({ iface, onNavigate, timeRange }: Props) {
{infoSignal && ( {infoSignal && (
<InfoPanel signal={infoSignal} onClose={() => setInfoSignal(null)} /> <InfoPanel signal={infoSignal} onClose={() => setInfoSignal(null)} />
)} )}
<LogicDialogs />
</div> </div>
); );
} }
+4
View File
@@ -12,6 +12,8 @@ const TIPS: Record<string, Array<{ text: string; section?: string }>> = {
{ text: 'Right-click a widget to view signal info or export data as CSV.', section: 'view' }, { text: 'Right-click a widget to view signal info or export data as CSV.', section: 'view' },
{ text: 'The ● Live button shows live streaming is active. Use the date pickers to load historical data.', section: 'history' }, { text: 'The ● Live button shows live streaming is active. Use the date pickers to load historical data.', section: 'history' },
{ text: 'Click Edit (top-right) to open the drag-and-drop panel builder.', section: 'edit' }, { text: 'Click Edit (top-right) to open the drag-and-drop panel builder.', section: 'edit' },
{ text: 'Use the Share button on a panel to grant access to users or groups.', section: 'sharing' },
{ text: 'The ⚙ Control logic button opens server-side automation that runs even with no panel open.', section: 'control' },
{ text: 'The dot in the status chip turns green when the WebSocket is connected.', section: 'view' }, { text: 'The dot in the status chip turns green when the WebSocket is connected.', section: 'view' },
], ],
edit: [ edit: [
@@ -20,6 +22,8 @@ const TIPS: Record<string, Array<{ text: string; section?: string }>> = {
{ text: 'Ctrl+click to select multiple widgets; then use the align toolbar.', section: 'edit' }, { text: 'Ctrl+click to select multiple widgets; then use the align toolbar.', section: 'edit' },
{ text: 'Ctrl+Z to undo, Ctrl+Y to redo. Up to 50 steps are remembered.', section: 'shortcuts' }, { text: 'Ctrl+Z to undo, Ctrl+Y to redo. Up to 50 steps are remembered.', section: 'shortcuts' },
{ text: 'Use "+ Synthetic" in the signal tree to create computed/filtered signals.', section: 'signals' }, { text: 'Use "+ Synthetic" in the signal tree to create computed/filtered signals.', section: 'signals' },
{ text: 'Switch to the Logic tab to add interactive behaviour with a node-graph flow editor.', section: 'logic' },
{ text: 'Add Local variables for set-points and counters that live inside the panel.', section: 'edit' },
{ text: 'Arrow keys nudge the selected widget by 1 px (Shift for grid size).', section: 'shortcuts' }, { text: 'Arrow keys nudge the selected widget by 1 px (Shift for grid size).', section: 'shortcuts' },
], ],
}; };
+826
View File
@@ -0,0 +1,826 @@
import { h, Fragment } from 'preact';
import { useState, useEffect, useRef } from 'preact/hooks';
import SearchableSelect from './SearchableSelect';
import LuaEditor from './LuaEditor';
import { checkExpr } from './lib/expr';
// ── Types (mirror internal/controllogic/model.go) ────────────────────────────
type CLNodeKind =
| 'trigger.threshold'
| 'trigger.change'
| 'trigger.timer'
| 'trigger.cron'
| 'trigger.alarm'
| 'gate.and'
| 'flow.if'
| 'flow.loop'
| 'action.write'
| 'action.delay'
| 'action.log'
| 'action.lua';
interface CLNode {
id: string;
kind: CLNodeKind;
x: number;
y: number;
params: Record<string, string>;
}
interface CLWire { from: string; fromPort?: string; to: string; }
interface CLGraph {
id: string;
name: string;
enabled: boolean;
nodes: CLNode[];
wires: CLWire[];
}
interface DataSource { name: string; }
interface SignalInfo { name: string; }
function splitRef(ref: string): { ds: string; name: string } {
const i = ref.indexOf(':');
if (i < 0) return { ds: '', name: '' };
return { ds: ref.slice(0, i), name: ref.slice(i + 1) };
}
// ── Geometry (shared with LogicEditor look/feel) ─────────────────────────────
const REM = (() => {
if (typeof document === 'undefined') return 16;
return parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
})();
const NODE_W = 11.5 * REM;
const PORT_TOP = 2 * REM;
const PORT_GAP = 1.375 * REM;
const PORT_R = 0.375 * REM;
interface PaletteEntry { kind: CLNodeKind; label: string; params: Record<string, string>; }
const PALETTE: PaletteEntry[] = [
{ kind: 'trigger.threshold', label: 'Threshold', params: { signal: '', op: '>', value: '0' } },
{ kind: 'trigger.change', label: 'On change', params: { signal: '' } },
{ kind: 'trigger.timer', label: 'Timer', params: { interval: '1000' } },
{ kind: 'trigger.cron', label: 'Cron', params: { spec: '*/5 * * * *' } },
{ kind: 'trigger.alarm', label: 'Alarm', params: { signal: '', min: '0', max: '100' } },
{ kind: 'gate.and', label: 'AND gate', params: {} },
{ kind: 'flow.if', label: 'If / else', params: { cond: '' } },
{ kind: 'flow.loop', label: 'Loop', params: { mode: 'count', count: '3', cond: '' } },
{ kind: 'action.write', label: 'Write', params: { target: '', expr: '' } },
{ kind: 'action.delay', label: 'Delay', params: { ms: '500' } },
{ kind: 'action.log', label: 'Log', params: { expr: '', label: '' } },
{ kind: 'action.lua', label: 'Lua script', params: { script: '-- get("ds:name") reads, set("ds:name", v) writes,\n-- log("msg") logs to the server.\n' } },
];
const KIND_LABEL: Record<CLNodeKind, string> = {
'trigger.threshold': 'Threshold',
'trigger.change': 'On change',
'trigger.timer': 'Timer',
'trigger.cron': 'Cron',
'trigger.alarm': 'Alarm',
'gate.and': 'AND gate',
'flow.if': 'If / else',
'flow.loop': 'Loop',
'action.write': 'Write',
'action.delay': 'Delay',
'action.log': 'Log',
'action.lua': 'Lua script',
};
const THRESHOLD_OPS = ['>', '<', '>=', '<=', '==', '!='] as const;
const CRON_PRESETS: { label: string; spec: string }[] = [
{ label: 'Every minute', spec: '* * * * *' },
{ label: 'Every 5 minutes', spec: '*/5 * * * *' },
{ label: 'Every hour', spec: '0 * * * *' },
{ label: 'Daily at 08:00', spec: '0 8 * * *' },
{ label: 'Weekdays at 09:00', spec: '0 9 * * 1-5' },
];
function isTrigger(kind: CLNodeKind): boolean { return kind.startsWith('trigger.'); }
function category(kind: CLNodeKind): 'trigger' | 'gate' | 'flow' | 'action' {
if (kind.startsWith('trigger.')) return 'trigger';
if (kind.startsWith('gate.')) return 'gate';
if (kind.startsWith('flow.')) return 'flow';
return 'action';
}
function hasInput(kind: CLNodeKind): boolean { return !isTrigger(kind); }
interface Port { id: string; label: string; }
function outputs(kind: CLNodeKind): Port[] {
if (kind === 'flow.if') return [{ id: 'then', label: 'then' }, { id: 'else', label: 'else' }];
if (kind === 'flow.loop') return [{ id: 'body', label: 'body' }, { id: 'done', label: 'done' }];
return [{ id: 'out', label: '' }];
}
function nodeHeight(kind: CLNodeKind): number { return PORT_TOP + outputs(kind).length * PORT_GAP; }
function genId(): string { return 'n_' + Math.random().toString(36).slice(2, 9); }
function inAnchor(n: CLNode) { return { x: n.x, y: n.y + PORT_TOP }; }
function outAnchor(n: CLNode, port: string) {
const ports = outputs(n.kind);
const i = Math.max(0, ports.findIndex(p => p.id === port));
return { x: n.x + NODE_W, y: n.y + PORT_TOP + i * PORT_GAP };
}
function wirePathStr(x1: number, y1: number, x2: number, y2: number): string {
const dx = Math.max(40, Math.abs(x2 - x1) / 2);
return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`;
}
// ── Manager modal ────────────────────────────────────────────────────────────
interface Props { onClose: () => void; }
export default function ControlLogicEditor({ onClose }: Props) {
const [graphs, setGraphs] = useState<CLGraph[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [graph, setGraph] = useState<CLGraph | null>(null);
const [dirty, setDirty] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function reload() {
try {
const res = await fetch('/api/v1/controllogic');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
setGraphs(await res.json());
} catch (err) {
setError(`Failed to load control logic: ${err instanceof Error ? err.message : err}`);
}
}
useEffect(() => { reload(); }, []);
function selectGraph(g: CLGraph) {
if (dirty && !confirm('Discard unsaved changes to the current graph?')) return;
setSelectedId(g.id);
setGraph(JSON.parse(JSON.stringify(g)));
setDirty(false);
setError(null);
}
async function createGraph() {
setBusy(true);
setError(null);
try {
const body: Omit<CLGraph, 'id'> = { name: 'New control logic', enabled: false, nodes: [], wires: [] };
const res = await fetch('/api/v1/controllogic', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const created: CLGraph = await res.json();
await reload();
setSelectedId(created.id);
setGraph(created);
setDirty(false);
} catch (err) {
setError(`Create failed: ${err instanceof Error ? err.message : err}`);
} finally {
setBusy(false);
}
}
async function saveGraph() {
if (!graph) return;
setBusy(true);
setError(null);
try {
const res = await fetch(`/api/v1/controllogic/${encodeURIComponent(graph.id)}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(graph),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
setDirty(false);
await reload();
} catch (err) {
setError(`Save failed: ${err instanceof Error ? err.message : err}`);
} finally {
setBusy(false);
}
}
async function deleteGraph(id: string) {
if (!confirm('Delete this control logic graph? This stops it on the server.')) return;
setBusy(true);
setError(null);
try {
const res = await fetch(`/api/v1/controllogic/${encodeURIComponent(id)}`, { method: 'DELETE' });
if (!res.ok && res.status !== 204) throw new Error(`HTTP ${res.status}`);
if (selectedId === id) { setSelectedId(null); setGraph(null); setDirty(false); }
await reload();
} catch (err) {
setError(`Delete failed: ${err instanceof Error ? err.message : err}`);
} finally {
setBusy(false);
}
}
// Toggle a graph's enabled flag straight from the list (persists immediately).
async function toggleEnabled(g: CLGraph) {
setBusy(true);
setError(null);
try {
const updated = { ...g, enabled: !g.enabled };
const res = await fetch(`/api/v1/controllogic/${encodeURIComponent(g.id)}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updated),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
if (selectedId === g.id && graph) setGraph({ ...graph, enabled: updated.enabled });
await reload();
} catch (err) {
setError(`Update failed: ${err instanceof Error ? err.message : err}`);
} finally {
setBusy(false);
}
}
function patchGraph(patch: Partial<CLGraph>) {
if (!graph) return;
setGraph({ ...graph, ...patch });
setDirty(true);
}
return (
<div class="cl-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div class="cl-modal">
<header class="cl-header">
<span class="cl-title">Control logic</span>
<span class="hint cl-subtitle">Server-side flows run continuously, independent of any panel.</span>
<div class="cl-header-actions">
{graph && dirty && <span class="cl-dirty">unsaved</span>}
{graph && <button class="panel-btn" disabled={busy || !dirty} onClick={saveGraph}>Save</button>}
<button class="panel-btn" onClick={onClose}>Close</button>
</div>
</header>
{error && <div class="cl-error">{error}</div>}
<div class="cl-body">
<div class="cl-list">
<div class="cl-list-head">
<span>Graphs</span>
<button class="panel-btn" disabled={busy} onClick={createGraph}>+ New</button>
</div>
{graphs.length === 0 && <div class="hint cl-list-empty">No control logic yet.</div>}
{graphs.map(g => (
<div key={g.id}
class={`cl-list-item${selectedId === g.id ? ' cl-list-item-active' : ''}`}
onClick={() => selectGraph(g)}>
<span class={`cl-status-dot${g.enabled ? ' cl-status-on' : ''}`}
title={g.enabled ? 'Enabled' : 'Disabled'} />
<span class="cl-list-name" title={g.name}>{g.name || '(unnamed)'}</span>
<button class="cl-mini-btn" title={g.enabled ? 'Disable' : 'Enable'}
onClick={(e) => { e.stopPropagation(); toggleEnabled(g); }}>
{g.enabled ? '⏸' : '▶'}
</button>
<button class="cl-mini-btn" title="Delete"
onClick={(e) => { e.stopPropagation(); deleteGraph(g.id); }}></button>
</div>
))}
</div>
<div class="cl-editor-wrap">
{!graph && <div class="cl-empty hint">Select a graph on the left, or create a new one.</div>}
{graph && (
<Fragment>
<div class="cl-graph-bar">
<input class="prop-input cl-name-input" value={graph.name}
placeholder="Graph name"
onInput={(e) => patchGraph({ name: (e.target as HTMLInputElement).value })} />
<label class="cl-enable">
<input type="checkbox" checked={graph.enabled}
onChange={(e) => patchGraph({ enabled: (e.target as HTMLInputElement).checked })} />
Enabled
</label>
<span class="hint">Saving a disabled graph stops it; enabling + saving starts it.</span>
</div>
<FlowEditor graph={graph}
onChange={(g) => { setGraph({ ...graph, nodes: g.nodes, wires: g.wires }); setDirty(true); }} />
</Fragment>
)}
</div>
</div>
</div>
</div>
);
}
// ── Node-graph editor ─────────────────────────────────────────────────────────
function FlowEditor({ graph, onChange }: {
graph: CLGraph;
onChange: (g: { nodes: CLNode[]; wires: CLWire[] }) => void;
}) {
const nodes = graph.nodes;
const wires = graph.wires;
const [selectedNode, setSelectedNode] = useState<string | null>(null);
const [selectedWire, setSelectedWire] = useState<number | null>(null);
const [dataSources, setDataSources] = useState<string[]>([]);
const [dsSignals, setDsSignals] = useState<Record<string, string[]>>({});
const canvasRef = useRef<HTMLDivElement>(null);
const dragNode = useRef<{ id: string; dx: number; dy: number } | null>(null);
const [pendingWire, setPendingWire] = useState<{ from: string; port: string; x: number; y: number } | null>(null);
const pendingRef = useRef(pendingWire);
pendingRef.current = pendingWire;
useEffect(() => {
fetch('/api/v1/datasources')
.then(r => r.ok ? r.json() : [])
.then((ds: DataSource[]) => setDataSources(ds.map(d => d.name)))
.catch(() => {});
}, []);
async function loadSignals(ds: string) {
if (!ds || ds === 'local' || ds === 'sys' || dsSignals[ds]) return;
try {
const res = await fetch(`/api/v1/signals?ds=${encodeURIComponent(ds)}`);
if (!res.ok) return;
const sigs: SignalInfo[] = await res.json();
setDsSignals(prev => ({ ...prev, [ds]: sigs.map(s => s.name) }));
} catch {}
}
// Bare-name write targets are graph-local variables; offer them under 'local'.
const localNames = Array.from(new Set(
nodes.filter(n => n.kind === 'action.write')
.map(n => n.params.target ?? '')
.filter(t => t && !t.includes(':'))
));
const dsOptions = ['local', 'sys', ...dataSources];
function signalOptions(ds: string): string[] {
if (ds === 'local') return localNames;
if (ds === 'sys') return ['time', 'dt'];
return dsSignals[ds] ?? [];
}
const selected = nodes.find(n => n.id === selectedNode) ?? null;
useEffect(() => {
if (selected?.kind === 'trigger.threshold' || selected?.kind === 'trigger.change' || selected?.kind === 'trigger.alarm') {
loadSignals(splitRef(selected.params.signal ?? '').ds);
}
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 }); }
function addNode(entry: PaletteEntry, x?: number, y?: number) {
const node: CLNode = {
id: genId(),
kind: entry.kind,
x: x ?? 40 + (nodes.length % 5) * 30,
y: y ?? 40 + (nodes.length % 5) * 30,
params: { ...entry.params },
};
emit({ nodes: [...nodes, node], wires });
setSelectedNode(node.id);
setSelectedWire(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 deleteNode(id: string) {
emit({ nodes: nodes.filter(n => n.id !== id), wires: wires.filter(w => w.from !== id && w.to !== id) });
if (selectedNode === id) setSelectedNode(null);
}
function addWire(from: string, port: string, to: string) {
if (from === to) return;
if (wires.some(w => w.from === from && (w.fromPort ?? 'out') === port && w.to === to)) return;
setWires([...wires, { from, to, ...(port !== 'out' ? { fromPort: port } : {}) }]);
}
function deleteWire(idx: number) {
setWires(wires.filter((_, i) => i !== idx));
if (selectedWire === idx) setSelectedWire(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 };
}
function startNodeDrag(e: MouseEvent, node: CLNode) {
e.stopPropagation();
const p = toCanvas(e);
dragNode.current = { id: node.id, dx: p.x - node.x, dy: p.y - node.y };
setSelectedNode(node.id);
setSelectedWire(null);
window.addEventListener('mousemove', onNodeDragMove);
window.addEventListener('mouseup', onNodeDragUp);
}
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));
}
function onNodeDragUp() {
dragNode.current = null;
window.removeEventListener('mousemove', onNodeDragMove);
window.removeEventListener('mouseup', onNodeDragUp);
}
function startWire(e: MouseEvent, node: CLNode, port: string) {
e.stopPropagation();
const p = toCanvas(e);
setPendingWire({ from: node.id, port, x: p.x, y: p.y });
window.addEventListener('mousemove', onWireMove);
window.addEventListener('mouseup', onWireUp);
}
function onWireMove(e: MouseEvent) {
const cur = pendingRef.current;
if (!cur) return;
const p = toCanvas(e);
setPendingWire({ ...cur, x: p.x, y: p.y });
}
function endWire() {
pendingRef.current = null;
window.removeEventListener('mousemove', onWireMove);
window.removeEventListener('mouseup', onWireUp);
setPendingWire(null);
}
function onWireUp() { endWire(); }
function finishWire(target: CLNode) {
const cur = pendingRef.current;
if (cur && hasInput(target.kind)) addWire(cur.from, cur.port, target.id);
endWire();
}
const DRAG_MIME = 'application/x-uopi-cl-node';
function onCanvasDragOver(e: DragEvent) {
if (Array.from(e.dataTransfer?.types ?? []).includes(DRAG_MIME)) {
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
}
}
function onCanvasDrop(e: DragEvent) {
const kind = e.dataTransfer?.getData(DRAG_MIME);
if (!kind) return;
e.preventDefault();
const entry = PALETTE.find(p => p.kind === kind);
if (!entry) return;
const p = toCanvas(e);
addNode(entry, Math.max(0, p.x - NODE_W / 2), Math.max(0, p.y - PORT_TOP / 2));
}
useEffect(() => {
function onKey(e: KeyboardEvent) {
const t = e.target as Element;
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT')) return;
if (e.key !== 'Delete' && e.key !== 'Backspace') return;
if (selectedWire !== null) deleteWire(selectedWire);
else if (selectedNode) deleteNode(selectedNode);
}
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [selectedNode, selectedWire, nodes, wires]);
const byId = new Map(nodes.map(n => [n.id, n]));
function insertRef(id: string, field: string, ds: string, sig: string) {
const cur = (byId.get(id)?.params[field]) ?? '';
patchParams(id, { [field]: `${cur}{${ds}:${sig}}` });
}
return (
<div class="flow-editor">
<div class="flow-palette">
<div class="flow-palette-title">Triggers</div>
{PALETTE.filter(e => category(e.kind) === 'trigger').map(paletteBtn)}
<div class="flow-palette-title">Logic</div>
{PALETTE.filter(e => category(e.kind) === 'gate' || category(e.kind) === 'flow').map(paletteBtn)}
<div class="flow-palette-title">Actions</div>
{PALETTE.filter(e => category(e.kind) === 'action').map(paletteBtn)}
<div class="flow-palette-hint hint">
Triggers start a flow. Drag a node's right port to another node's left port to connect.
In expressions, reference signals as <code>{'{ds:name}'}</code> and graph-local vars by name.
</div>
</div>
<div class="flow-canvas" ref={canvasRef}
onMouseDown={() => { setSelectedNode(null); setSelectedWire(null); }}
onDragOver={onCanvasDragOver}
onDrop={onCanvasDrop}>
<div class="flow-canvas-inner">
<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);
return (
<path key={idx}
class={`flow-wire${selectedWire === idx ? ' flow-wire-selected' : ''}`}
d={wirePathStr(p1.x, p1.y, p2.x, p2.y)}
onClick={(e) => { e.stopPropagation(); setSelectedWire(idx); setSelectedNode(null); }} />
);
})}
{pendingWire && (() => {
const a = byId.get(pendingWire.from);
if (!a) return null;
const p1 = outAnchor(a, pendingWire.port);
return <path class="flow-wire flow-wire-pending" d={wirePathStr(p1.x, p1.y, pendingWire.x, pendingWire.y)} />;
})()}
</svg>
{nodes.map(node => (
<div key={node.id}
class={`flow-node flow-node-${category(node.kind)}${selectedNode === node.id ? ' flow-node-selected' : ''}`}
style={`left:${node.x}px; top:${node.y}px; width:${NODE_W}px; min-height:${nodeHeight(node.kind)}px;`}
onMouseDown={(e) => startNodeDrag(e, node)}
onMouseUp={() => { if (pendingRef.current) finishWire(node); }}>
<div class="flow-node-header">
<span class="flow-node-title">{KIND_LABEL[node.kind]}</span>
<button class="flow-node-del" title="Delete node"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); deleteNode(node.id); }}></button>
</div>
<div class="flow-node-body hint">{nodeSummary(node)}</div>
{hasInput(node.kind) && (
<div class="flow-port flow-port-in" title="Input"
style={`top:${PORT_TOP - PORT_R}px; left:${-PORT_R}px;`}
onMouseDown={(e) => e.stopPropagation()}
onMouseUp={(e) => { e.stopPropagation(); finishWire(node); }} />
)}
{outputs(node.kind).map((port, i) => (
<Fragment key={port.id}>
{port.label && (
<span class="flow-port-label" style={`top:${PORT_TOP + i * PORT_GAP - 0.5 * REM}px;`}>{port.label}</span>
)}
<div class={`flow-port flow-port-out flow-port-${port.id}`}
style={`top:${PORT_TOP + i * PORT_GAP - PORT_R}px; right:${-PORT_R}px;`}
title={`Output: ${port.id}`}
onMouseDown={(e) => startWire(e, node, port.id)} />
</Fragment>
))}
</div>
))}
</div>
</div>
<div class="flow-inspector">
{!selected && <div class="hint">Select a node to edit it, or add one from the palette.</div>}
{selected && (
<Fragment>
<div class="wizard-section-title">{KIND_LABEL[selected.kind]}</div>
{(selected.kind === 'trigger.threshold' || selected.kind === 'trigger.change' || selected.kind === 'trigger.alarm') && (() => {
const { ds, name } = splitRef(selected.params.signal ?? '');
return (
<Fragment>
<div class="wizard-field">
<label>Data source</label>
<SearchableSelect value={ds} options={dataSources}
onSelect={(newDs) => { loadSignals(newDs); patchParams(selected.id, { signal: `${newDs}:` }); }} />
</div>
<div class="wizard-field">
<label>Signal</label>
<SearchableSelect value={name} options={signalOptions(ds)}
onSelect={(sig) => patchParams(selected.id, { signal: `${ds}:${sig}` })}
placeholder={ds ? 'Search signals…' : 'Select data source first'} />
</div>
{selected.kind === 'trigger.threshold' && (
<div class="wizard-field wizard-field-row">
<div class="wizard-field" style="flex:0 0 4.5rem;">
<label>Op</label>
<select class="prop-select" value={selected.params.op ?? '>'}
onChange={(e) => patchParams(selected.id, { op: (e.target as HTMLSelectElement).value })}>
{THRESHOLD_OPS.map(o => <option key={o} value={o}>{o}</option>)}
</select>
</div>
<div class="wizard-field" style="flex:1;">
<label>Value</label>
<input class="prop-input" value={selected.params.value ?? ''}
onInput={(e) => patchParams(selected.id, { value: (e.target as HTMLInputElement).value })} />
</div>
</div>
)}
{selected.kind === 'trigger.alarm' && (
<div class="wizard-field wizard-field-row">
<div class="wizard-field" style="flex:1;">
<label>Min</label>
<input class="prop-input" type="number" value={selected.params.min ?? '0'}
onInput={(e) => patchParams(selected.id, { min: (e.target as HTMLInputElement).value })} />
</div>
<div class="wizard-field" style="flex:1;">
<label>Max</label>
<input class="prop-input" type="number" value={selected.params.max ?? '100'}
onInput={(e) => patchParams(selected.id, { max: (e.target as HTMLInputElement).value })} />
</div>
</div>
)}
{selected.kind === 'trigger.change' && <p class="hint">Fires whenever the signal's value changes.</p>}
{selected.kind === 'trigger.alarm' && <p class="hint">Fires when the signal leaves the [min, max] range (rising edge).</p>}
</Fragment>
);
})()}
{selected.kind === 'trigger.timer' && (
<div class="wizard-field">
<label>Interval (ms)</label>
<input class="prop-input" type="number" value={selected.params.interval ?? '1000'}
onInput={(e) => patchParams(selected.id, { interval: (e.target as HTMLInputElement).value })} />
<p class="hint">Fires repeatedly on the server while the graph is enabled.</p>
</div>
)}
{selected.kind === 'trigger.cron' && (
<Fragment>
<div class="wizard-field">
<label>Schedule (cron)</label>
<input class="prop-input" value={selected.params.spec ?? ''}
placeholder="min hour dom month dow"
onInput={(e) => patchParams(selected.id, { spec: (e.target as HTMLInputElement).value })} />
</div>
<div class="wizard-field">
<label>Presets</label>
<select class="prop-select" value=""
onChange={(e) => { const v = (e.target as HTMLSelectElement).value; if (v) patchParams(selected.id, { spec: v }); }}>
<option value="">choose</option>
{CRON_PRESETS.map(p => <option key={p.spec} value={p.spec}>{p.label} ({p.spec})</option>)}
</select>
</div>
<p class="hint">5 fields: minute hour day-of-month month day-of-week. Supports <code>*</code>, <code>*/n</code>, ranges <code>a-b</code> and lists <code>a,b</code>.</p>
</Fragment>
)}
{selected.kind === 'gate.and' && (
<p class="hint">Wire two or more triggers into this gate. The flow continues only when all
inputs are satisfied (level triggers like Threshold/Alarm use their current truth).</p>
)}
{selected.kind === 'flow.if' && (
<ExprField label="Condition" value={selected.params.cond ?? ''}
onChange={(v) => patchParams(selected.id, { cond: v })}
onInsert={(ds, sig) => insertRef(selected.id, 'cond', ds, sig)}
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals}
hint="True → 'then' port, false → 'else'. e.g. {stub:level} > 5 && temp < 100. {sys:time}=epoch seconds, {sys:dt}=seconds since the trigger last fired." />
)}
{selected.kind === 'flow.loop' && (
<Fragment>
<div class="wizard-field">
<label>Mode</label>
<select class="prop-select" value={selected.params.mode ?? 'count'}
onChange={(e) => patchParams(selected.id, { mode: (e.target as HTMLSelectElement).value })}>
<option value="count">Repeat N times</option>
<option value="while">While condition</option>
</select>
</div>
{(selected.params.mode ?? 'count') === 'count' ? (
<div class="wizard-field">
<label>Repeat count</label>
<input class="prop-input" type="number" value={selected.params.count ?? '3'}
onInput={(e) => patchParams(selected.id, { count: (e.target as HTMLInputElement).value })} />
</div>
) : (
<ExprField label="While condition" value={selected.params.cond ?? ''}
onChange={(v) => patchParams(selected.id, { cond: v })}
onInsert={(ds, sig) => insertRef(selected.id, 'cond', ds, sig)}
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals}
hint="Loops the 'body' port while true (capped). e.g. {stub:level} < 90" />
)}
<p class="hint">Runs the <b>body</b> port repeatedly, then continues on <b>done</b>.</p>
</Fragment>
)}
{selected.kind === 'action.write' && (() => {
const { ds, name } = splitRef(selected.params.target ?? '');
return (
<Fragment>
<div class="wizard-field">
<label>Target data source</label>
<SearchableSelect value={ds} options={dsOptions}
onSelect={(newDs) => { loadSignals(newDs); patchParams(selected.id, { target: `${newDs}:` }); }} />
</div>
<div class="wizard-field">
<label>Target signal</label>
<SearchableSelect value={name} options={signalOptions(ds)}
onSelect={(sig) => patchParams(selected.id, { target: `${ds}:${sig}` })}
placeholder={ds ? 'Search signals…' : 'Select data source first'} />
</div>
<ExprField label="Value (expression)" value={selected.params.expr ?? ''}
onChange={(v) => patchParams(selected.id, { expr: v })}
onInsert={(ds2, sig) => insertRef(selected.id, 'expr', ds2, sig)}
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals}
hint="Write to a data source signal, or a bare name to store a graph-local var. e.g. ({stub:a} - {stub:b}) / {sys:dt}." />
</Fragment>
);
})()}
{selected.kind === 'action.delay' && (
<div class="wizard-field">
<label>Delay (ms)</label>
<input class="prop-input" type="number" value={selected.params.ms ?? '0'}
onInput={(e) => patchParams(selected.id, { ms: (e.target as HTMLInputElement).value })} />
</div>
)}
{selected.kind === 'action.log' && (
<Fragment>
<div class="wizard-field">
<label>Label (optional)</label>
<input class="prop-input" value={selected.params.label ?? ''}
placeholder="prefix for the log line"
onInput={(e) => patchParams(selected.id, { label: (e.target as HTMLInputElement).value })} />
</div>
<ExprField label="Value (expression)" value={selected.params.expr ?? ''}
onChange={(v) => patchParams(selected.id, { expr: v })}
onInsert={(ds2, sig) => insertRef(selected.id, 'expr', ds2, sig)}
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals}
hint="Logs this value to the server log — handy for debugging a flow." />
</Fragment>
)}
{selected.kind === 'action.lua' && (
<div class="wizard-field">
<label>Lua script</label>
<LuaEditor value={selected.params.script ?? ''}
onChange={(v) => patchParams(selected.id, { script: v })} rows={14} />
<p class="hint">
Host functions: <code>get("ds:name")</code> reads a signal/sys/local value,
<code>set("ds:name", v)</code> writes (bare name graph-local var),
<code>log(msg)</code> logs to the server. The <code>os/io</code> libraries are disabled.
</p>
</div>
)}
<button class="panel-btn" style="margin-top:1rem;" onClick={() => deleteNode(selected.id)}>Delete node</button>
</Fragment>
)}
</div>
</div>
);
function paletteBtn(entry: PaletteEntry) {
return (
<button key={entry.kind} class={`flow-palette-btn flow-palette-${category(entry.kind)}`}
title={`${entry.kind} — drag onto the canvas or click to add`}
draggable
onDragStart={(e) => {
e.dataTransfer?.setData(DRAG_MIME, entry.kind);
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'copy';
}}
onClick={() => addNode(entry)}>{entry.label}</button>
);
}
}
function ExprField({ label, value, onChange, onInsert, dsOptions, signalOptions, loadSignals, hint }: {
label: string;
value: string;
onChange: (v: string) => void;
onInsert: (ds: string, sig: string) => void;
dsOptions: string[];
signalOptions: (ds: string) => string[];
loadSignals: (ds: string) => void;
hint: string;
}) {
const [insDs, setInsDs] = useState('');
const err = checkExpr(value);
return (
<div class="wizard-field">
<label>{label}</label>
<input class={`prop-input${err ? ' prop-input-error' : ''}`} value={value}
placeholder="expression"
onInput={(e) => onChange((e.target as HTMLInputElement).value)} />
{err && <p class="wizard-error">{err}</p>}
<div class="flow-expr-insert">
<SearchableSelect value={insDs} options={dsOptions}
onSelect={(ds) => { setInsDs(ds); loadSignals(ds); }} placeholder="ds…" />
<SearchableSelect value="" options={signalOptions(insDs)}
onSelect={(sig) => onInsert(insDs, sig)}
placeholder={insDs ? '+ insert signal' : 'pick ds first'} />
</div>
<p class="hint">{hint}</p>
</div>
);
}
function nodeSummary(n: CLNode): string {
switch (n.kind) {
case 'trigger.threshold': return `${n.params.signal || '?'} ${n.params.op || '>'} ${n.params.value || '0'}`;
case 'trigger.change': return `Δ ${n.params.signal || '?'}`;
case 'trigger.timer': return `every ${n.params.interval || '?'} ms`;
case 'trigger.cron': return n.params.spec || '(no schedule)';
case 'trigger.alarm': return `${n.params.signal || '?'} ∉ [${n.params.min || '?'}, ${n.params.max || '?'}]`;
case 'gate.and': return 'all inputs';
case 'flow.if': return n.params.cond || '(no condition)';
case 'flow.loop': return (n.params.mode ?? 'count') === 'count'
? `repeat ${n.params.count || '0'}×`
: `while ${n.params.cond || '?'}`;
case 'action.write': return `${n.params.target || '?'} = ${n.params.expr || ''}`;
case 'action.delay': return `wait ${n.params.ms || '0'} ms`;
case 'action.log': return `log ${n.params.label ? n.params.label + ': ' : ''}${n.params.expr || ''}`;
case 'action.lua': return 'Lua script';
default: return '';
}
}
+6 -2
View File
@@ -88,7 +88,8 @@ const STATIC_WIDGET_TYPES = [
export default function EditMode({ initial, onDone }: Props) { export default function EditMode({ initial, onDone }: Props) {
const [iface, setIface] = useState<Interface>(initial ?? blankInterface()); const [iface, setIface] = useState<Interface>(initial ?? blankInterface());
const writable = canWrite(useAuth().level); const me = useAuth();
const writable = canWrite(me.level);
const [selectedIds, setSelectedIds] = useState<string[]>([]); const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [dirty, setDirty] = useState(false); const [dirty, setDirty] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -670,15 +671,18 @@ export default function EditMode({ initial, onDone }: Props) {
class={`center-tab${centerTab === 'layout' ? ' center-tab-active' : ''}`} class={`center-tab${centerTab === 'layout' ? ' center-tab-active' : ''}`}
onClick={() => setCenterTab('layout')} onClick={() => setCenterTab('layout')}
>Layout</button> >Layout</button>
{me.canEditLogic && (
<button <button
class={`center-tab${centerTab === 'logic' ? ' center-tab-active' : ''}`} class={`center-tab${centerTab === 'logic' ? ' center-tab-active' : ''}`}
onClick={() => setCenterTab('logic')} onClick={() => setCenterTab('logic')}
>Logic{iface.logic && iface.logic.nodes.length > 0 ? ` (${iface.logic.nodes.length})` : ''}</button> >Logic{iface.logic && iface.logic.nodes.length > 0 ? ` (${iface.logic.nodes.length})` : ''}</button>
)}
</div> </div>
{centerTab === 'logic' ? ( {centerTab === 'logic' && me.canEditLogic ? (
<LogicEditor <LogicEditor
graph={iface.logic ?? { nodes: [], wires: [] }} graph={iface.logic ?? { nodes: [], wires: [] }}
onChange={handleLogicChange} onChange={handleLogicChange}
widgets={iface.widgets}
statevars={iface.statevars} statevars={iface.statevars}
onStateVarsChange={handleStateVarsChange} onStateVarsChange={handleStateVarsChange}
/> />
+180 -6
View File
@@ -406,7 +406,11 @@ const SECTIONS = [
{ id: 'view', label: 'View Mode' }, { id: 'view', label: 'View Mode' },
{ id: 'edit', label: 'Edit Mode' }, { id: 'edit', label: 'Edit Mode' },
{ id: 'widgets', label: 'Widgets' }, { id: 'widgets', label: 'Widgets' },
{ id: 'plots', label: 'Plot Panels' },
{ id: 'signals', label: 'Signals' }, { id: 'signals', label: 'Signals' },
{ id: 'logic', label: 'Panel Logic' },
{ id: 'control', label: 'Control Logic' },
{ id: 'sharing', label: 'Sharing & Access' },
{ id: 'history', label: 'Historical Data' }, { id: 'history', label: 'Historical Data' },
{ id: 'shortcuts', label: 'Keyboard Shortcuts' }, { id: 'shortcuts', label: 'Keyboard Shortcuts' },
]; ];
@@ -510,6 +514,24 @@ function SectionEdit() {
<h3 class="help-h3">Align &amp; distribute</h3> <h3 class="help-h3">Align &amp; distribute</h3>
<p>When 2+ widgets are selected the alignment toolbar appears. Buttons align edges or centres; with 3+ selected you can distribute spacing evenly.</p> <p>When 2+ widgets are selected the alignment toolbar appears. Buttons align edges or centres; with 3+ selected you can distribute spacing evenly.</p>
<h3 class="help-h3">Layout &amp; Logic tabs</h3>
<p>The centre of the editor has two tabs:</p>
<ul class="help-list">
<li><strong>Layout</strong> the drag-and-drop widget canvas described above.</li>
<li><strong>Logic</strong> a visual flow editor that adds interactive behaviour to the
panel (buttons that run actions, thresholds that pop up dialogs, timers, ). See the
<em>Panel Logic</em> section. The tab is hidden if you are not permitted to edit logic.</li>
</ul>
<h3 class="help-h3">Local variables</h3>
<p>
Panels can define their own <strong>local variables</strong> lightweight values that live
only inside the panel (no data source needed). Add them from the signal tree's
<em>Local</em> group (or the Logic palette). They are written by Set-value widgets, buttons
and logic actions, and referenced anywhere a signal is, making them handy for set-points,
toggles and counters used by panel logic.
</p>
<h3 class="help-h3">Saving</h3> <h3 class="help-h3">Saving</h3>
<ul class="help-list"> <ul class="help-list">
<li><strong>Save</strong> stores the panel on the server (XML). A <span style="color:#f59e0b"></span> indicates unsaved changes.</li> <li><strong>Save</strong> stores the panel on the server (XML). A <span style="color:#f59e0b"></span> indicates unsaved changes.</li>
@@ -520,6 +542,141 @@ function SectionEdit() {
); );
} }
function SectionPlots() {
return (
<div>
<p class="help-lead">
A <strong>plot panel</strong> is a special interface dedicated to charts. Instead of
free-form boxes, plots <em>fill the viewport</em> and you split the space between them like
panes in a tiling window manager (tmux / IDE style).
</p>
<h3 class="help-h3">Creating one</h3>
<ul class="help-list">
<li>Click <strong>+ Plot</strong> in the interface list to create a plot panel it opens
with one full-viewport empty plot.</li>
</ul>
<h3 class="help-h3">Splitting &amp; arranging</h3>
<ul class="help-list">
<li>Hover a pane and use its split buttons (<strong></strong> vertical / <strong></strong>
horizontal) to divide it a new empty plot appears in the freed half.</li>
<li>Drag the divider between two panes to resize them; nesting is unlimited.</li>
<li>Click a pane to select it, then configure its plot in the Properties pane (plot type,
time window, range, legend, per-signal colour).</li>
<li>Drag a signal from the tree onto a pane to add it to that pane's plot.</li>
<li>Use a pane's <strong></strong> to remove it; the layout collapses onto its sibling.</li>
</ul>
<p>In view mode the saved split layout fills the screen with live, streaming plots.</p>
</div>
);
}
function SectionLogic() {
return (
<div>
<p class="help-lead">
The <strong>Logic</strong> tab in the panel editor is a node-graph (Node-RED style) flow
editor. You wire <em>trigger</em> nodes to <em>action</em> nodes to give a panel interactive
behaviour. Logic runs entirely client-side while the panel is open in view mode.
</p>
<h3 class="help-h3">Building a flow</h3>
<ul class="help-list">
<li>Drag blocks from the left palette onto the canvas (or click to add), then drag from a
node's output port to another node's input port to connect them.</li>
<li>Select a node to edit its parameters in the right inspector.</li>
<li>Expression fields accept arithmetic/logic over signals reference a signal as
<code>{'{ds:name}'}</code> and a panel-local variable by its bare name.</li>
<li>The editor has its own undo/redo and copy/paste (see Keyboard Shortcuts).</li>
</ul>
<h3 class="help-h3">Node types</h3>
<div class="help-widget-table">
{[
{ type: 'Triggers', desc: 'Button press, threshold crossing, value change, timer/interval, panel loop, and On-open / On-close lifecycle.' },
{ type: 'Logic', desc: 'AND gate, If (then/else branches), and Loop (count or while).' },
{ type: 'Actions', desc: 'Write a value to a signal/variable, Delay, Log (debug), and Accumulate / Export-CSV / Clear for in-memory data arrays.' },
{ type: 'Dialogs', desc: 'Info and Error pop-ups, and a Set-point prompt that asks the user for a number and writes it to a target.' },
].map(w => (
<div key={w.type} class="help-widget-row">
<span class="help-widget-name">{w.type}</span>
<span class="help-widget-desc">{w.desc}</span>
</div>
))}
</div>
<h3 class="help-h3">System helpers</h3>
<ul class="help-list">
<li><code>{'{sys:time}'}</code> current time in epoch seconds.</li>
<li><code>{'{sys:dt}'}</code> seconds since the firing trigger last fired (useful for rates).</li>
</ul>
</div>
);
}
function SectionControl() {
return (
<div>
<p class="help-lead">
<strong>Control logic</strong> is server-side automation. Unlike panel logic (which only
runs while a panel is open in a browser), control-logic graphs run continuously on the
server, independent of any client. Open them with the <strong> Control logic</strong>
button in the view-mode toolbar.
</p>
<h3 class="help-h3">What it can do</h3>
<ul class="help-list">
<li>React to <em>cron</em> schedules and signal <em>alarm</em>/threshold conditions.</li>
<li>Run a <strong>Lua</strong> block for custom logic and compute writes back to signals.</li>
<li>Each graph can be enabled/disabled independently; changes reload the engine live.</li>
</ul>
<p class="help-callout">
Editing both panel logic and control logic can be restricted to an allowlist of users or
groups (<code>server.logic_editors</code> in the config). When restricted, users not on the
list keep full access to everything else but cannot add or change logic the Logic tab and
the Control logic button are hidden for them.
</p>
</div>
);
}
function SectionSharing() {
return (
<div>
<p class="help-lead">
uopi has graduated, server-enforced access control. Identity comes from a trusted
reverse-proxy header (with a configurable default for unproxied/LAN use) there is no login
page in the app itself.
</p>
<h3 class="help-h3">Global access levels</h3>
<ul class="help-list">
<li>Every user is trusted with full <strong>write</strong> access by default.</li>
<li>A config blacklist can downgrade specific users to <strong>read-only</strong> or
<strong>no access</strong>. Read-only users see panels but cannot edit or write signals.</li>
<li>Named <strong>groups</strong> are defined in the config and used by panel sharing.</li>
</ul>
<h3 class="help-h3">Panel ownership &amp; sharing</h3>
<ul class="help-list">
<li>New panels are <strong>private</strong> to their owner by default.</li>
<li>Use the <strong>Share</strong> button on a panel (in the interface list) to grant
read or write to specific users/groups, or make it public.</li>
<li>Your global level always caps the per-panel permission (read-only stays read-only).</li>
</ul>
<h3 class="help-h3">Folders</h3>
<ul class="help-list">
<li>Organise panels into nested <strong>folders</strong> in the interface list.</li>
<li>Permissions inherit down the folder chain.</li>
<li>Drag panels to reorder them or move them between folders.</li>
</ul>
</div>
);
}
function SectionWidgets() { function SectionWidgets() {
return ( return (
<div> <div>
@@ -602,7 +759,19 @@ function SectionSignals() {
</p> </p>
<h3 class="help-h3">Synthetic signals</h3> <h3 class="help-h3">Synthetic signals</h3>
<p>Click <strong>+ Synthetic</strong> to open the wizard. Choose an input signal, a processing node (gain, moving average, lowpass filter, ), and optional metadata. The new signal appears in the tree immediately.</p> <p>Click <strong>+ Synthetic</strong> to compose a new signal from existing ones. A quick
<em>wizard</em> covers the common case (pick an input + a processing node such as gain,
offset, moving average, lowpass/highpass, derivative, integral, clamp or a custom
formula). For multi-input pipelines, the <strong>node-graph editor</strong> lets you wire
inputs through a chain of DSP blocks visually. The new signal appears in the tree
immediately.</p>
<p>Each synthetic signal has a <strong>visibility scope</strong>: <em>panel</em> (only the
panel that created it), <em>user</em>, or <em>global</em> (shared with everyone).</p>
<h3 class="help-h3">Local variables</h3>
<p>The <strong>Local</strong> group holds panel-local variables values stored in the
panel itself with no data source. Use them for set-points, toggles and counters driven by
panel logic. They are added in the editor and saved with the panel.</p>
</div> </div>
<DiagramSignalTree /> <DiagramSignalTree />
</div> </div>
@@ -676,14 +845,15 @@ function SectionShortcuts() {
<h3 class="help-h3">API &amp; Metrics</h3> <h3 class="help-h3">API &amp; Metrics</h3>
<ul class="help-list"> <ul class="help-list">
<li>REST API: <code>/api/v1/datasources</code>, <code>/api/v1/signals</code>, <code>/api/v1/interfaces</code>, </li> <li>Signals/data: <code>/api/v1/datasources</code>, <code>/api/v1/signals</code>, <code>/api/v1/synthetic</code></li>
<li>Prometheus metrics: <code>/metrics</code></li> <li>Panels: <code>/api/v1/interfaces</code>, <code>/api/v1/folders</code>, <code>/api/v1/interfaces/{'{id}'}/acl</code></li>
<li>Health check: <code>/healthz</code></li> <li>Identity &amp; groups: <code>/api/v1/me</code>, <code>/api/v1/usergroups</code></li>
<li>WebSocket: <code>ws://&lt;host&gt;/ws</code></li> <li>Control logic: <code>/api/v1/controllogic</code></li>
<li>Prometheus metrics: <code>/metrics</code> · Health check: <code>/healthz</code> · WebSocket: <code>ws://&lt;host&gt;/ws</code></li>
</ul> </ul>
<h3 class="help-h3">Configuration file</h3> <h3 class="help-h3">Configuration file</h3>
<pre class="help-pre">[server]{'\n'}listen = ":8080"{'\n'}storage_dir = "./data"{'\n\n'}[datasource.epics]{'\n'}enabled = true{'\n'}archive_url = "http://archiver:17665"{'\n\n'}[datasource.synthetic]{'\n'}enabled = true</pre> <pre class="help-pre">[server]{'\n'}listen = ":8080"{'\n'}storage_dir = "./interfaces"{'\n'}# optional: restrict who may edit panel/control logic{'\n'}# logic_editors = ["operators"]{'\n\n'}[datasource.epics]{'\n'}enabled = true{'\n'}archive_url = "http://archiver:17665"{'\n\n'}[datasource.synthetic]{'\n'}enabled = true</pre>
</div> </div>
); );
} }
@@ -693,7 +863,11 @@ const SECTION_COMPONENTS: Record<string, () => JSX.Element> = {
view: SectionView, view: SectionView,
edit: SectionEdit, edit: SectionEdit,
widgets: SectionWidgets, widgets: SectionWidgets,
plots: SectionPlots,
signals: SectionSignals, signals: SectionSignals,
logic: SectionLogic,
control: SectionControl,
sharing: SectionSharing,
history: SectionHistory, history: SectionHistory,
shortcuts: SectionShortcuts, shortcuts: SectionShortcuts,
}; };
+77 -5
View File
@@ -21,10 +21,60 @@ export default function InterfaceList({ onLoad, onSelect, onEdit, onNewPlot, onE
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [share, setShare] = useState<{ id: string; name: string } | null>(null); const [share, setShare] = useState<{ id: string; name: string } | null>(null);
const [expanded, setExpanded] = useState<Record<string, boolean>>({}); const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const [dropTarget, setDropTarget] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const dragId = useRef<string | null>(null);
const me = useAuth(); const me = useAuth();
const writable = canWrite(me.level); const writable = canWrite(me.level);
// Panels in a folder ('' = root), sorted by their stored order then name.
function panelsIn(folder: string): InterfaceListItem[] {
return interfaces
.filter(i => (i.folder || '') === folder)
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0) || (a.name || a.id).localeCompare(b.name || b.id));
}
// Persist a new ordering for a folder's panels (also moves panels between folders).
async function reorder(folder: string, ids: string[]) {
const res = await fetch('/api/v1/interfaces/reorder', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ folder, ids }),
});
if (res.ok) refresh();
}
function endDrag() {
dragId.current = null;
setDropTarget(null);
}
// Drop dragged panel just before `target` within target's folder.
function dropOnPanel(target: InterfaceListItem, e: DragEvent) {
e.preventDefault();
e.stopPropagation();
const id = dragId.current;
if (!id || id === target.id) { endDrag(); return; }
const dest = target.folder || '';
const ids = panelsIn(dest).map(p => p.id).filter(pid => pid !== id);
const idx = ids.indexOf(target.id);
ids.splice(idx < 0 ? ids.length : idx, 0, id);
reorder(dest, ids);
endDrag();
}
// Drop dragged panel at the end of `folder` ('' = root).
function dropInFolder(folder: string, e: DragEvent) {
e.preventDefault();
e.stopPropagation();
const id = dragId.current;
if (!id) { endDrag(); return; }
const ids = panelsIn(folder).map(p => p.id).filter(pid => pid !== id);
ids.push(id);
reorder(folder, ids);
endDrag();
}
function refresh() { function refresh() {
setLoading(true); setLoading(true);
Promise.all([ Promise.all([
@@ -39,6 +89,7 @@ export default function InterfaceList({ onLoad, onSelect, onEdit, onNewPlot, onE
version: Number(item.version || item.Version || 0), version: Number(item.version || item.Version || 0),
owner: item.owner ? String(item.owner) : '', owner: item.owner ? String(item.owner) : '',
folder: item.folder ? String(item.folder) : '', folder: item.folder ? String(item.folder) : '',
order: Number(item.order || 0),
perm: (item.perm as InterfaceListItem['perm']) || 'write', perm: (item.perm as InterfaceListItem['perm']) || 'write',
})).filter((item: InterfaceListItem) => item.id); })).filter((item: InterfaceListItem) => item.id);
setInterfaces(normalized); setInterfaces(normalized);
@@ -116,8 +167,18 @@ export default function InterfaceList({ onLoad, onSelect, onEdit, onNewPlot, onE
} }
function renderPanel(item: InterfaceListItem) { function renderPanel(item: InterfaceListItem) {
const drag = writable && item.perm === 'write';
return ( return (
<li key={item.id} class="iface-item"> <li
key={item.id}
class={`iface-item${dropTarget === `p:${item.id}` ? ' drop-target' : ''}`}
draggable={drag}
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" onClick={() => onSelect?.(item.id)} title={item.owner ? `Owner: ${item.owner}` : undefined}>
{item.name || item.id} {item.name || item.id}
{item.perm === 'read' && <span class="iface-badge" title="Read-only">ro</span>} {item.perm === 'read' && <span class="iface-badge" title="Read-only">ro</span>}
@@ -136,11 +197,17 @@ export default function InterfaceList({ onLoad, onSelect, onEdit, onNewPlot, onE
// Render a folder and everything beneath it (subfolders first, then panels). // Render a folder and everything beneath it (subfolders first, then panels).
function renderFolder(folder: Folder) { function renderFolder(folder: Folder) {
const children = folders.filter(f => f.parent === folder.id); const children = folders.filter(f => f.parent === folder.id);
const panels = interfaces.filter(i => i.folder === folder.id); const panels = panelsIn(folder.id);
const open = expanded[folder.id] !== false; // default expanded const open = expanded[folder.id] !== false; // default expanded
const canDrop = folder.perm === 'write';
return ( return (
<li key={folder.id} class="iface-folder"> <li key={folder.id} class="iface-folder">
<div class="iface-folder-header"> <div
class={`iface-folder-header${dropTarget === `f:${folder.id}` ? ' drop-target' : ''}`}
onDragOver={canDrop ? (e) => { if (dragId.current) { e.preventDefault(); e.stopPropagation(); setDropTarget(`f:${folder.id}`); } } : undefined}
onDragLeave={() => setDropTarget(t => t === `f:${folder.id}` ? null : t)}
onDrop={canDrop ? (e) => dropInFolder(folder.id, e) : undefined}
>
<span class="iface-folder-name" onClick={() => toggle(folder.id)}> <span class="iface-folder-name" onClick={() => toggle(folder.id)}>
{open ? '▾' : '▸'} {folder.name} {open ? '▾' : '▸'} {folder.name}
</span> </span>
@@ -162,7 +229,7 @@ export default function InterfaceList({ onLoad, onSelect, onEdit, onNewPlot, onE
} }
const rootFolders = folders.filter(f => !f.parent); const rootFolders = folders.filter(f => !f.parent);
const rootPanels = interfaces.filter(i => !i.folder); const rootPanels = panelsIn('');
return ( return (
<aside class={`panel${collapsed ? ' collapsed' : ''}`} style={!collapsed && width ? `width:${width}px;min-width:${width}px;` : undefined}> <aside class={`panel${collapsed ? ' collapsed' : ''}`} style={!collapsed && width ? `width:${width}px;min-width:${width}px;` : undefined}>
@@ -201,7 +268,12 @@ export default function InterfaceList({ onLoad, onSelect, onEdit, onNewPlot, onE
) : interfaces.length === 0 && folders.length === 0 ? ( ) : interfaces.length === 0 && folders.length === 0 ? (
<p class="hint">No interfaces yet. Create one or import XML.</p> <p class="hint">No interfaces yet. Create one or import XML.</p>
) : ( ) : (
<ul class="iface-list"> <ul
class={`iface-list${dropTarget === 'f:' ? ' drop-target' : ''}`}
onDragOver={(e) => { if (dragId.current) { e.preventDefault(); setDropTarget('f:'); } }}
onDragLeave={() => setDropTarget(t => t === 'f:' ? null : t)}
onDrop={(e) => dropInFolder('', e)}
>
{rootFolders.map(renderFolder)} {rootFolders.map(renderFolder)}
{rootPanels.map(renderPanel)} {rootPanels.map(renderPanel)}
</ul> </ul>
+61
View File
@@ -0,0 +1,61 @@
import { h, Fragment } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import { logicDialogs, type DialogRequest } from './lib/logic';
// Renders the user-interaction dialogs requested by action.dialog.* logic nodes.
// The engine pushes requests to the `logicDialogs` store and awaits the user's
// response; this component shows each as a modal and calls req.resolve.
export default function LogicDialogs() {
const [reqs, setReqs] = useState<DialogRequest[]>([]);
useEffect(() => logicDialogs.subscribe(setReqs), []);
if (reqs.length === 0) return null;
return <Fragment>{reqs.map(r => <DialogModal key={r.id} req={r} />)}</Fragment>;
}
function DialogModal({ req }: { req: DialogRequest; key?: string }) {
const isSetpoint = req.kind === 'setpoint';
const inputIdx = req.fields
.map((f, i) => (f.type === 'input' ? i : -1))
.filter(i => i >= 0);
// Per-input editable values, keyed by field index, seeded from the prefill.
const [vals, setVals] = useState<Record<number, string>>(
() => Object.fromEntries(inputIdx.map(i => [i, req.fields[i].value]))
);
function ok() {
req.resolve(inputIdx.map(i => vals[i] ?? ''));
}
function cancel() { req.resolve(null); }
function onKey(e: KeyboardEvent) {
if (e.key === 'Enter' && !(e.target instanceof HTMLTextAreaElement)) { e.preventDefault(); ok(); }
if (e.key === 'Escape') { e.preventDefault(); cancel(); }
}
return (
<div class="logic-dialog-backdrop" onKeyDown={onKey}>
<div class={`logic-dialog logic-dialog-${req.kind}`} role="dialog">
<div class="logic-dialog-header">{req.title || (req.kind === 'error' ? 'Error' : isSetpoint ? 'Set value' : 'Info')}</div>
{req.message && <div class="logic-dialog-message">{req.message}</div>}
{req.fields.map((f, i) => (
<div class="logic-dialog-field" key={i}>
{f.label && <label class="logic-dialog-label">{f.label}</label>}
{f.type === 'input' ? (
<input class="prop-input logic-dialog-input" type="number"
autoFocus={i === inputIdx[0]}
value={vals[i] ?? ''}
onInput={(e) => setVals(v => ({ ...v, [i]: (e.target as HTMLInputElement).value }))}
onKeyDown={onKey} />
) : (
<span class="logic-dialog-value">{f.value}</span>
)}
</div>
))}
<div class="logic-dialog-actions">
{isSetpoint && <button class="panel-btn" onClick={cancel}>Cancel</button>}
<button class="panel-btn panel-btn-primary" onClick={ok}>OK</button>
</div>
</div>
</div>
);
}
+315 -20
View File
@@ -2,7 +2,7 @@ import { h, Fragment } from 'preact';
import { useState, useEffect, useRef } from 'preact/hooks'; import { useState, useEffect, useRef } from 'preact/hooks';
import SearchableSelect from './SearchableSelect'; import SearchableSelect from './SearchableSelect';
import { checkExpr } from './lib/expr'; import { checkExpr } from './lib/expr';
import type { LogicGraph, LogicNode, LogicNodeKind, LogicWire, StateVar } from './lib/types'; import type { LogicGraph, LogicNode, LogicNodeKind, LogicWire, StateVar, Widget } from './lib/types';
interface DataSource { name: string; } interface DataSource { name: string; }
interface SignalInfo { name: string; } interface SignalInfo { name: string; }
@@ -17,6 +17,8 @@ function splitRef(ref: string): { ds: string; name: string } {
interface Props { interface Props {
graph: LogicGraph; graph: LogicGraph;
onChange: (graph: LogicGraph) => void; onChange: (graph: LogicGraph) => void;
// The panel's widgets, offered as targets for the Widget-control action.
widgets?: Widget[];
// Panel-local state variables, offered as quick write targets / signals. // Panel-local state variables, offered as quick write targets / signals.
statevars?: StateVar[]; statevars?: StateVar[];
// Edit local state variables directly from the logic editor (the layout-side // Edit local state variables directly from the logic editor (the layout-side
@@ -48,15 +50,21 @@ const PALETTE: PaletteEntry[] = [
{ kind: 'trigger.change', label: 'On change', params: { signal: '' } }, { kind: 'trigger.change', label: 'On change', params: { signal: '' } },
{ kind: 'trigger.timer', label: 'Timer', params: { interval: '1000' } }, { kind: 'trigger.timer', label: 'Timer', params: { interval: '1000' } },
{ kind: 'trigger.loop', label: 'Panel loop', params: { interval: '1000' } }, { kind: 'trigger.loop', label: 'Panel loop', params: { interval: '1000' } },
{ kind: 'trigger.start', label: 'On open', params: {} },
{ kind: 'trigger.stop', label: 'On close', params: {} },
{ kind: 'gate.and', label: 'AND gate', params: {} }, { kind: 'gate.and', label: 'AND gate', params: {} },
{ kind: 'flow.if', label: 'If / else', params: { cond: '' } }, { kind: 'flow.if', label: 'If / else', params: { cond: '' } },
{ kind: 'flow.loop', label: 'Loop', params: { mode: 'count', count: '3', cond: '' } }, { kind: 'flow.loop', label: 'Loop', params: { mode: 'count', count: '3', cond: '' } },
{ kind: 'action.write', label: 'Write', params: { target: '', expr: '' } }, { kind: 'action.write', label: 'Write', params: { target: '', expr: '' } },
{ kind: 'action.delay', label: 'Delay', params: { ms: '500' } }, { kind: 'action.delay', label: 'Delay', params: { ms: '500' } },
{ kind: 'action.accumulate', label: 'Accumulate', params: { array: 'data', expr: '' } }, { kind: 'action.accumulate', label: 'Accumulate', params: { array: 'data', expr: '' } },
{ kind: 'action.export', label: 'Export CSV', params: { array: 'data', filename: '' } }, { kind: 'action.export', label: 'Export CSV', params: { columns: '[{"array":"data","label":""}]', align: 'common', filename: '' } },
{ kind: 'action.clear', label: 'Clear array', params: { array: 'data' } }, { kind: 'action.clear', label: 'Clear array', params: { array: 'data' } },
{ kind: 'action.log', label: 'Log', params: { expr: '', label: '' } }, { kind: 'action.log', label: 'Log', params: { expr: '', label: '' } },
{ kind: 'action.widget', label: 'Widget control', params: { widget: '', op: 'disable' } },
{ kind: 'action.dialog.info', label: 'Info dialog', params: { title: 'Info', message: '' } },
{ kind: 'action.dialog.error', label: 'Error dialog', params: { title: 'Error', message: '' } },
{ kind: 'action.dialog.setpoint', label: 'Set-point dialog', params: { title: 'Set value', message: '', fields: '[{"type":"input","label":"","target":"","default":""}]' } },
]; ];
const KIND_LABEL: Record<LogicNodeKind, string> = { const KIND_LABEL: Record<LogicNodeKind, string> = {
@@ -65,6 +73,8 @@ const KIND_LABEL: Record<LogicNodeKind, string> = {
'trigger.change': 'On change', 'trigger.change': 'On change',
'trigger.timer': 'Timer', 'trigger.timer': 'Timer',
'trigger.loop': 'Panel loop', 'trigger.loop': 'Panel loop',
'trigger.start': 'On open',
'trigger.stop': 'On close',
'gate.and': 'AND gate', 'gate.and': 'AND gate',
'flow.if': 'If / else', 'flow.if': 'If / else',
'flow.loop': 'Loop', 'flow.loop': 'Loop',
@@ -74,6 +84,10 @@ const KIND_LABEL: Record<LogicNodeKind, string> = {
'action.export': 'Export CSV', 'action.export': 'Export CSV',
'action.clear': 'Clear array', 'action.clear': 'Clear array',
'action.log': 'Log', 'action.log': 'Log',
'action.widget': 'Widget control',
'action.dialog.info': 'Info dialog',
'action.dialog.error': 'Error dialog',
'action.dialog.setpoint': 'Set-point dialog',
}; };
const THRESHOLD_OPS = ['>', '<', '>=', '<=', '==', '!='] as const; const THRESHOLD_OPS = ['>', '<', '>=', '<=', '==', '!='] as const;
@@ -112,7 +126,7 @@ function wirePathStr(x1: number, y1: number, x2: number, y2: number): string {
return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`; return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`;
} }
export default function LogicEditor({ graph, onChange, statevars, onStateVarsChange }: Props) { export default function LogicEditor({ graph, onChange, widgets = [], statevars, onStateVarsChange }: Props) {
const nodes = graph.nodes; const nodes = graph.nodes;
const wires = graph.wires; const wires = graph.wires;
@@ -172,7 +186,14 @@ export default function LogicEditor({ graph, onChange, statevars, onStateVarsCha
if (selected?.kind === 'trigger.threshold' || selected?.kind === 'trigger.change') { if (selected?.kind === 'trigger.threshold' || selected?.kind === 'trigger.change') {
loadSignals(splitRef(selected.params.signal ?? '').ds); loadSignals(splitRef(selected.params.signal ?? '').ds);
} }
if (selected?.kind === 'action.write') loadSignals(splitRef(selected.params.target ?? '').ds); if (selected?.kind === 'action.write') {
loadSignals(splitRef(selected.params.target ?? '').ds);
}
if (selected && selected.kind.startsWith('action.dialog.')) {
for (const f of parseDialogFields(selected)) {
if (f.type === 'input') loadSignals(splitRef(f.target ?? '').ds);
}
}
}, [selectedNode, selected?.kind]); }, [selectedNode, selected?.kind]);
// ── History ──────────────────────────────────────────────────────────────── // ── History ────────────────────────────────────────────────────────────────
@@ -632,21 +653,13 @@ export default function LogicEditor({ graph, onChange, statevars, onStateVarsCha
)} )}
{selected.kind === 'action.export' && ( {selected.kind === 'action.export' && (
<Fragment> <ExportEditor
<div class="wizard-field"> columns={parseExportColumns(selected)}
<label>Array name</label> align={selected.params.align ?? 'common'}
<input class="prop-input" value={selected.params.array ?? ''} list="flow-array-names" filename={selected.params.filename ?? ''}
placeholder="e.g. data" onColumns={(cols) => patchParams(selected.id, { columns: JSON.stringify(cols) })}
onInput={(e) => patchParams(selected.id, { array: (e.target as HTMLInputElement).value })} /> onAlign={(a) => patchParams(selected.id, { align: a })}
</div> onFilename={(f) => patchParams(selected.id, { filename: f })} />
<div class="wizard-field">
<label>File name</label>
<input class="prop-input" value={selected.params.filename ?? ''}
placeholder="defaults to the array name"
onInput={(e) => patchParams(selected.id, { filename: (e.target as HTMLInputElement).value })} />
<p class="hint">Downloads the array as CSV (timestamp_ms, iso, value).</p>
</div>
</Fragment>
)} )}
{selected.kind === 'action.clear' && ( {selected.kind === 'action.clear' && (
@@ -675,6 +688,92 @@ export default function LogicEditor({ graph, onChange, statevars, onStateVarsCha
</Fragment> </Fragment>
)} )}
{selected.kind === 'action.widget' && (() => {
const op = selected.params.op ?? 'disable';
const plotOnly = op === 'clearplot' || op === 'pauseplot' || op === 'resumeplot';
const choices = plotOnly ? widgets.filter(w => w.type === 'plot') : widgets;
return (
<Fragment>
<div class="wizard-field">
<label>Action</label>
<select class="prop-input" value={op}
onChange={(e) => patchParams(selected.id, { op: (e.target as HTMLSelectElement).value })}>
<option value="enable">Enable</option>
<option value="disable">Disable</option>
<option value="show">Show</option>
<option value="hide">Hide</option>
<option value="clearplot">Clear plot</option>
<option value="pauseplot">Pause plot</option>
<option value="resumeplot">Resume plot</option>
</select>
</div>
<div class="wizard-field">
<label>Widget</label>
<select class="prop-input" value={selected.params.widget ?? ''}
onChange={(e) => patchParams(selected.id, { widget: (e.target as HTMLSelectElement).value })}>
<option value=""> select a widget </option>
{choices.map(w => (
<option value={w.id}>{widgetLabel(w)}</option>
))}
</select>
{plotOnly && <p class="hint">Only plot widgets are listed for this action.</p>}
</div>
</Fragment>
);
})()}
{(selected.kind === 'trigger.start' || selected.kind === 'trigger.stop') && (
<p class="hint">{selected.kind === 'trigger.start'
? 'Fires once when the panel is opened — use it to initialise state or greet the operator.'
: 'Fires once when the panel is closed — use it to write a safe value or log a closing message. Only immediate (no-delay) actions are guaranteed to run.'}</p>
)}
{(selected.kind === 'action.dialog.info' || selected.kind === 'action.dialog.error') && (
<Fragment>
<div class="wizard-field">
<label>Title</label>
<input class="prop-input" value={selected.params.title ?? ''}
placeholder={selected.kind === 'action.dialog.error' ? 'Error' : 'Info'}
onInput={(e) => patchParams(selected.id, { title: (e.target as HTMLInputElement).value })} />
</div>
<div class="wizard-field">
<label>Message</label>
<textarea class="prop-input" rows={4} value={selected.params.message ?? ''}
placeholder="Shown to the operator. Embed values as {ds:name}."
onInput={(e) => patchParams(selected.id, { message: (e.target as HTMLTextAreaElement).value })} />
<p class="hint">Waits for the operator to dismiss the dialog before continuing.
Embed live values with <code>{'{ds:name}'}</code> (e.g. Level is {'{stub:level}'}).</p>
</div>
<DialogFieldsEditor allowInput={false}
fields={parseDialogFields(selected)}
onChange={(f) => patchParams(selected.id, { fields: JSON.stringify(f) })}
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals} />
</Fragment>
)}
{selected.kind === 'action.dialog.setpoint' && (
<Fragment>
<div class="wizard-field">
<label>Title</label>
<input class="prop-input" value={selected.params.title ?? ''}
placeholder="Set value"
onInput={(e) => patchParams(selected.id, { title: (e.target as HTMLInputElement).value })} />
</div>
<div class="wizard-field">
<label>Message</label>
<textarea class="prop-input" rows={3} value={selected.params.message ?? ''}
placeholder="Prompt shown above the fields. Embed values as {ds:name}."
onInput={(e) => patchParams(selected.id, { message: (e.target as HTMLTextAreaElement).value })} />
</div>
<DialogFieldsEditor allowInput={true}
fields={parseDialogFields(selected)}
onChange={(f) => patchParams(selected.id, { fields: JSON.stringify(f) })}
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals} />
<p class="hint">Each <b>input</b> prompts the operator and writes the entered number to its
target on OK; each <b>display</b> shows a live value. Cancel aborts the flow.</p>
</Fragment>
)}
<button class="panel-btn" style="margin-top:1rem;" onClick={() => deleteNode(selected.id)}>Delete node</button> <button class="panel-btn" style="margin-top:1rem;" onClick={() => deleteNode(selected.id)}>Delete node</button>
</Fragment> </Fragment>
)} )}
@@ -728,6 +827,184 @@ function ExprField({ label, value, onChange, onInsert, dsOptions, signalOptions,
); );
} }
// ── Export CSV: multi-column editor ──────────────────────────────────────────
interface ExportColumn { array: string; label: string; }
// The columns configured on an action.export node. Reads the JSON `columns`
// param; falls back to the legacy single `array` param (so old panels migrate
// transparently the first time the node is edited).
function parseExportColumns(n: LogicNode): ExportColumn[] {
const raw = (n.params.columns ?? '').trim();
if (raw) {
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
return parsed.map((c: any) => ({ array: String(c?.array ?? ''), label: String(c?.label ?? '') }));
}
} catch {}
}
const a = (n.params.array ?? '').trim();
return [{ array: a, label: '' }];
}
const ALIGN_OPTS: Array<{ value: string; label: string; hint: string }> = [
{ value: 'common', label: 'Common rows', hint: 'Only timestamps present in every array (intersection).' },
{ value: 'any', label: 'All rows', hint: 'Union of all timestamps; missing cells are left blank.' },
{ value: 'interpolate', label: 'Interpolate', hint: 'Union of all timestamps; missing cells linearly interpolated.' },
];
function ExportEditor({ columns, align, filename, onColumns, onAlign, onFilename }: {
columns: ExportColumn[];
align: string;
filename: string;
onColumns: (cols: ExportColumn[]) => void;
onAlign: (a: string) => void;
onFilename: (f: string) => void;
}) {
function patch(i: number, patch: Partial<ExportColumn>) {
onColumns(columns.map((c, j) => (j === i ? { ...c, ...patch } : c)));
}
const alignHint = ALIGN_OPTS.find(o => o.value === align)?.hint ?? '';
return (
<Fragment>
<div class="wizard-field">
<label>Columns (arrays)</label>
{columns.map((c, i) => (
<div key={i} class="flow-row-edit">
<input class="prop-input" value={c.array} list="flow-array-names"
placeholder="array name"
onInput={(e) => patch(i, { array: (e.target as HTMLInputElement).value })} />
<input class="prop-input" value={c.label}
placeholder="column label (optional)"
onInput={(e) => patch(i, { label: (e.target as HTMLInputElement).value })} />
<button class="flow-node-del" title="Remove column"
disabled={columns.length <= 1}
onClick={() => onColumns(columns.filter((_, j) => j !== i))}></button>
</div>
))}
<button class="panel-btn flow-row-add" onClick={() => onColumns([...columns, { array: '', label: '' }])}>+ Add column</button>
</div>
{columns.length > 1 && (
<div class="wizard-field">
<label>Alignment</label>
<select class="prop-select" value={align} onChange={(e) => onAlign((e.target as HTMLSelectElement).value)}>
{ALIGN_OPTS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
<p class="hint">{alignHint}</p>
</div>
)}
<div class="wizard-field">
<label>File name</label>
<input class="prop-input" value={filename}
placeholder="defaults to the column labels"
onInput={(e) => onFilename((e.target as HTMLInputElement).value)} />
<p class="hint">Downloads a CSV with columns: timestamp_ms, iso, then one column per array.</p>
</div>
</Fragment>
);
}
// ── Dialogs: multi-field editor ──────────────────────────────────────────────
interface DialogFieldSpec { type: 'input' | 'display'; label: string; target?: string; default?: string; expr?: string; }
// The fields configured on an action.dialog.* node. Reads the JSON `fields`
// param; for a legacy set-point node it synthesises a single input from the old
// target/default params so existing panels migrate transparently.
function parseDialogFields(n: LogicNode): DialogFieldSpec[] {
const raw = (n.params.fields ?? '').trim();
if (raw) {
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
return parsed.map((f: any) => ({
type: f?.type === 'display' ? 'display' : 'input',
label: String(f?.label ?? ''),
target: String(f?.target ?? ''),
default: String(f?.default ?? ''),
expr: String(f?.expr ?? ''),
}));
}
} catch {}
}
if (n.kind === 'action.dialog.setpoint') {
return [{ type: 'input', label: '', target: n.params.target ?? '', default: n.params.default ?? '', expr: '' }];
}
return [];
}
function DialogFieldsEditor({ allowInput, fields, onChange, dsOptions, signalOptions, loadSignals }: {
allowInput: boolean;
fields: DialogFieldSpec[];
onChange: (fields: DialogFieldSpec[]) => void;
dsOptions: string[];
signalOptions: (ds: string) => string[];
loadSignals: (ds: string) => void;
}) {
function patch(i: number, patch: Partial<DialogFieldSpec>) {
onChange(fields.map((f, j) => (j === i ? { ...f, ...patch } : f)));
}
function add(type: 'input' | 'display') {
onChange([...fields, { type, label: '', target: '', default: '', expr: '' }]);
}
return (
<div class="wizard-field">
<label>{allowInput ? 'Fields' : 'Displayed values'}</label>
{fields.length === 0 && <p class="hint">None yet.</p>}
{fields.map((f, i) => {
const { ds, name } = splitRef(f.target ?? '');
return (
<div key={i} class="flow-field-edit">
<div class="flow-field-head">
{allowInput ? (
<select class="prop-select" value={f.type}
onChange={(e) => patch(i, { type: (e.target as HTMLSelectElement).value as 'input' | 'display' })}>
<option value="input">Input</option>
<option value="display">Display</option>
</select>
) : <span class="flow-field-kind">Display</span>}
<input class="prop-input" value={f.label} placeholder="label"
onInput={(e) => patch(i, { label: (e.target as HTMLInputElement).value })} />
<button class="flow-node-del" title="Remove field"
onClick={() => onChange(fields.filter((_, j) => j !== i))}></button>
</div>
{f.type === 'input' ? (
<Fragment>
<div class="flow-row-edit">
<SearchableSelect value={ds} options={dsOptions}
onSelect={(newDs) => { loadSignals(newDs); patch(i, { target: `${newDs}:` }); }} placeholder="ds…" />
<SearchableSelect value={name} options={signalOptions(ds)}
onSelect={(sig) => patch(i, { target: `${ds}:${sig}` })}
placeholder={ds ? 'signal…' : 'pick ds'} />
</div>
<input class={`prop-input${checkExpr(f.default ?? '') ? ' prop-input-error' : ''}`}
value={f.default ?? ''} placeholder="default value (expression, optional)"
onInput={(e) => patch(i, { default: (e.target as HTMLInputElement).value })} />
</Fragment>
) : (
<input class={`prop-input${checkExpr(f.expr ?? '') ? ' prop-input-error' : ''}`}
value={f.expr ?? ''} placeholder="value to show (expression, e.g. {stub:level})"
onInput={(e) => patch(i, { expr: (e.target as HTMLInputElement).value })} />
)}
</div>
);
})}
<div class="flow-field-add">
{allowInput && <button class="panel-btn flow-row-add" onClick={() => add('input')}>+ Add input</button>}
<button class="panel-btn flow-row-add" onClick={() => add('display')}>+ Add value to show</button>
</div>
</div>
);
}
// A human-friendly label for a widget in the Widget-control picker: its type
// plus its first signal (or id) so panels with many widgets stay legible.
function widgetLabel(w: Widget): string {
const sig = w.signals[0] ? `${w.signals[0].ds}:${w.signals[0].name}` : '';
return `${w.type}${sig ? ' · ' + sig : ''} (${w.id})`;
}
// One-line summary shown in a node block's body. // One-line summary shown in a node block's body.
function nodeSummary(n: LogicNode): string { function nodeSummary(n: LogicNode): string {
switch (n.kind) { switch (n.kind) {
@@ -736,6 +1013,8 @@ function nodeSummary(n: LogicNode): string {
case 'trigger.change': return `Δ ${n.params.signal || '?'}`; case 'trigger.change': return `Δ ${n.params.signal || '?'}`;
case 'trigger.timer': return `every ${n.params.interval || '?'} ms`; case 'trigger.timer': return `every ${n.params.interval || '?'} ms`;
case 'trigger.loop': return `load + every ${n.params.interval || '?'} ms`; case 'trigger.loop': return `load + every ${n.params.interval || '?'} ms`;
case 'trigger.start': return 'on panel open';
case 'trigger.stop': return 'on panel close';
case 'gate.and': return 'all inputs'; case 'gate.and': return 'all inputs';
case 'flow.if': return n.params.cond || '(no condition)'; case 'flow.if': return n.params.cond || '(no condition)';
case 'flow.loop': return (n.params.mode ?? 'count') === 'count' case 'flow.loop': return (n.params.mode ?? 'count') === 'count'
@@ -744,9 +1023,25 @@ function nodeSummary(n: LogicNode): string {
case 'action.write': return `${n.params.target || '?'} = ${n.params.expr || ''}`; case 'action.write': return `${n.params.target || '?'} = ${n.params.expr || ''}`;
case 'action.delay': return `wait ${n.params.ms || '0'} ms`; case 'action.delay': return `wait ${n.params.ms || '0'} ms`;
case 'action.accumulate': return `${n.params.array || '?'}${n.params.expr || ''}`; case 'action.accumulate': return `${n.params.array || '?'}${n.params.expr || ''}`;
case 'action.export': return `export ${n.params.array || '?'} → csv`; case 'action.export': {
const cols = parseExportColumns(n).filter(c => c.array);
const names = cols.map(c => c.label || c.array).join(', ');
return `export ${names || '?'} → csv`;
}
case 'action.clear': return `clear ${n.params.array || '?'}`; case 'action.clear': return `clear ${n.params.array || '?'}`;
case 'action.log': return `log ${n.params.label ? n.params.label + ': ' : ''}${n.params.expr || ''}`; case 'action.log': return `log ${n.params.label ? n.params.label + ': ' : ''}${n.params.expr || ''}`;
case 'action.widget': return `${n.params.op || 'disable'} ${n.params.widget || '?'}`;
case 'action.dialog.info': return `info: ${n.params.title || n.params.message || '…'}`;
case 'action.dialog.error': return `error: ${n.params.title || n.params.message || '…'}`;
case 'action.dialog.setpoint': {
const fs = parseDialogFields(n);
const ins = fs.filter(f => f.type === 'input').length;
const shows = fs.filter(f => f.type === 'display').length;
const parts: string[] = [];
if (ins) parts.push(`ask ${ins}`);
if (shows) parts.push(`show ${shows}`);
return parts.length ? parts.join(', ') : 'ask…';
}
default: return ''; default: return '';
} }
} }
+2 -2
View File
@@ -2,7 +2,7 @@ import { h, Fragment } from 'preact';
import { useState, useEffect, useRef, useMemo } from 'preact/hooks'; import { useState, useEffect, useRef, useMemo } from 'preact/hooks';
import type { SignalRef, StateVar } from './lib/types'; import type { SignalRef, StateVar } from './lib/types';
import SyntheticWizard from './SyntheticWizard'; import SyntheticWizard from './SyntheticWizard';
import SyntheticEditor from './SyntheticEditor'; import SyntheticGraphEditor from './SyntheticGraphEditor';
import GroupsTree from './GroupsTree'; import GroupsTree from './GroupsTree';
import ChannelFinderModal from './ChannelFinderModal'; import ChannelFinderModal from './ChannelFinderModal';
@@ -499,7 +499,7 @@ export default function SignalTree({ onDragStart, width, panelId, statevars, onS
)} )}
{editSynthetic && ( {editSynthetic && (
<SyntheticEditor <SyntheticGraphEditor
name={editSynthetic} name={editSynthetic}
onClose={() => setEditSynthetic(null)} onClose={() => setEditSynthetic(null)}
onSaved={loadAllSignals} onSaved={loadAllSignals}
-347
View File
@@ -1,347 +0,0 @@
import { h } from 'preact';
import { useState, useEffect, useMemo } from 'preact/hooks';
import LuaEditor from './LuaEditor';
import type { InputRef, SignalDef, PipelineNode } from './lib/types';
interface NodeParam {
label: string;
key: string;
type: 'number' | 'text' | 'lua';
default: string;
}
const NODE_TYPES: Array<{ type: string; label: string; params: NodeParam[] }> = [
{ type: 'gain', label: 'Gain', params: [{ label: 'Factor', key: 'gain', type: 'number', default: '1' }] },
{ type: 'offset', label: 'Offset', params: [{ label: 'Offset', key: 'offset', type: 'number', default: '0' }] },
{ type: 'moving_average', label: 'Moving Average', params: [{ label: 'Window (n)', key: 'window', type: 'number', default: '10' }] },
{ type: 'rms', label: 'RMS', params: [{ label: 'Window (n)', key: 'window', type: 'number', default: '10' }] },
{ type: 'lowpass', label: 'Low-pass Filter', params: [
{ label: 'Cutoff freq (Hz)', key: 'freq', type: 'number', default: '1' },
{ label: 'Order (18)', key: 'order', type: 'number', default: '1' },
]},
{ type: 'derivative', label: 'Derivative', params: [] },
{ type: 'integrate', label: 'Integrate', params: [] },
{ type: 'clamp', label: 'Clamp', params: [{ label: 'Min', key: 'min', type: 'number', default: '0' }, { label: 'Max', key: 'max', type: 'number', default: '100' }] },
{ type: 'expr', label: 'Formula', params: [{ label: 'Expression (a,b,c,d = inputs)', key: 'expr', type: 'text', default: 'a * 1.0' }] },
{ type: 'lua', label: 'Lua Script', params: [{ label: 'Script (a,b,c,d = inputs)', key: 'script', type: 'lua', default: 'return a' }] },
];
interface Props {
name: string;
onClose: () => void;
onSaved: () => void;
}
interface DataSource {
name: string;
}
interface SignalInfo {
name: string;
description?: string;
}
// ── Searchable Selector ──────────────────────────────────────────────────────
function SearchableSelect({
value,
options,
onSelect,
placeholder = 'Select…'
}: {
value: string;
options: string[];
onSelect: (val: string) => void;
placeholder?: string;
}) {
const [open, setOpen] = useState(false);
const [filter, setFilter] = useState('');
const filtered = useMemo(() => {
const f = filter.toLowerCase();
return options.filter(o => o.toLowerCase().includes(f));
}, [options, filter]);
return (
<div class="search-select">
<div class="search-select-trigger" onClick={() => setOpen(!open)}>
{value || <span class="hint">{placeholder}</span>}
<span class="search-select-arrow">{open ? '▴' : '▾'}</span>
</div>
{open && (
<div class="search-select-dropdown">
<input
class="prop-input"
autoFocus
placeholder="Search…"
value={filter}
onInput={(e) => setFilter((e.target as HTMLInputElement).value)}
onClick={(e) => e.stopPropagation()}
/>
<div class="search-select-list">
{filtered.length === 0 && <div class="search-select-item hint">No matches</div>}
{filtered.map(opt => (
<div
key={opt}
class={`search-select-item${opt === value ? ' search-select-item-selected' : ''}`}
onClick={() => { onSelect(opt); setOpen(false); setFilter(''); }}
>
{opt}
</div>
))}
</div>
</div>
)}
{open && <div class="search-select-backdrop" onClick={() => setOpen(false)} />}
</div>
);
}
// ── Main Editor ──────────────────────────────────────────────────────────────
export default function SyntheticEditor({ name, onClose, onSaved }: Props) {
const [def, setDef] = useState<SignalDef | null>(null);
const [inputs, setInputs] = useState<InputRef[]>([]);
const [pipeline, setPipeline] = useState<PipelineNode[]>([]);
const [addType, setAddType] = useState('gain');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
// Loaded data for selectors
const [dataSources, setDataSources] = useState<string[]>([]);
const [dsSignals, setDsSignals] = useState<Record<string, string[]>>({});
useEffect(() => {
fetch('/api/v1/datasources')
.then(r => r.ok ? r.json() : [])
.then((ds: DataSource[]) => setDataSources(ds.map(d => d.name)))
.catch(() => {});
}, []);
async function loadSignals(ds: string) {
if (!ds || dsSignals[ds]) return;
try {
const res = await fetch(`/api/v1/signals?ds=${encodeURIComponent(ds)}`);
if (!res.ok) return;
const sigs: SignalInfo[] = await res.json();
setDsSignals(prev => ({ ...prev, [ds]: sigs.map(s => s.name) }));
} catch {}
}
useEffect(() => {
fetch(`/api/v1/synthetic/${encodeURIComponent(name)}`)
.then(r => r.ok ? r.json() : Promise.reject(r.statusText))
.then((d: SignalDef) => {
setDef(d);
const initialInputs = d.inputs && d.inputs.length > 0
? d.inputs
: (d.ds && d.signal ? [{ ds: d.ds, signal: d.signal }] : []);
setInputs(initialInputs);
setPipeline(d.pipeline ?? []);
// Load signals for existing input data sources
initialInputs.forEach(inp => loadSignals(inp.ds));
})
.catch(e => setError(String(e)))
.finally(() => setLoading(false));
}, [name]);
function nodeLabel(type: string): string {
return NODE_TYPES.find(n => n.type === type)?.label ?? type;
}
function nodeParamDefs(type: string): NodeParam[] {
return NODE_TYPES.find(n => n.type === type)?.params ?? [];
}
function setNodeParam(idx: number, key: string, val: string) {
setPipeline(p => p.map((node, i) => {
if (i !== idx) return node;
const paramDef = nodeParamDefs(node.type).find(pd => pd.key === key);
const typed: any = paramDef?.type === 'number' ? parseFloat(val) : val;
return { ...node, params: { ...node.params, [key]: typed } };
}));
}
function removeNode(idx: number) {
setPipeline(p => p.filter((_, i) => i !== idx));
}
function moveNode(idx: number, dir: -1 | 1) {
setPipeline(p => {
const next = [...p];
const target = idx + dir;
if (target < 0 || target >= next.length) return next;
[next[idx], next[target]] = [next[target], next[idx]];
return next;
});
}
function addNode() {
const nodeDef = NODE_TYPES.find(n => n.type === addType)!;
const params: Record<string, any> = {};
for (const p of nodeDef.params) {
params[p.key] = p.type === 'number' ? parseFloat(p.default) : p.default;
}
setPipeline(p => [...p, { type: addType, params }]);
}
function updateInput(idx: number, patch: Partial<InputRef>) {
setInputs(prev => prev.map((inp, i) => {
if (i !== idx) return inp;
const next = { ...inp, ...patch };
if (patch.ds) {
next.signal = '';
loadSignals(patch.ds);
}
return next;
}));
}
function addInput() {
setInputs(prev => [...prev, { ds: dataSources[0] || '', signal: '' }]);
}
function removeInput(idx: number) {
setInputs(prev => prev.filter((_, i) => i !== idx));
}
async function handleSave() {
if (!def) return;
if (inputs.length === 0) { setError('At least one input is required'); return; }
if (inputs.some(i => !i.ds || !i.signal)) { setError('All inputs must have a data source and signal'); return; }
setSaving(true);
setError('');
try {
const body = {
...def,
inputs,
pipeline,
// Clear legacy fields if present
ds: undefined,
signal: undefined
};
const res = await fetch(`/api/v1/synthetic/${encodeURIComponent(name)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const j = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(j.error ?? res.statusText);
}
onSaved();
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setSaving(false);
}
}
return (
<div class="wizard-backdrop" onClick={onClose}>
<div class="wizard" onClick={(e) => e.stopPropagation()} style="max-width:560px;">
<div class="wizard-header">
<span>Edit Synthetic Signal {name}</span>
<button class="icon-btn" onClick={onClose}></button>
</div>
<div class="wizard-body">
{error && <p class="wizard-error">{error}</p>}
{loading ? (
<p class="hint">Loading</p>
) : (
<div>
<div class="wizard-section-title">Input signals</div>
{inputs.map((inp, idx) => (
<div key={idx} class="wizard-field wizard-field-row" style="align-items: flex-end; gap: 0.5rem; margin-bottom: 0.5rem;">
<div class="wizard-field" style="flex:1;">
<label>Data source</label>
<SearchableSelect
value={inp.ds}
options={dataSources}
onSelect={(ds) => updateInput(idx, { ds })}
/>
</div>
<div class="wizard-field" style="flex:2;">
<label>Signal</label>
<SearchableSelect
value={inp.signal}
options={dsSignals[inp.ds] || []}
onSelect={(signal) => updateInput(idx, { signal })}
placeholder={inp.ds ? 'Search signals…' : 'Select data source first'}
/>
</div>
<button
class="icon-btn"
style="margin-bottom: 0.25rem;"
title="Remove input"
onClick={() => removeInput(idx)}
disabled={inputs.length === 1}
></button>
</div>
))}
<button class="panel-btn" style="margin-top: 0.5rem; margin-bottom: 1.5rem;" onClick={addInput}>+ Add input</button>
<div class="wizard-section-title">Pipeline nodes</div>
{pipeline.length === 0 && (
<p class="hint" style="padding:0.5rem 0;">No processing nodes input passes through unchanged.</p>
)}
{pipeline.map((node, idx) => {
const paramDefs = nodeParamDefs(node.type);
return (
<div key={idx} class="synth-pipeline-node">
<div class="synth-node-header">
<span class="synth-node-index">{idx + 1}</span>
<span class="synth-node-label">{nodeLabel(node.type)}</span>
<div class="synth-node-actions">
<button class="icon-btn" title="Move up" disabled={idx === 0} onClick={() => moveNode(idx, -1)}></button>
<button class="icon-btn" title="Move down" disabled={idx === pipeline.length - 1} onClick={() => moveNode(idx, 1)}></button>
<button class="icon-btn" title="Remove node" onClick={() => removeNode(idx)}></button>
</div>
</div>
{paramDefs.map(pd => (
<div key={pd.key} class="wizard-field wizard-field-inline">
<label>{pd.label}</label>
{pd.type === 'lua' ? (
<LuaEditor
value={String(node.params[pd.key] ?? pd.default)}
onChange={(v) => setNodeParam(idx, pd.key, v)}
/>
) : (
<input
class="prop-input"
type={pd.type === 'number' ? 'number' : 'text'}
value={node.params[pd.key] ?? pd.default}
onInput={(e) => setNodeParam(idx, pd.key, (e.target as HTMLInputElement).value)}
/>
)}
</div>
))}
</div>
);
})}
<div class="synth-add-node-row">
<select class="prop-select" value={addType} onChange={(e) => setAddType((e.target as HTMLSelectElement).value)}>
{NODE_TYPES.map(n => <option key={n.type} value={n.type}>{n.label}</option>)}
</select>
<button class="toolbar-btn" onClick={addNode}>+ Add node</button>
</div>
</div>
)}
</div>
<div class="wizard-footer">
<button class="toolbar-btn" onClick={onClose}>Cancel</button>
<button class="toolbar-btn toolbar-btn-primary" onClick={handleSave} disabled={saving || loading}>
{saving ? 'Saving…' : 'Save Signal'}
</button>
</div>
</div>
</div>
);
}
+582
View File
@@ -0,0 +1,582 @@
import { h, Fragment } from 'preact';
import { useState, useEffect, useRef } from 'preact/hooks';
import SearchableSelect from './SearchableSelect';
import LuaEditor from './LuaEditor';
import type { InputRef, PipelineNode, SignalDef } from './lib/types';
interface DataSource { name: string; }
interface SignalInfo { name: string; }
interface Props {
name: string;
onClose: () => void;
onSaved: () => void;
}
// ── Node-type catalogue ──────────────────────────────────────────────────────
// Mirrors the backend dsp node set (internal/dsp/nodes.go). The combine nodes
// (add/subtract/…) and expr take several inputs, which only the FIRST node of a
// pipeline receives — see the compile() note below.
interface NodeParam { label: string; key: string; type: 'number' | 'text' | 'lua'; default: string; }
interface OpDef { type: string; label: string; params: NodeParam[]; }
const OPS: OpDef[] = [
{ type: 'gain', label: 'Gain', params: [{ label: 'Factor', key: 'gain', type: 'number', default: '1' }] },
{ type: 'offset', label: 'Offset', params: [{ label: 'Offset', key: 'offset', type: 'number', default: '0' }] },
{ type: 'add', label: 'Add (Σ inputs)', params: [] },
{ type: 'subtract', label: 'Subtract (ab)', params: [] },
{ type: 'multiply', label: 'Multiply (Π)', params: [] },
{ type: 'divide', label: 'Divide (a÷b)', params: [] },
{ type: 'moving_average', label: 'Moving Average', params: [{ label: 'Window (n)', key: 'window', type: 'number', default: '10' }] },
{ type: 'rms', label: 'RMS', params: [{ label: 'Window (n)', key: 'window', type: 'number', default: '10' }] },
{ type: 'lowpass', label: 'Low-pass', params: [
{ label: 'Cutoff freq (Hz)', key: 'freq', type: 'number', default: '1' },
{ label: 'Order (18)', key: 'order', type: 'number', default: '1' },
]},
{ type: 'derivative', label: 'Derivative', params: [] },
{ type: 'integrate', label: 'Integrate', params: [] },
{ type: 'clamp', label: 'Clamp', params: [{ label: 'Min', key: 'min', type: 'number', default: '0' }, { label: 'Max', key: 'max', type: 'number', default: '100' }] },
{ type: 'threshold', label: 'Threshold', params: [
{ label: 'Threshold', key: 'threshold', type: 'number', default: '0' },
{ label: 'High output', key: 'high', type: 'number', default: '1' },
{ label: 'Low output', key: 'low', type: 'number', default: '0' },
]},
{ type: 'expr', label: 'Formula', params: [{ label: 'Expression (a,b,c,d = inputs)', key: 'expr', type: 'text', default: 'a * 1.0' }] },
{ type: 'lua', label: 'Lua Script', params: [{ label: 'Script (a,b,c,d = inputs)', key: 'script', type: 'lua', default: 'return a' }] },
];
const OP_BY_TYPE = new Map(OPS.map(o => [o.type, o]));
function opLabel(type: string): string { return OP_BY_TYPE.get(type)?.label ?? type; }
function opParamDefs(type: string): NodeParam[] { return OP_BY_TYPE.get(type)?.params ?? []; }
// ── Graph model (UI only — compiled to SignalDef on save) ───────────────────
type NodeKind = 'source' | 'op' | 'output';
interface GNode {
id: string;
kind: NodeKind;
x: number;
y: number;
ds?: string; // source
signal?: string; // source
op?: string; // op type
params?: Record<string, any>; // op
}
interface GWire { from: string; to: string; }
interface Graph { nodes: GNode[]; wires: GWire[]; }
// ── Geometry (rem-based, like the logic editor) ─────────────────────────────
const REM = (() => {
if (typeof document === 'undefined') return 16;
return parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
})();
const NODE_W = 10 * REM;
const PORT_TOP = 1.4 * REM;
const PORT_R = 0.375 * REM;
function genId(): string { return 'g_' + Math.random().toString(36).slice(2, 9); }
function hasInput(k: NodeKind): boolean { return k !== 'source'; }
function hasOutput(k: NodeKind): boolean { return k !== 'output'; }
function inAnchor(n: GNode) { return { x: n.x, y: n.y + PORT_TOP }; }
function outAnchor(n: GNode) { return { x: n.x + NODE_W, y: n.y + PORT_TOP }; }
function wirePathStr(x1: number, y1: number, x2: number, y2: number): string {
const dx = Math.max(40, Math.abs(x2 - x1) / 2);
return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`;
}
// Build an initial graph by laying out an existing SignalDef: sources stacked on
// the left, the linear pipeline as a row of op nodes, output on the right.
function buildInitial(def: SignalDef): Graph {
const inputs: InputRef[] = def.inputs && def.inputs.length > 0
? def.inputs
: (def.ds && def.signal ? [{ ds: def.ds, signal: def.signal }] : []);
const pipeline = def.pipeline ?? [];
const nodes: GNode[] = [];
const wires: GWire[] = [];
const srcIds = inputs.map((inp, i) => {
const id = genId();
nodes.push({ id, kind: 'source', x: 2 * REM, y: (2 + i * 5) * REM, ds: inp.ds, signal: inp.signal });
return id;
});
const opIds = pipeline.map((nd, i) => {
const id = genId();
nodes.push({ id, kind: 'op', x: (15 + i * 12) * REM, y: 3 * REM, op: nd.type, params: { ...nd.params } });
return id;
});
const outId = genId();
nodes.push({ id: outId, kind: 'output', x: (15 + pipeline.length * 12) * REM, y: 3 * REM });
const headId = opIds[0] ?? outId;
srcIds.forEach(s => wires.push({ from: s, to: headId }));
for (let i = 0; i < opIds.length - 1; i++) wires.push({ from: opIds[i], to: opIds[i + 1] });
if (opIds.length > 0) wires.push({ from: opIds[opIds.length - 1], to: outId });
return { nodes, wires };
}
// Compile the visual graph back into the backend's linear form. The pipeline is
// a single chain ending at the output; the head node receives every source
// signal (inputs[0]=a, inputs[1]=b, …), every later node the previous output.
function compile(g: Graph): { inputs?: InputRef[]; pipeline?: PipelineNode[]; error?: string } {
const output = g.nodes.find(n => n.kind === 'output');
if (!output) return { error: 'missing output node' };
const byId = new Map(g.nodes.map(n => [n.id, n]));
const upstream = (id: string) => g.wires.filter(w => w.to === id).map(w => byId.get(w.from)).filter(Boolean) as GNode[];
const chain: GNode[] = [];
const seen = new Set<string>();
let cur: GNode = output;
for (;;) {
if (seen.has(cur.id)) return { error: 'the graph contains a cycle' };
seen.add(cur.id);
const ups = upstream(cur.id);
const opUps = ups.filter(n => n.kind === 'op');
const srcUps = ups.filter(n => n.kind === 'source');
if (opUps.length > 1) return { error: 'a node may have only one upstream node — the pipeline must be a single chain' };
if (opUps.length === 1) {
if (srcUps.length > 0) return { error: 'only the first processing node may take input signals directly' };
chain.unshift(opUps[0]);
cur = opUps[0];
continue;
}
// Reached the head of the chain: its source upstreams become the inputs.
if (srcUps.length === 0) {
return { error: cur.kind === 'output' ? 'connect at least one signal to the output' : 'the first node has no input signals' };
}
const inputs = srcUps
.slice()
.sort((a, b) => a.y - b.y)
.map(n => ({ ds: n.ds || '', signal: n.signal || '' }));
if (inputs.some(i => !i.ds || !i.signal)) return { error: 'every input signal needs a data source and a signal' };
const pipeline = chain.map(n => ({ type: n.op!, params: n.params ?? {} }));
return { inputs, pipeline };
}
}
function nodeSummary(n: GNode): string {
if (n.kind === 'source') return n.ds && n.signal ? `${n.ds}:${n.signal}` : '(pick a signal)';
if (n.kind === 'output') return 'synthetic result';
const p = n.params ?? {};
switch (n.op) {
case 'gain': return `× ${p.gain ?? 1}`;
case 'offset': return `+ ${p.offset ?? 0}`;
case 'moving_average': return `avg ${p.window ?? 10}`;
case 'rms': return `rms ${p.window ?? 10}`;
case 'lowpass': return `${p.freq ?? 1} Hz`;
case 'clamp': return `[${p.min ?? 0}, ${p.max ?? 100}]`;
case 'threshold': return `> ${p.threshold ?? 0}`;
case 'expr': return String(p.expr ?? '');
case 'lua': return 'lua';
default: return opLabel(n.op ?? '');
}
}
export default function SyntheticGraphEditor({ name, onClose, onSaved }: Props) {
const [def, setDef] = useState<SignalDef | null>(null);
const [graph, setGraph] = useState<Graph>({ nodes: [], wires: [] });
const [selected, setSelected] = useState<string | null>(null);
const [selectedWire, setSelectedWire] = useState<number | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [dataSources, setDataSources] = useState<string[]>([]);
const [dsSignals, setDsSignals] = useState<Record<string, string[]>>({});
const canvasRef = useRef<HTMLDivElement>(null);
const dragNode = useRef<{ id: string; dx: number; dy: number; pushed: boolean } | null>(null);
const [pendingWire, setPendingWire] = useState<{ from: string; x: number; y: number } | null>(null);
const pendingRef = useRef(pendingWire);
pendingRef.current = pendingWire;
const graphRef = useRef(graph);
graphRef.current = graph;
const undoStack = useRef<Graph[]>([]);
const redoStack = useRef<Graph[]>([]);
const [, setTick] = useState(0);
const bump = () => setTick(t => t + 1);
const canUndo = undoStack.current.length > 0;
const canRedo = redoStack.current.length > 0;
useEffect(() => {
fetch('/api/v1/datasources')
.then(r => r.ok ? r.json() : [])
.then((ds: DataSource[]) => setDataSources(ds.map(d => d.name)))
.catch(() => {});
}, []);
async function loadSignals(ds: string) {
if (!ds || dsSignals[ds]) return;
try {
const res = await fetch(`/api/v1/signals?ds=${encodeURIComponent(ds)}`);
if (!res.ok) return;
const sigs: SignalInfo[] = await res.json();
setDsSignals(prev => ({ ...prev, [ds]: sigs.map(s => s.name) }));
} catch {}
}
useEffect(() => {
fetch(`/api/v1/synthetic/${encodeURIComponent(name)}`)
.then(r => r.ok ? r.json() : Promise.reject(r.statusText))
.then((d: SignalDef) => {
setDef(d);
const g = buildInitial(d);
setGraph(g);
g.nodes.forEach(n => { if (n.kind === 'source' && n.ds) loadSignals(n.ds); });
})
.catch(e => setError(String(e)))
.finally(() => setLoading(false));
}, [name]);
// ── History ────────────────────────────────────────────────────────────────
function pushUndo() {
undoStack.current = [...undoStack.current.slice(-49), graphRef.current];
redoStack.current = [];
}
function commit(next: Graph, record = true) {
if (record) pushUndo();
setGraph(next);
}
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);
setGraph(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);
setSelected(null); setSelectedWire(null);
setGraph(next); bump();
}
// ── Graph mutators ───────────────────────────────────────────────────────
function addSource() {
const node: GNode = { id: genId(), kind: 'source', x: 2 * REM, y: (2 + graph.nodes.filter(n => n.kind === 'source').length * 5) * REM, ds: dataSources[0] || '', signal: '' };
if (node.ds) loadSignals(node.ds);
commit({ nodes: [...graph.nodes, node], wires: graph.wires });
setSelected(node.id); setSelectedWire(null);
}
function addOp(op: OpDef, x?: number, y?: number) {
const params: Record<string, any> = {};
for (const p of op.params) params[p.key] = p.type === 'number' ? parseFloat(p.default) : p.default;
const node: GNode = { id: genId(), kind: 'op', op: op.type, params, x: x ?? (14 + (graph.nodes.length % 4) * 3) * REM, y: y ?? (3 + (graph.nodes.length % 4) * 2) * REM };
commit({ nodes: [...graph.nodes, node], wires: graph.wires });
setSelected(node.id); setSelectedWire(null);
}
function patchNode(id: string, patch: Partial<GNode>) {
commit({ nodes: graph.nodes.map(n => (n.id === id ? { ...n, ...patch } : n)), wires: graph.wires });
}
function patchParam(id: string, key: string, val: string) {
commit({
nodes: graph.nodes.map(n => {
if (n.id !== id) return n;
const pd = opParamDefs(n.op ?? '').find(p => p.key === key);
const typed: any = pd?.type === 'number' ? parseFloat(val) : val;
return { ...n, params: { ...n.params, [key]: typed } };
}),
wires: graph.wires,
});
}
function moveNode(id: string, x: number, y: number, record: boolean) {
commit({ nodes: graph.nodes.map(n => (n.id === id ? { ...n, x, y } : n)), wires: graph.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) });
if (selected === id) setSelected(null);
}
function addWire(from: string, to: string) {
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) || !hasInput(tn.kind)) return;
if (graph.wires.some(w => w.from === from && w.to === to)) return;
commit({ nodes: graph.nodes, wires: [...graph.wires, { from, to }] });
}
function deleteWire(idx: number) {
commit({ nodes: graph.nodes, wires: graph.wires.filter((_, i) => i !== idx) });
if (selectedWire === idx) setSelectedWire(null);
}
// ── Pointer / drag / wire ──────────────────────────────────────────────────
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 };
}
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);
window.addEventListener('mousemove', onNodeDragMove);
window.addEventListener('mouseup', onNodeDragUp);
}
function onNodeDragMove(e: MouseEvent) {
const d = dragNode.current;
if (!d) return;
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);
}
function onNodeDragUp() {
dragNode.current = null;
window.removeEventListener('mousemove', onNodeDragMove);
window.removeEventListener('mouseup', onNodeDragUp);
}
function startWire(e: MouseEvent, node: GNode) {
e.stopPropagation();
const p = toCanvas(e);
setPendingWire({ from: node.id, x: p.x, y: p.y });
window.addEventListener('mousemove', onWireMove);
window.addEventListener('mouseup', onWireUp);
}
function onWireMove(e: MouseEvent) {
const cur = pendingRef.current;
if (!cur) return;
const p = toCanvas(e);
setPendingWire({ ...cur, x: p.x, y: p.y });
}
function endWire() {
pendingRef.current = null;
window.removeEventListener('mousemove', onWireMove);
window.removeEventListener('mouseup', onWireUp);
setPendingWire(null);
}
function onWireUp() { endWire(); }
function finishWire(target: GNode) {
const cur = pendingRef.current;
if (cur && hasInput(target.kind)) addWire(cur.from, target.id);
endWire();
}
// ── Palette drag-and-drop ──────────────────────────────────────────────────
const DRAG_MIME = 'application/x-uopi-synth-op';
function onCanvasDragOver(e: DragEvent) {
if (Array.from(e.dataTransfer?.types ?? []).includes(DRAG_MIME)) {
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
}
}
function onCanvasDrop(e: DragEvent) {
const type = e.dataTransfer?.getData(DRAG_MIME);
if (!type) return;
e.preventDefault();
const op = OP_BY_TYPE.get(type);
if (!op) return;
const p = toCanvas(e);
addOp(op, Math.max(0, p.x - NODE_W / 2), Math.max(0, p.y - PORT_TOP));
}
// ── Keyboard ────────────────────────────────────────────────────────────────
useEffect(() => {
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 (e.key !== 'Delete' && e.key !== 'Backspace') return;
if (selectedWire !== null) deleteWire(selectedWire);
else if (selected) deleteNode(selected);
}
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [selected, selectedWire, graph]);
const byId = new Map(graph.nodes.map(n => [n.id, n]));
const sel = graph.nodes.find(n => n.id === selected) ?? null;
const compiled = compile(graph);
async function handleSave() {
if (!def) return;
if (compiled.error) { setError(compiled.error); return; }
setSaving(true);
setError('');
try {
const body = { ...def, inputs: compiled.inputs, pipeline: compiled.pipeline, ds: undefined, signal: undefined };
const res = await fetch(`/api/v1/synthetic/${encodeURIComponent(name)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const j = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(j.error ?? res.statusText);
}
onSaved();
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setSaving(false);
}
}
function nodeClass(n: GNode): string {
const cat = n.kind === 'source' ? 'trigger' : n.kind === 'output' ? 'action' : 'flow';
return `flow-node flow-node-${cat}${selected === n.id ? ' flow-node-selected' : ''}`;
}
return (
<div class="wizard-backdrop" onClick={onClose}>
<div class="synth-graph" onClick={(e) => e.stopPropagation()}>
<div class="wizard-header">
<span>Synthetic Signal {name}</span>
<button class="icon-btn" onClick={onClose}></button>
</div>
{loading ? (
<div class="wizard-body"><p class="hint">Loading</p></div>
) : (
<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>
</div>
<div class="flow-palette-title">Inputs</div>
<button class="flow-palette-btn flow-palette-trigger" onClick={addSource}>+ Signal</button>
<div class="flow-palette-title">Operations</div>
{OPS.map(op => (
<button key={op.type} class="flow-palette-btn flow-palette-flow"
title={`${op.type} — drag onto the canvas or click to add`}
draggable
onDragStart={(e) => { e.dataTransfer?.setData(DRAG_MIME, op.type); if (e.dataTransfer) e.dataTransfer.effectAllowed = 'copy'; }}
onClick={() => addOp(op)}>{op.label}</button>
))}
<div class="flow-palette-hint hint">
Wire input signals into the first operation, chain operations, and connect the
last one to <b>Output</b>. The first node receives every input as <code>a,b,c,d</code>.
</div>
</div>
<div class="flow-canvas" ref={canvasRef}
onMouseDown={() => { setSelected(null); setSelectedWire(null); }}
onDragOver={onCanvasDragOver}
onDrop={onCanvasDrop}>
<div class="flow-canvas-inner">
<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);
return (
<path key={idx}
class={`flow-wire${selectedWire === idx ? ' flow-wire-selected' : ''}`}
d={wirePathStr(p1.x, p1.y, p2.x, p2.y)}
onClick={(e) => { e.stopPropagation(); setSelectedWire(idx); setSelected(null); }} />
);
})}
{pendingWire && (() => {
const a = byId.get(pendingWire.from);
if (!a) return null;
const p1 = outAnchor(a);
return <path class="flow-wire flow-wire-pending" d={wirePathStr(p1.x, p1.y, pendingWire.x, pendingWire.y)} />;
})()}
</svg>
{graph.nodes.map(node => (
<div key={node.id}
class={nodeClass(node)}
style={`left:${node.x}px; top:${node.y}px; width:${NODE_W}px;`}
onMouseDown={(e) => startNodeDrag(e, node)}
onMouseUp={() => { if (pendingRef.current) finishWire(node); }}>
<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' && (
<button class="flow-node-del" title="Delete node"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); deleteNode(node.id); }}></button>
)}
</div>
<div class="flow-node-body hint">{nodeSummary(node)}</div>
{hasInput(node.kind) && (
<div class="flow-port flow-port-in" title="Input"
style={`top:${PORT_TOP - PORT_R}px; left:${-PORT_R}px;`}
onMouseDown={(e) => e.stopPropagation()}
onMouseUp={(e) => { e.stopPropagation(); finishWire(node); }} />
)}
{hasOutput(node.kind) && (
<div class="flow-port flow-port-out" title="Output"
style={`top:${PORT_TOP - PORT_R}px; right:${-PORT_R}px;`}
onMouseDown={(e) => startWire(e, node)} />
)}
</div>
))}
</div>
</div>
<div class="flow-inspector">
{!sel && <div class="hint">Select a node to edit it, or add one from the palette.</div>}
{sel?.kind === 'source' && (
<Fragment>
<div class="wizard-section-title">Input signal</div>
<div class="wizard-field">
<label>Data source</label>
<SearchableSelect value={sel.ds ?? ''} options={dataSources}
onSelect={(ds) => { loadSignals(ds); patchNode(sel.id, { ds, signal: '' }); }} />
</div>
<div class="wizard-field">
<label>Signal</label>
<SearchableSelect value={sel.signal ?? ''} options={dsSignals[sel.ds ?? ''] ?? []}
onSelect={(signal) => patchNode(sel.id, { signal })}
placeholder={sel.ds ? 'Search signals…' : 'Select data source first'} />
</div>
<button class="panel-btn" style="margin-top:1rem;" onClick={() => deleteNode(sel.id)}>Delete node</button>
</Fragment>
)}
{sel?.kind === 'op' && (
<Fragment>
<div class="wizard-section-title">{opLabel(sel.op ?? '')}</div>
{opParamDefs(sel.op ?? '').length === 0 && (
<p class="hint">No parameters this operation transforms its input directly.</p>
)}
{opParamDefs(sel.op ?? '').map(pd => (
<div key={pd.key} class="wizard-field">
<label>{pd.label}</label>
{pd.type === 'lua' ? (
<LuaEditor value={String(sel.params?.[pd.key] ?? pd.default)}
onChange={(v) => patchParam(sel.id, pd.key, v)} />
) : (
<input class="prop-input" type={pd.type === 'number' ? 'number' : 'text'}
value={sel.params?.[pd.key] ?? pd.default}
onInput={(e) => patchParam(sel.id, pd.key, (e.target as HTMLInputElement).value)} />
)}
</div>
))}
<button class="panel-btn" style="margin-top:1rem;" onClick={() => deleteNode(sel.id)}>Delete node</button>
</Fragment>
)}
{sel?.kind === 'output' && (
<Fragment>
<div class="wizard-section-title">Output</div>
<p class="hint">The value wired into this node becomes the signal <b>{name}</b>.</p>
</Fragment>
)}
</div>
</div>
)}
<div class="wizard-footer">
{error && <span class="wizard-error" style="flex:1;">{error}</span>}
{!error && compiled.error && <span class="hint" style="flex:1;">{compiled.error}</span>}
<button class="toolbar-btn" onClick={onClose}>Cancel</button>
<button class="toolbar-btn toolbar-btn-primary" onClick={handleSave} disabled={saving || loading || !!compiled.error}>
{saving ? 'Saving…' : 'Save Signal'}
</button>
</div>
</div>
</div>
);
}
+15
View File
@@ -10,6 +10,7 @@ import type { Interface } from './lib/types';
import ContextualHelp from './ContextualHelp'; import ContextualHelp from './ContextualHelp';
import HelpModal from './HelpModal'; import HelpModal from './HelpModal';
import ZoomControl from './ZoomControl'; import ZoomControl from './ZoomControl';
import ControlLogicEditor from './ControlLogicEditor';
interface Props { interface Props {
onEdit?: (iface?: Interface) => void; onEdit?: (iface?: Interface) => void;
@@ -31,6 +32,7 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) {
const [showHelp, setShowHelp] = useState(false); const [showHelp, setShowHelp] = useState(false);
const [helpSection, setHelpSection] = useState('start'); const [helpSection, setHelpSection] = useState('start');
const [showTimeNav, setShowTimeNav] = useState(false); const [showTimeNav, setShowTimeNav] = useState(false);
const [showControlLogic, setShowControlLogic] = useState(false);
const [leftW, setLeftW] = useState(220); const [leftW, setLeftW] = useState(220);
const [listCollapsed, setListCollapsed] = useState(false); const [listCollapsed, setListCollapsed] = useState(false);
const me = useAuth(); const me = useAuth();
@@ -155,6 +157,15 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) {
📖 📖
</button> </button>
<ZoomControl /> <ZoomControl />
{writable && me.canEditLogic && (
<button
class="toolbar-btn"
onClick={() => setShowControlLogic(true)}
title="Open server-side control logic"
>
Control logic
</button>
)}
{writable && ( {writable && (
<button <button
class="btn-edit" class="btn-edit"
@@ -242,6 +253,10 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) {
{showHelp && ( {showHelp && (
<HelpModal initialSection={helpSection} onClose={() => setShowHelp(false)} /> <HelpModal initialSection={helpSection} onClose={() => setShowHelp(false)} />
)} )}
{showControlLogic && (
<ControlLogicEditor onClose={() => setShowControlLogic(false)} />
)}
</div> </div>
); );
} }
+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 // Default identity used before /api/v1/me resolves (or if it fails): assume a
// trusted LAN user with full access, matching the backend default. // trusted LAN user with full access, matching the backend default.
export const DEFAULT_ME: Me = { user: '', level: 'write', groups: [] }; export const DEFAULT_ME: Me = { user: '', level: 'write', groups: [], canEditLogic: true };
// AuthContext carries the resolved identity + global access level for the // AuthContext carries the resolved identity + global access level for the
// current user throughout the app. // current user throughout the app.
@@ -36,6 +36,7 @@ export async function fetchMe(): Promise<Me> {
user: typeof data.user === 'string' ? data.user : '', user: typeof data.user === 'string' ? data.user : '',
level: (data.level as AccessLevel) ?? 'write', level: (data.level as AccessLevel) ?? 'write',
groups: Array.isArray(data.groups) ? data.groups : [], groups: Array.isArray(data.groups) ? data.groups : [],
canEditLogic: data.canEditLogic !== false,
}; };
} catch { } catch {
return DEFAULT_ME; return DEFAULT_ME;
+270 -8
View File
@@ -26,9 +26,38 @@
import { wsClient } from './ws'; import { wsClient } from './ws';
import { getSignalStore } from './stores'; import { getSignalStore } from './stores';
import { writable } from './store';
import { setWidgetDisabled, setWidgetHidden, setPlotPaused, clearPlot, resetWidgetCmds } from './widgetCommands';
import type { SignalRef, SignalValue, LogicGraph, LogicNode } from './types'; import type { SignalRef, SignalValue, LogicGraph, LogicNode } from './types';
import { evalExpr, evalBool, collectRefs, type Resolver } from './expr'; import { evalExpr, evalBool, collectRefs, type Resolver } from './expr';
// A single row in a dialog: either an `input` (prompts the operator for a value
// that is later written to its target) or a `display` (a read-only live value
// shown to the operator). A dialog may carry any number of both.
export interface DialogField {
type: 'input' | 'display';
label: string;
// input: the pre-filled default text; display: the resolved value to show.
value: string;
}
// A pending user-interaction dialog requested by an action.dialog.* node. The
// engine pushes one to `logicDialogs` and awaits the user's response; the
// LogicDialogs component renders it and calls `resolve` with the entered values
// for the input fields (in field order), or null for Cancel. Info/error
// dialogs have no inputs and resolve with an empty array on OK.
export interface DialogRequest {
id: string;
kind: 'info' | 'error' | 'setpoint';
title: string;
message: string;
fields: DialogField[];
resolve: (entered: string[] | null) => void;
}
// Active dialogs, rendered by <LogicDialogs/>. Cleared when the panel unmounts.
export const logicDialogs = writable<DialogRequest[]>([]);
// Guards against runaway flows (cycles / pathological loops). // Guards against runaway flows (cycles / pathological loops).
const MAX_STEPS = 100000; const MAX_STEPS = 100000;
const MAX_LOOP = 100000; const MAX_LOOP = 100000;
@@ -57,6 +86,36 @@ function refKey(ds: string, name: string): string {
return `${ds}\0${name}`; return `${ds}\0${name}`;
} }
// A dialog field as configured on a node (before its value is resolved).
interface DialogFieldSpec {
type: 'input' | 'display';
label: string;
target?: string; // input: "ds:name" (or bare local var) to write on OK
default?: string; // input: expression pre-filling the input
expr?: string; // display: expression evaluated and shown
}
// Linearly interpolate a value at time `t` from timestamp-sorted samples. Returns
// null when `t` falls outside the samples' own time range (no extrapolation).
function interpAt(samples: Array<{ t: number; v: number }>, t: number): number | null {
const n = samples.length;
if (n === 0 || t < samples[0].t || t > samples[n - 1].t) return null;
for (let i = 0; i < n - 1; i++) {
const a = samples[i], b = samples[i + 1];
if (t >= a.t && t <= b.t) {
if (b.t === a.t) return a.v;
return a.v + (b.v - a.v) * (t - a.t) / (b.t - a.t);
}
}
return samples[n - 1].v;
}
// Escape a CSV cell: quote and double inner quotes when it contains a comma,
// quote, or newline.
function csvEsc(s: string): string {
return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
}
interface WireOut { to: string; port: string } interface WireOut { to: string; port: string }
// One flow run, started by an activating trigger. `dt` is the seconds elapsed // One flow run, started by an activating trigger. `dt` is the seconds elapsed
// since that trigger last fired (0 on its first fire); `resolve` is an // since that trigger last fired (0 on its first fire); `resolve` is an
@@ -132,10 +191,26 @@ class LogicEngine {
// button/threshold/change are driven imperatively / by subscriptions. // button/threshold/change are driven imperatively / by subscriptions.
} }
} }
// Panel-open lifecycle: fire every "On open" trigger once now that the
// graph is wired and subscriptions are live.
for (const node of this.graph.nodes) {
if (node.kind === 'trigger.start') this.activate(node.id);
}
} }
/** Deactivate all triggers and subscriptions. Called when a panel unmounts. */ /** Deactivate all triggers and subscriptions. Called when a panel unmounts. */
clear(): void { clear(): void {
// Panel-close lifecycle: fire every "On close" trigger before tearing down,
// while the live cache and wiring are still intact (synchronous writes run
// immediately; anything past an await is truncated as the graph resets).
for (const node of this.graph.nodes) {
if (node.kind === 'trigger.stop') this.activate(node.id);
}
// Dismiss any dialogs left open by the panel being closed (resolve null).
for (const d of logicDialogs.get()) { try { d.resolve(null); } catch {} }
logicDialogs.set([]);
for (const c of this.cleanups) { try { c(); } catch {} } for (const c of this.cleanups) { try { c(); } catch {} }
this.cleanups = []; this.cleanups = [];
this.graph = { nodes: [], wires: [] }; this.graph = { nodes: [], wires: [] };
@@ -149,6 +224,9 @@ class LogicEngine {
this.watchers = new Map(); this.watchers = new Map();
this.arrays = new Map(); this.arrays = new Map();
this.lastFire = new Map(); this.lastFire = new Map();
// Restore every widget to its default state so reopening a panel (whose
// widget ids are stable) does not inherit disabled/hidden/paused flags.
resetWidgetCmds();
} }
/** Fire every button-trigger node whose `name` param matches. */ /** Fire every button-trigger node whose `name` param matches. */
@@ -188,6 +266,17 @@ class LogicEngine {
case 'action.write': case 'action.write':
case 'action.accumulate': case 'action.accumulate':
case 'action.log': collectRefs(node.params.expr ?? '').forEach(want); break; case 'action.log': collectRefs(node.params.expr ?? '').forEach(want); break;
case 'action.dialog.info':
case 'action.dialog.error':
case 'action.dialog.setpoint': {
this.messageRefs(node.params.message ?? '').forEach(want);
this.messageRefs(node.params.title ?? '').forEach(want);
for (const f of this.dialogFieldSpecs(node)) {
if (f.type === 'display') collectRefs(f.expr ?? '').forEach(want);
else collectRefs(f.default ?? '').forEach(want);
}
break;
}
} }
} }
@@ -318,7 +407,7 @@ class LogicEngine {
} }
case 'action.export': { case 'action.export': {
this.exportArray((node.params.array ?? '').trim(), node.params.filename ?? ''); this.exportArrays(this.exportColumns(node), node.params.align ?? 'common', node.params.filename ?? '');
await this.follow(node.id, 'out', ctx); await this.follow(node.id, 'out', ctx);
return; return;
} }
@@ -338,19 +427,119 @@ class LogicEngine {
return; return;
} }
case 'action.widget': {
const wid = (node.params.widget ?? '').trim();
if (wid) {
switch (node.params.op ?? '') {
case 'enable': setWidgetDisabled(wid, false); break;
case 'disable': setWidgetDisabled(wid, true); break;
case 'show': setWidgetHidden(wid, false); break;
case 'hide': setWidgetHidden(wid, true); break;
case 'pauseplot': setPlotPaused(wid, true); break;
case 'resumeplot': setPlotPaused(wid, false); break;
case 'clearplot': clearPlot(wid); break;
}
}
await this.follow(node.id, 'out', ctx);
return;
}
case 'action.dialog.info':
case 'action.dialog.error': {
const specs = this.dialogFieldSpecs(node);
await this.showDialog({
kind: node.kind === 'action.dialog.error' ? 'error' : 'info',
title: this.interpolate(node.params.title ?? '', ctx.resolve),
message: this.interpolate(node.params.message ?? '', ctx.resolve),
fields: this.dialogFieldViews(specs, ctx),
});
await this.follow(node.id, 'out', ctx);
return;
}
case 'action.dialog.setpoint': {
const specs = this.dialogFieldSpecs(node);
const result = await this.showDialog({
kind: 'setpoint',
title: this.interpolate(node.params.title ?? '', ctx.resolve),
message: this.interpolate(node.params.message ?? '', ctx.resolve),
fields: this.dialogFieldViews(specs, ctx),
});
// Cancel (null) aborts the flow; OK writes every input value and continues.
if (result === null) return;
let i = 0;
for (const spec of specs) {
if (spec.type !== 'input') continue;
const ref = parseRef(spec.target ?? '');
const num = parseFloat(result[i++] ?? '');
if (ref && !isNaN(num)) wsClient.write(ref, num);
}
await this.follow(node.id, 'out', ctx);
return;
}
default: default:
// A trigger reached mid-walk (unusual): just pass through. // A trigger reached mid-walk (unusual): just pass through.
await this.follow(node.id, 'out', ctx); await this.follow(node.id, 'out', ctx);
} }
} }
// Dump a named array to a CSV file the browser downloads (timestamp + value). // The columns an action.export node should emit. Reads the JSON `columns`
private exportArray(name: string, filename: string): void { // param (a list of { array, label }); falls back to the legacy single `array`.
if (!name) return; private exportColumns(node: LogicNode): Array<{ array: string; label: string }> {
const arr = this.arrays.get(name) ?? []; const raw = (node.params.columns ?? '').trim();
const rows = arr.map(p => `${p.t},${new Date(p.t).toISOString()},${p.v}`); if (raw) {
const csv = 'timestamp_ms,iso,value\n' + rows.join('\n') + '\n'; try {
const safe = (filename || name).replace(/[^a-z0-9_.-]/gi, '_'); const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
return parsed
.map((c: any) => ({ array: String(c?.array ?? '').trim(), label: String(c?.label ?? '').trim() }))
.filter(c => c.array);
}
} catch {}
}
const a = (node.params.array ?? '').trim();
return a ? [{ array: a, label: a }] : [];
}
// Dump one or more named arrays to a CSV the browser downloads. Each column is
// a captured array; rows are keyed by sample timestamp. `align` controls how
// arrays with differing timestamps are combined:
// common — only timestamps present in every array (intersection).
// any — the union of all timestamps; cells with no sample are blank.
// interpolate — the union of all timestamps; a missing cell is linearly
// interpolated between the array's surrounding samples (blank
// outside the array's own time range).
private exportArrays(cols: Array<{ array: string; label: string }>, align: string, filename: string): void {
if (cols.length === 0) return;
const series = cols.map((c, i) => ({
label: c.label || c.array || `col${i + 1}`,
samples: (this.arrays.get(c.array) ?? []).slice().sort((a, b) => a.t - b.t),
map: new Map<number, number>(),
}));
for (const s of series) for (const p of s.samples) s.map.set(p.t, p.v);
const tsSet = new Set<number>();
for (const s of series) for (const p of s.samples) tsSet.add(p.t);
let times = Array.from(tsSet).sort((a, b) => a - b);
if (align === 'common') times = times.filter(t => series.every(s => s.map.has(t)));
const cell = (s: typeof series[number], t: number): string => {
const exact = s.map.get(t);
if (exact !== undefined) return String(exact);
if (align === 'interpolate') {
const v = interpAt(s.samples, t);
return v === null ? '' : String(v);
}
return ''; // 'any' (and 'common' never reaches here)
};
const header = ['timestamp_ms', 'iso', ...series.map(s => s.label)];
const rows = times.map(t => [String(t), new Date(t).toISOString(), ...series.map(s => cell(s, t))]);
const csv = [header, ...rows].map(r => r.map(csvEsc).join(',')).join('\n') + '\n';
const base = filename || series.map(s => s.label).join('_') || 'export';
const safe = base.replace(/[^a-z0-9_.-]/gi, '_');
const fname = safe.toLowerCase().endsWith('.csv') ? safe : `${safe}.csv`; const fname = safe.toLowerCase().endsWith('.csv') ? safe : `${safe}.csv`;
const blob = new Blob([csv], { type: 'text/csv' }); const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
@@ -361,6 +550,79 @@ class LogicEngine {
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }
// ── Dialogs (user interaction) ───────────────────────────────────────────────
// The field specs of a dialog node. Reads the JSON `fields` param (a list of
// { type:'input'|'display', label, target?, default?, expr? }); for a legacy
// set-point node with no `fields` it falls back to the single target/default.
private dialogFieldSpecs(node: LogicNode): DialogFieldSpec[] {
const raw = (node.params.fields ?? '').trim();
if (raw) {
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
return parsed.map((f: any) => ({
type: f?.type === 'display' ? 'display' : 'input',
label: String(f?.label ?? ''),
target: String(f?.target ?? ''),
default: String(f?.default ?? ''),
expr: String(f?.expr ?? ''),
}));
}
} catch {}
}
if (node.kind === 'action.dialog.setpoint') {
return [{ type: 'input', label: '', target: node.params.target ?? '', default: node.params.default ?? '', expr: '' }];
}
return [];
}
// Resolve a node's field specs into displayable views: display fields get their
// expression evaluated to text; input fields get their default expression
// evaluated to a pre-fill string ('' when no default is set).
private dialogFieldViews(specs: DialogFieldSpec[], ctx: RunCtx): DialogField[] {
return specs.map(s => {
if (s.type === 'display') {
const v = evalExpr(s.expr ?? '', ctx.resolve);
return { type: 'display' as const, label: s.label, value: isNaN(v) ? '?' : String(v) };
}
const def = (s.default ?? '').trim() ? String(evalExpr(s.default ?? '', ctx.resolve)) : '';
return { type: 'input' as const, label: s.label, value: def };
});
}
// Push a dialog request to the shared store and resolve when the user responds
// (the entered input values on OK, null on Cancel).
private showDialog(req: Omit<DialogRequest, 'id' | 'resolve'>): Promise<string[] | null> {
return new Promise((resolve) => {
const id = 'dlg_' + Math.random().toString(36).slice(2, 9);
const full: DialogRequest = {
...req,
id,
resolve: (r) => { logicDialogs.update(list => list.filter(d => d.id !== id)); resolve(r); },
};
logicDialogs.update(list => [...list, full]);
});
}
// Substitute {ds:name} (and {sys:*}) tokens in dialog text with live values.
private interpolate(text: string, resolve: Resolver): string {
return (text ?? '').replace(/\{([^}]*)\}/g, (_m, inner) => {
const v = evalExpr('{' + inner + '}', resolve);
return isNaN(v) ? '?' : String(v);
});
}
// Signals referenced by {…} tokens in dialog message/title text.
private messageRefs(text: string): SignalRef[] {
const out: SignalRef[] = [];
for (const m of (text ?? '').matchAll(/\{([^}]*)\}/g)) {
const r = parseRef(m[1]);
if (r) out.push(r);
}
return out;
}
// An AND-gate fires when every incoming trigger is currently satisfied: the // An AND-gate fires when every incoming trigger is currently satisfied: the
// activating trigger counts as satisfied, and level triggers (threshold) use // activating trigger counts as satisfied, and level triggers (threshold) use
// their current truth. Momentary triggers that are not firing now are false. // their current truth. Momentary triggers that are not firing now are false.
+33 -3
View File
@@ -84,6 +84,8 @@ export interface StateVar {
// (params: interval). // (params: interval).
// trigger.change — fired whenever a signal's value changes // trigger.change — fired whenever a signal's value changes
// (params: signal). // (params: signal).
// trigger.start — fired once when the panel is opened (no params).
// trigger.stop — fired once when the panel is closed (no params).
// //
// Gates combine/guard flow: // Gates combine/guard flow:
// gate.and — passes to its output only when ALL incoming triggers // gate.and — passes to its output only when ALL incoming triggers
@@ -101,11 +103,29 @@ export interface StateVar {
// action.delay — pause `ms` before continuing downstream (params: ms). // action.delay — pause `ms` before continuing downstream (params: ms).
// action.accumulate — evaluate `expr` and append the value (with a timestamp) // action.accumulate — evaluate `expr` and append the value (with a timestamp)
// to the named in-memory data array (params: array, expr). // to the named in-memory data array (params: array, expr).
// action.export — download the named array as a CSV file (params: array, // action.export — download one or more named arrays as a CSV file, one
// filename). // column per array (params: columns = JSON list of
// {array,label}, align 'common'|'any'|'interpolate',
// filename). Legacy single-array nodes use `array`.
// action.clear — empty the named array (params: array). // action.clear — empty the named array (params: array).
// action.log — evaluate `expr` and log it to the browser console for // action.log — evaluate `expr` and log it to the browser console for
// debugging a flow (params: expr, label). // debugging a flow (params: expr, label).
// action.widget — drive a panel widget by id (params: widget, op). `op` is
// one of enable/disable (interaction), show/hide
// (visibility), or for plot widgets clearplot (one-shot
// buffer clear) / pauseplot / resumeplot (live stream).
// action.dialog.info — show an info dialog and wait for the user to dismiss
// it before continuing (params: title, message, fields).
// action.dialog.error — like dialog.info but styled as an error (params:
// title, message, fields).
// action.dialog.setpoint — prompt the user for one or more values; on OK write
// each input to its target and continue, on Cancel abort
// the flow (params: title, message, fields).
// All dialogs may carry a `fields` param: a JSON list of
// {type:'input'|'display', label, target, default, expr}. `input` fields
// prompt for a value written to `target`; `display` fields show the resolved
// `expr`. Legacy set-point nodes use `target`/`default` for a single input.
// Dialog `message`/`title` text may embed signal values as {ds:name} tokens.
// //
// Expressions (cond/expr) may reference signals inline as {ds:name} and panel- // Expressions (cond/expr) may reference signals inline as {ds:name} and panel-
// local state variables as bare identifiers. See lib/expr.ts. // local state variables as bare identifiers. See lib/expr.ts.
@@ -115,6 +135,8 @@ export type LogicNodeKind =
| 'trigger.timer' | 'trigger.timer'
| 'trigger.loop' | 'trigger.loop'
| 'trigger.change' | 'trigger.change'
| 'trigger.start'
| 'trigger.stop'
| 'gate.and' | 'gate.and'
| 'flow.if' | 'flow.if'
| 'flow.loop' | 'flow.loop'
@@ -123,7 +145,11 @@ export type LogicNodeKind =
| 'action.accumulate' | 'action.accumulate'
| 'action.export' | 'action.export'
| 'action.clear' | 'action.clear'
| 'action.log'; | 'action.log'
| 'action.widget'
| 'action.dialog.info'
| 'action.dialog.error'
| 'action.dialog.setpoint';
// A block on the flow canvas. `params` holds kind-specific fields as strings. // A block on the flow canvas. `params` holds kind-specific fields as strings.
export interface LogicNode { export interface LogicNode {
@@ -180,6 +206,9 @@ export interface Me {
user: string; user: string;
level: AccessLevel; level: AccessLevel;
groups: string[]; groups: string[];
// Whether the user may add/edit panel logic and server-side control logic.
// False only when a logic-editors allowlist is configured and excludes them.
canEditLogic: boolean;
} }
// Effective permission a user holds on a single panel or folder. // Effective permission a user holds on a single panel or folder.
@@ -224,6 +253,7 @@ export interface InterfaceListItem {
version: number; version: number;
owner?: string; owner?: string;
folder?: string; folder?: string;
order?: number;
perm: Perm; perm: Perm;
} }
+47
View File
@@ -0,0 +1,47 @@
// Per-widget command bus.
//
// Panel logic (logic.ts) runs in view mode and can drive individual widgets via
// the `action.widget` node: enable/disable, show/hide, and — for plot widgets —
// clear or pause/resume the live stream. Each widget id gets a small reactive
// store; widget components subscribe to their own store (Canvas wraps every
// widget in a <WidgetView> that honours disabled/hidden, and PlotWidget reacts
// to paused/clear). The engine writes commands through the setters below.
import { writable, type Writable } from './store';
export interface WidgetCmd {
disabled: boolean; // interaction blocked + dimmed
hidden: boolean; // removed from the view
paused: boolean; // plot: stop ingesting live samples
clearNonce: number; // plot: bumped to request a one-shot buffer clear
}
const DEFAULT: WidgetCmd = { disabled: false, hidden: false, paused: false, clearNonce: 0 };
const stores = new Map<string, Writable<WidgetCmd>>();
export function getWidgetCmdStore(id: string): Writable<WidgetCmd> {
let s = stores.get(id);
if (!s) { s = writable({ ...DEFAULT }); stores.set(id, s); }
return s;
}
export function setWidgetDisabled(id: string, v: boolean): void {
getWidgetCmdStore(id).update(c => ({ ...c, disabled: v }));
}
export function setWidgetHidden(id: string, v: boolean): void {
getWidgetCmdStore(id).update(c => ({ ...c, hidden: v }));
}
export function setPlotPaused(id: string, v: boolean): void {
getWidgetCmdStore(id).update(c => ({ ...c, paused: v }));
}
export function clearPlot(id: string): void {
getWidgetCmdStore(id).update(c => ({ ...c, clearNonce: c.clearNonce + 1 }));
}
// Reset every widget back to its default state. Called when a panel unmounts so
// that re-opening the same panel (whose widget ids are stable) starts fresh
// rather than inheriting disabled/hidden/paused flags from the previous view.
export function resetWidgetCmds(): void {
for (const s of stores.values()) s.set({ ...DEFAULT });
}
+328
View File
@@ -399,6 +399,16 @@ body {
white-space: nowrap; white-space: nowrap;
} }
/* Overlay shown over a widget disabled via action.widget logic: dims it and
blocks interaction while still surfacing the right-click context menu. */
.widget-disable-overlay {
position: absolute;
z-index: 10;
background: rgba(20, 25, 35, 0.45);
cursor: not-allowed;
box-sizing: border-box;
}
/* ── Unknown widget ────────────────────────────────────────────────────────── */ /* ── Unknown widget ────────────────────────────────────────────────────────── */
.unknown-widget { .unknown-widget {
@@ -1014,6 +1024,13 @@ body {
.chart-area .u-legend { color: #94a3b8; font-size: 0.75rem; } .chart-area .u-legend { color: #94a3b8; font-size: 0.75rem; }
.chart-area .u-value { color: #e2e8f0; } .chart-area .u-value { color: #e2e8f0; }
/* Legend position for the uPlot (timeseries) chart. uPlot always renders its
legend below the canvas, so to honour "top" we make the chart root a flex
column and reorder the legend above the plot. ECharts plots position their
own legend internally (see echartsOption), so these rules don't apply there. */
.plot-widget.legend-top .chart-area .uplot { display: flex; flex-direction: column; }
.plot-widget.legend-top .chart-area .u-legend { order: -1; }
/* ── Edit Mode layout ──────────────────────────────────────────────────── */ /* ── Edit Mode layout ──────────────────────────────────────────────────── */
.edit-mode-layout { .edit-mode-layout {
@@ -1361,6 +1378,44 @@ body {
} }
.flow-localvar-actions .panel-btn { flex: 1; } .flow-localvar-actions .panel-btn { flex: 1; }
/* Repeated rows in inspector editors (export columns, dialog fields). */
.flow-row-edit {
display: flex;
align-items: center;
gap: 0.35rem;
margin-bottom: 0.35rem;
}
.flow-row-edit .prop-input { flex: 1; min-width: 0; }
.flow-row-add {
width: 100%;
margin-top: 0.15rem;
}
.flow-field-edit {
border: 1px solid #2a3548;
border-radius: 4px;
padding: 0.4rem;
margin-bottom: 0.4rem;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.flow-field-head {
display: flex;
align-items: center;
gap: 0.35rem;
}
.flow-field-head .prop-input { flex: 1; min-width: 0; }
.flow-field-kind {
font-size: 0.75rem;
color: #94a3b8;
flex: 0 0 auto;
}
.flow-field-add {
display: flex;
gap: 0.35rem;
}
/* ── History pane ───────────────────────────────────────────────────────── */ /* ── History pane ───────────────────────────────────────────────────────── */
.history-pane { .history-pane {
@@ -1923,6 +1978,22 @@ body {
box-shadow: 0 8px 32px rgba(0,0,0,0.5); box-shadow: 0 8px 32px rgba(0,0,0,0.5);
} }
/* Synthetic-signal node editor: a large modal hosting the shared .flow-editor
layout (palette | canvas | inspector). */
.synth-graph {
background: #1e2535;
border: 1px solid #2d3748;
border-radius: 8px;
width: 92vw;
height: 88vh;
max-width: 95vw;
max-height: 92vh;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
}
.wizard-header { .wizard-header {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -2235,6 +2306,18 @@ body {
.iface-item:hover .iface-item-actions { display: flex; } .iface-item:hover .iface-item-actions { display: flex; }
.iface-item[draggable="true"] .iface-item-name { cursor: grab; }
/* Drag-and-drop reorder / move-to-folder affordances */
.iface-item.drop-target {
box-shadow: inset 0 2px 0 0 #3b82f6;
}
.iface-folder-header.drop-target,
.iface-list.drop-target {
background: rgba(59, 130, 246, 0.15);
outline: 1px dashed #3b82f6;
}
.iface-delete { color: #ef4444 !important; } .iface-delete { color: #ef4444 !important; }
.iface-delete:hover { background: rgba(239, 68, 68, 0.15) !important; } .iface-delete:hover { background: rgba(239, 68, 68, 0.15) !important; }
@@ -2511,6 +2594,16 @@ body {
font-style: italic; font-style: italic;
} }
.help-callout {
border-left: 3px solid #2563eb;
background: #1a2236;
padding: 0.6rem 0.85rem;
margin: 0.85rem 0;
border-radius: 4px;
font-size: 0.85rem;
color: #cbd5e1;
}
/* Getting started cards */ /* Getting started cards */
.help-cards { .help-cards {
display: flex; display: flex;
@@ -3329,3 +3422,238 @@ kbd {
} }
.plot-pane-btn:hover { color: #e2e8f0; border-color: #4a9eff; } .plot-pane-btn:hover { color: #e2e8f0; border-color: #4a9eff; }
.plot-pane-btn:disabled { opacity: 0.35; cursor: default; } .plot-pane-btn:disabled { opacity: 0.35; cursor: default; }
/* ── Control logic editor (server-side flows) ──────────────────────────────── */
.cl-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.65);
display: flex;
align-items: center;
justify-content: center;
z-index: 600;
}
.cl-modal {
background: #1a1f2e;
border: 1px solid #2d3748;
border-radius: 10px;
width: min(1200px, 97vw);
height: min(820px, 95vh);
display: flex;
flex-direction: column;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.6);
overflow: hidden;
}
.cl-header {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1.25rem;
border-bottom: 1px solid #2d3748;
flex-shrink: 0;
}
.cl-title {
font-size: 1rem;
font-weight: 700;
color: #e2e8f0;
}
.cl-subtitle { font-size: 0.8rem; }
.cl-header-actions {
margin-left: auto;
display: flex;
align-items: center;
gap: 0.5rem;
}
.cl-dirty {
font-size: 0.75rem;
color: #fbbf24;
}
.cl-error {
padding: 0.5rem 1.25rem;
background: #3b1d1d;
color: #fca5a5;
font-size: 0.82rem;
border-bottom: 1px solid #5b2a2a;
flex-shrink: 0;
}
.cl-body {
display: flex;
flex: 1;
overflow: hidden;
}
.cl-list {
width: 230px;
min-width: 230px;
border-right: 1px solid #2d3748;
display: flex;
flex-direction: column;
overflow-y: auto;
flex-shrink: 0;
}
.cl-list-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid #2d3748;
font-weight: 600;
color: #94a3b8;
font-size: 0.85rem;
}
.cl-list-empty { padding: 0.75rem; font-size: 0.8rem; }
.cl-list-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.45rem 0.75rem;
cursor: pointer;
border-bottom: 1px solid #232a3a;
}
.cl-list-item:hover { background: #232a3a; }
.cl-list-item-active { background: #2a3450; }
.cl-status-dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: #475569;
flex-shrink: 0;
}
.cl-status-on { background: #34d399; box-shadow: 0 0 5px #34d399; }
.cl-list-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #e2e8f0;
font-size: 0.85rem;
}
.cl-mini-btn {
color: #64748b;
font-size: 0.8rem;
padding: 1px 4px;
border-radius: 3px;
flex-shrink: 0;
}
.cl-mini-btn:hover { color: #e2e8f0; background: #1a1f2e; }
.cl-editor-wrap {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.cl-empty {
padding: 1.5rem;
font-size: 0.85rem;
}
.cl-graph-bar {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.6rem 0.9rem;
border-bottom: 1px solid #2d3748;
flex-shrink: 0;
}
.cl-name-input { width: 240px; }
.cl-enable {
display: flex;
align-items: center;
gap: 0.35rem;
color: #cbd5e1;
font-size: 0.85rem;
white-space: nowrap;
}
/* ── Logic dialogs (action.dialog.* user interaction) ──────────────────────── */
.logic-dialog-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 700;
}
.logic-dialog {
background: #1a1f2e;
border: 1px solid #2d3748;
border-top: 3px solid #4a9eff;
border-radius: 8px;
min-width: 320px;
max-width: 460px;
padding: 1rem 1.25rem 0.9rem;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.6);
}
.logic-dialog-error { border-top-color: #ef4444; }
.logic-dialog-setpoint { border-top-color: #34d399; }
.logic-dialog-header {
font-size: 0.95rem;
font-weight: 700;
color: #e2e8f0;
margin-bottom: 0.5rem;
}
.logic-dialog-error .logic-dialog-header { color: #fca5a5; }
.logic-dialog-message {
color: #cbd5e1;
font-size: 0.85rem;
line-height: 1.4;
white-space: pre-wrap;
margin-bottom: 0.75rem;
}
.logic-dialog-input {
width: 100%;
margin-bottom: 0;
}
/* A single field row (input or display) in a multi-value dialog. */
.logic-dialog-field {
display: flex;
flex-direction: column;
gap: 0.2rem;
margin-bottom: 0.6rem;
}
.logic-dialog-label {
font-size: 0.8rem;
color: #94a3b8;
}
.logic-dialog-value {
font-size: 0.9rem;
font-weight: 600;
color: #e2e8f0;
padding: 0.2rem 0;
}
.logic-dialog-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.logic-dialog-actions .panel-btn { flex: 0 0 auto; min-width: 5rem; }
.panel-btn-primary {
background: #2563eb;
}
.panel-btn-primary:hover { background: #3b82f6; }
+19 -1
View File
@@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from 'preact/hooks';
import uPlot from 'uplot'; import uPlot from 'uplot';
import * as echarts from 'echarts'; import * as echarts from 'echarts';
import { getSignalStore } from '../lib/stores'; import { getSignalStore } from '../lib/stores';
import { getWidgetCmdStore } from '../lib/widgetCommands';
import { wsClient } from '../lib/ws'; import { wsClient } from '../lib/ws';
import { formatValue } from '../lib/format'; import { formatValue } from '../lib/format';
import { fftMag, resampleUniform } from '../lib/fft'; import { fftMag, resampleUniform } from '../lib/fft';
@@ -69,6 +70,7 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
// Stable primitive deps so the effect re-runs when plotType or signals change. // Stable primitive deps so the effect re-runs when plotType or signals change.
const timeRangeKey = timeRange ? `${timeRange.start}|${timeRange.end}` : 'live'; const timeRangeKey = timeRange ? `${timeRange.start}|${timeRange.end}` : 'live';
const plotType = widget.options['plotType'] ?? 'timeseries'; const plotType = widget.options['plotType'] ?? 'timeseries';
const legendPos = widget.options['legend'] ?? 'bottom';
const signalsKey = widget.signals.map(s => `${s.ds}:${s.name}${s.color ?? ''}`).join(','); const signalsKey = widget.signals.map(s => `${s.ds}:${s.name}${s.color ?? ''}`).join(',');
useEffect(() => { useEffect(() => {
@@ -374,8 +376,24 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
// ── Live streaming mode ──────────────────────────────────────────────── // ── Live streaming mode ────────────────────────────────────────────────
setHistStatus('idle'); setHistStatus('idle');
// 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 lastClear = cmdStore.get().clearNonce;
unsubs.push(cmdStore.subscribe(cmd => {
paused = cmd.paused;
if (cmd.clearNonce !== lastClear) {
lastClear = cmd.clearNonce;
buffers.forEach(b => { b.timestamps.length = 0; b.values.length = 0; });
if (uplot) uplot.setData(uplotData(timeWindow));
if (echart) echart.setOption(echartsOption(), { notMerge: true });
}
}));
signals.forEach((sig: SignalRef & { color?: string }, i) => { signals.forEach((sig: SignalRef & { color?: string }, i) => {
const unsub = getSignalStore(sig).subscribe(sv => { const unsub = getSignalStore(sig).subscribe(sv => {
if (paused) return;
if (sv.value === null || sv.value === undefined || sv.ts === null) return; if (sv.value === null || sv.value === undefined || sv.ts === null) return;
const ts = new Date(sv.ts).getTime() / 1000; const ts = new Date(sv.ts).getTime() / 1000;
@@ -419,7 +437,7 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
return ( return (
<div <div
class="plot-widget" class={`plot-widget legend-${legendPos}`}
ref={outerRef} ref={outerRef}
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`} style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
onContextMenu={onContextMenu} onContextMenu={onContextMenu}