Compare commits
3 Commits
aba394b84d
...
914108e575
| Author | SHA1 | Date | |
|---|---|---|---|
| 914108e575 | |||
| ba836f00e6 | |||
| afefba3184 |
@@ -10,12 +10,17 @@ uopi runs as a **single portable binary** with no runtime dependencies. Point an
|
||||
|
||||
| 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 |
|
||||
| **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 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 |
|
||||
| **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 |
|
||||
| **Signal discovery** | Filter/search, CSV import, manual add, Channel Finder proxy |
|
||||
| **Observability** | Prometheus-format metrics at `/metrics` |
|
||||
@@ -64,7 +69,20 @@ Create `uopi.toml` (all fields are optional — shown values are defaults):
|
||||
```toml
|
||||
[server]
|
||||
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]
|
||||
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/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/reorder` | Reorder panels / move them between folders |
|
||||
| `GET/PUT` | `/api/v1/interfaces/{id}/acl` | Read or set a panel's sharing rules |
|
||||
| `GET` | `/api/v1/interfaces/{id}/versions` | List saved versions of a panel |
|
||||
| `GET/POST` | `/api/v1/folders` | List or create panel folders |
|
||||
| `PUT/DELETE` | `/api/v1/folders/{id}` | Rename/reparent or delete a folder |
|
||||
| `GET/POST/DELETE` | `/api/v1/synthetic` | Manage synthetic signal definitions |
|
||||
| `GET/POST` | `/api/v1/controllogic` | List or create server-side control-logic graphs |
|
||||
| `GET/PUT/DELETE` | `/api/v1/controllogic/{id}` | Read, update, or delete a control-logic graph |
|
||||
| `GET` | `/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` | `/healthz` | Health check |
|
||||
|
||||
|
||||
+14
-2
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/uopi/uopi/internal/access"
|
||||
"github.com/uopi/uopi/internal/broker"
|
||||
"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/pva"
|
||||
"github.com/uopi/uopi/internal/datasource/stub"
|
||||
@@ -132,9 +133,20 @@ func main() {
|
||||
for _, g := range cfg.Groups {
|
||||
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 {
|
||||
fmt.Fprintf(os.Stderr, "server error: %v\n", err)
|
||||
|
||||
+133
-46
@@ -8,13 +8,23 @@ uopi is a web-based HMI (Human-Machine Interface) for monitoring and controlling
|
||||
|
||||
## 2. Users and Roles
|
||||
|
||||
| Role | Description |
|
||||
|------|-------------|
|
||||
| Operator | Uses interfaces in View mode; can interact with controls but cannot edit layouts |
|
||||
| Engineer | Creates and edits interfaces in Edit mode; manages signal lists |
|
||||
| Administrator | Manages server configuration, data sources, and saved interfaces |
|
||||
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
|
||||
unproxied/LAN deployments. There is no login page inside the application.
|
||||
|
||||
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.
|
||||
- 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)**
|
||||
- Renders the selected interface as a live, interactive panel.
|
||||
- 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.
|
||||
- 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.
|
||||
- **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**
|
||||
- Show/hide interface list pane.
|
||||
- **⏱ 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).
|
||||
- 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)
|
||||
- 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.
|
||||
- Synthetic signals show an edit (✎) button to reopen the wizard.
|
||||
|
||||
**Widget canvas (center)**
|
||||
- Free-form canvas where widgets can be placed at arbitrary pixel positions.
|
||||
- Background grid with snap-to-grid.
|
||||
**Center area — Layout / Logic tabs**
|
||||
- **Layout**: free-form canvas where widgets can be placed at arbitrary pixel positions,
|
||||
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)**
|
||||
- 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).
|
||||
- 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:**
|
||||
- Color swatch and signal name.
|
||||
- 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.
|
||||
Per-plot configuration reuses the standard Plot widget (§4.4), so all plot sub-types and
|
||||
options are available within a pane.
|
||||
|
||||
### 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.
|
||||
|
||||
**New Synthetic Signal Wizard:**
|
||||
1. Click "New synthetic signal" in the signal tree.
|
||||
2. Name the signal and optionally set a unit.
|
||||
3. Add processing nodes from the node type dropdown and connect them.
|
||||
4. Configure each node's parameters inline.
|
||||
5. Click Create — the signal appears in the tree and updates live.
|
||||
**Two authoring surfaces:**
|
||||
- **Wizard** — for the common single-input case: name the signal, pick an input and a
|
||||
processing node, set parameters, Create.
|
||||
- **Node-graph editor** — a visual editor that wires one or more inputs through a chain of
|
||||
DSP blocks, for multi-input pipelines. It compiles to the same inputs + pipeline model.
|
||||
|
||||
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:**
|
||||
|
||||
@@ -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.
|
||||
- Export/Import allows local file exchange of XML files.
|
||||
- The XML schema records: widget type, position, size, signal bindings, and all property values.
|
||||
The **Logic** tab in the panel editor is a node-graph (Node-RED-style) flow editor that
|
||||
gives a panel interactive behaviour. The flow runs **client-side** while the panel is open
|
||||
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:
|
||||
- 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 |
|
||||
|-------------|--------|
|
||||
|
||||
+105
-24
@@ -186,17 +186,35 @@ Framing: JSON messages over a single persistent WebSocket connection per client.
|
||||
|
||||
Base path: `/api/v1`
|
||||
|
||||
| Method | Path | Description |
|
||||
| ------ | ------------------------- | -------------------------------------------- |
|
||||
| GET | `/datasources` | List connected data sources and their status |
|
||||
| GET | `/signals?ds=epics` | List signals for a data source |
|
||||
| GET | `/signals/:ds/:name/meta` | Get full metadata for a signal |
|
||||
| GET | `/interfaces` | List saved interfaces |
|
||||
| POST | `/interfaces` | Create a new interface (body: XML) |
|
||||
| GET | `/interfaces/:id` | Download interface XML |
|
||||
| PUT | `/interfaces/:id` | Update interface XML |
|
||||
| DELETE | `/interfaces/:id` | Delete interface |
|
||||
| POST | `/interfaces/:id/clone` | Clone an interface |
|
||||
| Method | Path | Description |
|
||||
| --------------- | ------------------------------------- | ------------------------------------------------- |
|
||||
| GET | `/me` | Caller identity, global level, groups, `canEditLogic` |
|
||||
| GET | `/datasources` | List connected data sources and their status |
|
||||
| GET | `/signals?ds=epics` | List signals for a data source |
|
||||
| GET | `/signals/search?q=` | Search signals across all sources |
|
||||
| GET | `/channel-finder?q=` | Proxy to EPICS Channel Finder |
|
||||
| GET | `/archiver/search?q=` | Search the EPICS archiver for PV names |
|
||||
| GET, POST | `/interfaces` | List saved interfaces / create one (body: XML) |
|
||||
| POST | `/interfaces/reorder` | Reorder panels / move between folders |
|
||||
| GET, PUT, DELETE| `/interfaces/{id}` | Download, update, or delete an interface |
|
||||
| POST | `/interfaces/{id}/clone` | Clone an interface |
|
||||
| GET | `/interfaces/{id}/versions` | List saved versions; `…/{version}` to fetch one |
|
||||
| PUT | `/interfaces/{id}/versions/{v}/tag` | Tag a version |
|
||||
| POST | `/interfaces/{id}/versions/{v}/promote` | Promote a version to current |
|
||||
| POST | `/interfaces/{id}/versions/{v}/fork` | Fork a version into a new panel |
|
||||
| GET, PUT | `/interfaces/{id}/acl` | Read or set a panel's sharing rules |
|
||||
| GET, POST | `/folders` | List or create panel folders |
|
||||
| PUT, DELETE | `/folders/{id}` | Rename/reparent or delete a folder |
|
||||
| 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
|
||||
|
||||
@@ -232,7 +250,15 @@ Base path: `/api/v1`
|
||||
|
||||
### 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
|
||||
<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>
|
||||
```
|
||||
|
||||
### 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
|
||||
@@ -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.
|
||||
- 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".
|
||||
- Supports configurable time window (10s to 1h).
|
||||
- Per-signal style editor: color picker, line width (0 = hidden), line dash (solid/dashed/dotted), marker size (none/S/M/L).
|
||||
- Statistics table per signal: last, min, max, mean over the current time window.
|
||||
- Uses a `requestAnimationFrame` loop limited to ≤1 redraw/second when data is not changing.
|
||||
- Chart fills its container; `ResizeObserver` keeps the uPlot canvas sized correctly.
|
||||
- In View mode, `Canvas` renders the saved layout read-only with live `PlotWidget`s.
|
||||
- In Edit mode, `PlotPanelCanvas.tsx` adds per-pane overlays: split (⬌/⬍), close (✕), click
|
||||
to select, and signal-drop to add a signal to that pane's plot.
|
||||
- Each pane reuses the standard `PlotWidget`, so all plot sub-types and options apply.
|
||||
- Layout edits (split/close/resize) participate in the editor's undo/redo.
|
||||
|
||||
### 4.6 Resizable Panels
|
||||
|
||||
@@ -407,17 +467,31 @@ Server is configured via a TOML file (default: `uopi.toml`, overridable via `--c
|
||||
listen = ":8080"
|
||||
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]
|
||||
enabled = true
|
||||
ca_addr_list = "" # EPICS_CA_ADDR_LIST override
|
||||
archive_url = "" # EPICS Archive Appliance URL
|
||||
channel_finder_url = "" # EPICS Channel Finder URL
|
||||
|
||||
[datasource.synthetic]
|
||||
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.
|
||||
- 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.
|
||||
- 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)
|
||||
|
||||
- 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).
|
||||
- Windows or macOS server binary.
|
||||
- Mobile-optimised frontend layout.
|
||||
|
||||
@@ -56,19 +56,29 @@ func ParseLevel(s string) Level {
|
||||
// Policy holds the resolved global access configuration. It is immutable after
|
||||
// construction and safe for concurrent use.
|
||||
type Policy struct {
|
||||
defaultUser string
|
||||
blacklist map[string]Level // user → downgraded level
|
||||
userGroups map[string][]string // user → groups they belong to
|
||||
groupNames []string // all configured group names (sorted)
|
||||
defaultUser string
|
||||
blacklist map[string]Level // user → downgraded level
|
||||
userGroups map[string][]string // user → groups they belong to
|
||||
groupNames []string // all configured group names (sorted)
|
||||
logicEditors map[string]bool // users + group names allowed to edit logic
|
||||
}
|
||||
|
||||
// New builds a Policy. blacklist maps a username to a config level string;
|
||||
// groups maps a group name to its member usernames.
|
||||
func New(defaultUser string, blacklist map[string]string, groups map[string][]string) *Policy {
|
||||
// groups maps a group name to its member usernames. logicEditors optionally
|
||||
// restricts who may edit panel/control logic (usernames or group names); empty
|
||||
// means no restriction.
|
||||
func New(defaultUser string, blacklist map[string]string, groups map[string][]string, logicEditors []string) *Policy {
|
||||
p := &Policy{
|
||||
defaultUser: strings.TrimSpace(defaultUser),
|
||||
blacklist: make(map[string]Level),
|
||||
userGroups: make(map[string][]string),
|
||||
defaultUser: strings.TrimSpace(defaultUser),
|
||||
blacklist: make(map[string]Level),
|
||||
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 {
|
||||
u := strings.TrimSpace(user)
|
||||
@@ -126,6 +136,35 @@ func (p *Policy) Level(user string) Level {
|
||||
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.
|
||||
func (p *Policy) GroupsOf(user string) []string {
|
||||
src := p.userGroups[strings.TrimSpace(user)]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+187
-7
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
"github.com/uopi/uopi/internal/access"
|
||||
"github.com/uopi/uopi/internal/broker"
|
||||
"github.com/uopi/uopi/internal/controllogic"
|
||||
"github.com/uopi/uopi/internal/datasource"
|
||||
"github.com/uopi/uopi/internal/datasource/synthetic"
|
||||
"github.com/uopi/uopi/internal/panelacl"
|
||||
@@ -28,19 +29,23 @@ type Handler struct {
|
||||
store *storage.Store
|
||||
policy *access.Policy
|
||||
acl *panelacl.Store
|
||||
ctrlLogic *controllogic.Store
|
||||
ctrlEngine *controllogic.Engine
|
||||
channelFinderURL string // empty if not configured
|
||||
archiverURL string // empty if not configured
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// New creates an API Handler. synth may be nil if the synthetic DS is disabled.
|
||||
func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, policy *access.Policy, acl *panelacl.Store, 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{
|
||||
broker: b,
|
||||
synthetic: synth,
|
||||
store: store,
|
||||
policy: policy,
|
||||
acl: acl,
|
||||
ctrlLogic: ctrlLogic,
|
||||
ctrlEngine: ctrlEngine,
|
||||
channelFinderURL: channelFinderURL,
|
||||
archiverURL: archiverURL,
|
||||
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("PUT "+prefix+"/synthetic/{name}", h.updateSynthetic)
|
||||
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 ─────────────────────────────────────────────────────────────────────
|
||||
@@ -102,9 +114,10 @@ func (h *Handler) getMe(w http.ResponseWriter, r *http.Request) {
|
||||
groups = []string{}
|
||||
}
|
||||
jsonOK(w, map[string]any{
|
||||
"user": user,
|
||||
"level": h.policy.Level(user).String(),
|
||||
"groups": groups,
|
||||
"user": user,
|
||||
"level": h.policy.Level(user).String(),
|
||||
"groups": groups,
|
||||
"canEditLogic": h.policy.CanEditLogic(user),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -420,9 +433,10 @@ func (h *Handler) putGroups(w http.ResponseWriter, r *http.Request) {
|
||||
// render sharing affordances and filter the visible set.
|
||||
type interfaceListItem struct {
|
||||
storage.InterfaceMeta
|
||||
Owner string `json:"owner,omitempty"`
|
||||
Folder string `json:"folder,omitempty"`
|
||||
Perm string `json:"perm"`
|
||||
Owner string `json:"owner,omitempty"`
|
||||
Folder string `json:"folder,omitempty"`
|
||||
Order float64 `json:"order,omitempty"`
|
||||
Perm string `json:"perm"`
|
||||
}
|
||||
|
||||
func (h *Handler) listInterfaces(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -442,6 +456,7 @@ func (h *Handler) listInterfaces(w http.ResponseWriter, r *http.Request) {
|
||||
if acl := h.acl.GetPanel(m.ID); acl != nil {
|
||||
item.Owner = acl.Owner
|
||||
item.Folder = acl.Folder
|
||||
item.Order = acl.Order
|
||||
}
|
||||
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())
|
||||
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"))
|
||||
if err != nil {
|
||||
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})
|
||||
}
|
||||
|
||||
// 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) {
|
||||
id := r.PathValue("id")
|
||||
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())
|
||||
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 errors.Is(err, storage.ErrNotFound) {
|
||||
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)
|
||||
}
|
||||
|
||||
// ── 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 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// 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 {
|
||||
return signalInfo{
|
||||
Name: m.Name,
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/uopi/uopi/internal/access"
|
||||
"github.com/uopi/uopi/internal/api"
|
||||
"github.com/uopi/uopi/internal/broker"
|
||||
"github.com/uopi/uopi/internal/controllogic"
|
||||
"github.com/uopi/uopi/internal/datasource/stub"
|
||||
"github.com/uopi/uopi/internal/panelacl"
|
||||
"github.com/uopi/uopi/internal/storage"
|
||||
@@ -45,8 +46,14 @@ func setup(t *testing.T) (*httptest.Server, func()) {
|
||||
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()
|
||||
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)
|
||||
return srv, func() {
|
||||
|
||||
@@ -50,6 +50,13 @@ type ServerConfig struct {
|
||||
// Blacklist downgrades specific users' global access level. Everyone not
|
||||
// listed is trusted with full write access.
|
||||
Blacklist []BlacklistEntry `toml:"blacklist"`
|
||||
|
||||
// LogicEditors optionally restricts who may add or edit panel logic (the
|
||||
// <logic> block of interfaces) and server-side control logic. Entries are
|
||||
// usernames or group names. When empty, no restriction applies (any user
|
||||
// with write access may edit logic). Anonymous/trusted-LAN callers are
|
||||
// always permitted.
|
||||
LogicEditors []string `toml:"logic_editors"`
|
||||
}
|
||||
|
||||
type DatasourceConfig struct {
|
||||
@@ -131,6 +138,9 @@ func applyEnv(cfg *Config) {
|
||||
if v := env("UOPI_SERVER_DEFAULT_USER"); v != "" {
|
||||
cfg.Server.DefaultUser = v
|
||||
}
|
||||
if v := env("UOPI_SERVER_LOGIC_EDITORS"); v != "" {
|
||||
cfg.Server.LogicEditors = strings.Fields(v)
|
||||
}
|
||||
if v := env("UOPI_EPICS_CA_ADDR_LIST"); v != "" {
|
||||
cfg.Datasource.EPICS.CAAddrList = v
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/uopi/uopi/internal/access"
|
||||
"github.com/uopi/uopi/internal/api"
|
||||
"github.com/uopi/uopi/internal/broker"
|
||||
"github.com/uopi/uopi/internal/controllogic"
|
||||
"github.com/uopi/uopi/internal/datasource/synthetic"
|
||||
"github.com/uopi/uopi/internal/metrics"
|
||||
"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.
|
||||
// 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()
|
||||
|
||||
// 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
|
||||
// access-control middleware (identity resolution + global level enforcement).
|
||||
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))
|
||||
|
||||
// Embedded frontend — must be last (catch-all)
|
||||
|
||||
+8
-3
@@ -18,11 +18,17 @@ default_user = ""
|
||||
# user = "guest"
|
||||
# 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]]
|
||||
# name = "operators"
|
||||
# members = ["alice", "bob"]
|
||||
|
||||
# Optional: restrict who may add or edit panel logic (the <logic> block of
|
||||
# interfaces) AND server-side control logic. Entries are usernames or group
|
||||
# names. Empty/unset = no restriction (any writer may edit logic). Also settable
|
||||
# via UOPI_SERVER_LOGIC_EDITORS (space-separated).
|
||||
# logic_editors = ["operators", "alice"]
|
||||
|
||||
[datasource.epics]
|
||||
enabled = true
|
||||
ca_addr_list = "" # overrides EPICS_CA_ADDR_LIST if set
|
||||
@@ -31,5 +37,4 @@ channel_finder_url = "" # e.g. http://channelfinder:8080/ChannelFinder
|
||||
auto_sync_filter = "" # e.g. area=StorageRing or tags=production
|
||||
|
||||
[datasource.synthetic]
|
||||
enabled = true
|
||||
definitions_file = "./synthetic.json"
|
||||
enabled = true
|
||||
|
||||
+43
-4
@@ -1,7 +1,8 @@
|
||||
import { h } from 'preact';
|
||||
import { h, Fragment } from 'preact';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import { initLocalState } from './lib/localstate';
|
||||
import { logicEngine } from './lib/logic';
|
||||
import { getWidgetCmdStore, type WidgetCmd } from './lib/widgetCommands';
|
||||
import type { Interface, Widget, SignalRef } from './lib/types';
|
||||
import TextView from './widgets/TextView';
|
||||
import TextLabel from './widgets/TextLabel';
|
||||
@@ -18,6 +19,7 @@ import LinkWidget from './widgets/LinkWidget';
|
||||
import ContextMenu from './ContextMenu';
|
||||
import InfoPanel from './InfoPanel';
|
||||
import SplitLayout from './SplitLayout';
|
||||
import LogicDialogs from './LogicDialogs';
|
||||
|
||||
const COMPONENTS: Record<string, any> = {
|
||||
textview: TextView,
|
||||
@@ -41,6 +43,32 @@ interface CtxState {
|
||||
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 {
|
||||
iface: Interface | null;
|
||||
onNavigate?: (interfaceId: string) => void;
|
||||
@@ -119,6 +147,8 @@ export default function Canvas({ iface, onNavigate, timeRange }: Props) {
|
||||
{infoSignal && (
|
||||
<InfoPanel signal={infoSignal} onClose={() => setInfoSignal(null)} />
|
||||
)}
|
||||
|
||||
<LogicDialogs />
|
||||
</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;`}>
|
||||
{iface.widgets.map(widget => {
|
||||
const Comp = COMPONENTS[widget.type];
|
||||
return Comp
|
||||
const inner = Comp
|
||||
? <Comp
|
||||
key={widget.id}
|
||||
widget={widget}
|
||||
onContextMenu={(e: MouseEvent) => onCtxMenu(e, widget)}
|
||||
onNavigate={onNavigate}
|
||||
@@ -138,7 +167,6 @@ export default function Canvas({ iface, onNavigate, timeRange }: Props) {
|
||||
/>
|
||||
: (
|
||||
<div
|
||||
key={widget.id}
|
||||
class="unknown-widget"
|
||||
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<WidgetView
|
||||
key={widget.id}
|
||||
widget={widget}
|
||||
onContextMenu={(e: MouseEvent) => onCtxMenu(e, widget)}
|
||||
>
|
||||
{inner}
|
||||
</WidgetView>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -159,6 +196,8 @@ export default function Canvas({ iface, onNavigate, timeRange }: Props) {
|
||||
{infoSignal && (
|
||||
<InfoPanel signal={infoSignal} onClose={() => setInfoSignal(null)} />
|
||||
)}
|
||||
|
||||
<LogicDialogs />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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: 'The ● Live button shows live streaming is active. Use the date pickers to load historical data.', section: 'history' },
|
||||
{ text: 'Click Edit (top-right) to open the drag-and-drop panel builder.', section: 'edit' },
|
||||
{ text: 'Use the Share button on a panel to grant access to users or groups.', section: 'sharing' },
|
||||
{ text: 'The ⚙ Control logic button opens server-side automation that runs even with no panel open.', section: 'control' },
|
||||
{ text: 'The dot in the status chip turns green when the WebSocket is connected.', section: 'view' },
|
||||
],
|
||||
edit: [
|
||||
@@ -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+Z to undo, Ctrl+Y to redo. Up to 50 steps are remembered.', section: 'shortcuts' },
|
||||
{ text: 'Use "+ Synthetic" in the signal tree to create computed/filtered signals.', section: 'signals' },
|
||||
{ text: 'Switch to the Logic tab to add interactive behaviour with a node-graph flow editor.', section: 'logic' },
|
||||
{ text: 'Add Local variables for set-points and counters that live inside the panel.', section: 'edit' },
|
||||
{ text: 'Arrow keys nudge the selected widget by 1 px (Shift for grid size).', section: 'shortcuts' },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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 '';
|
||||
}
|
||||
}
|
||||
+10
-6
@@ -88,7 +88,8 @@ const STATIC_WIDGET_TYPES = [
|
||||
|
||||
export default function EditMode({ initial, onDone }: Props) {
|
||||
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 [dirty, setDirty] = 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' : ''}`}
|
||||
onClick={() => setCenterTab('layout')}
|
||||
>Layout</button>
|
||||
<button
|
||||
class={`center-tab${centerTab === 'logic' ? ' center-tab-active' : ''}`}
|
||||
onClick={() => setCenterTab('logic')}
|
||||
>Logic{iface.logic && iface.logic.nodes.length > 0 ? ` (${iface.logic.nodes.length})` : ''}</button>
|
||||
{me.canEditLogic && (
|
||||
<button
|
||||
class={`center-tab${centerTab === 'logic' ? ' center-tab-active' : ''}`}
|
||||
onClick={() => setCenterTab('logic')}
|
||||
>Logic{iface.logic && iface.logic.nodes.length > 0 ? ` (${iface.logic.nodes.length})` : ''}</button>
|
||||
)}
|
||||
</div>
|
||||
{centerTab === 'logic' ? (
|
||||
{centerTab === 'logic' && me.canEditLogic ? (
|
||||
<LogicEditor
|
||||
graph={iface.logic ?? { nodes: [], wires: [] }}
|
||||
onChange={handleLogicChange}
|
||||
widgets={iface.widgets}
|
||||
statevars={iface.statevars}
|
||||
onStateVarsChange={handleStateVarsChange}
|
||||
/>
|
||||
|
||||
+180
-6
@@ -406,7 +406,11 @@ const SECTIONS = [
|
||||
{ id: 'view', label: 'View Mode' },
|
||||
{ id: 'edit', label: 'Edit Mode' },
|
||||
{ id: 'widgets', label: 'Widgets' },
|
||||
{ id: 'plots', label: 'Plot Panels' },
|
||||
{ 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: 'shortcuts', label: 'Keyboard Shortcuts' },
|
||||
];
|
||||
@@ -510,6 +514,24 @@ function SectionEdit() {
|
||||
<h3 class="help-h3">Align & 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>
|
||||
|
||||
<h3 class="help-h3">Layout & 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>
|
||||
<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>
|
||||
@@ -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 & 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 & 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() {
|
||||
return (
|
||||
<div>
|
||||
@@ -602,7 +759,19 @@ function SectionSignals() {
|
||||
</p>
|
||||
|
||||
<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>
|
||||
<DiagramSignalTree />
|
||||
</div>
|
||||
@@ -676,14 +845,15 @@ function SectionShortcuts() {
|
||||
|
||||
<h3 class="help-h3">API & Metrics</h3>
|
||||
<ul class="help-list">
|
||||
<li>REST API: <code>/api/v1/datasources</code>, <code>/api/v1/signals</code>, <code>/api/v1/interfaces</code>, …</li>
|
||||
<li>Prometheus metrics: <code>/metrics</code></li>
|
||||
<li>Health check: <code>/healthz</code></li>
|
||||
<li>WebSocket: <code>ws://<host>/ws</code></li>
|
||||
<li>Signals/data: <code>/api/v1/datasources</code>, <code>/api/v1/signals</code>, <code>/api/v1/synthetic</code></li>
|
||||
<li>Panels: <code>/api/v1/interfaces</code>, <code>/api/v1/folders</code>, <code>/api/v1/interfaces/{'{id}'}/acl</code></li>
|
||||
<li>Identity & groups: <code>/api/v1/me</code>, <code>/api/v1/usergroups</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://<host>/ws</code></li>
|
||||
</ul>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -693,7 +863,11 @@ const SECTION_COMPONENTS: Record<string, () => JSX.Element> = {
|
||||
view: SectionView,
|
||||
edit: SectionEdit,
|
||||
widgets: SectionWidgets,
|
||||
plots: SectionPlots,
|
||||
signals: SectionSignals,
|
||||
logic: SectionLogic,
|
||||
control: SectionControl,
|
||||
sharing: SectionSharing,
|
||||
history: SectionHistory,
|
||||
shortcuts: SectionShortcuts,
|
||||
};
|
||||
|
||||
@@ -21,10 +21,60 @@ export default function InterfaceList({ onLoad, onSelect, onEdit, onNewPlot, onE
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [share, setShare] = useState<{ id: string; name: string } | null>(null);
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
||||
const [dropTarget, setDropTarget] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const dragId = useRef<string | null>(null);
|
||||
const me = useAuth();
|
||||
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() {
|
||||
setLoading(true);
|
||||
Promise.all([
|
||||
@@ -39,6 +89,7 @@ export default function InterfaceList({ onLoad, onSelect, onEdit, onNewPlot, onE
|
||||
version: Number(item.version || item.Version || 0),
|
||||
owner: item.owner ? String(item.owner) : '',
|
||||
folder: item.folder ? String(item.folder) : '',
|
||||
order: Number(item.order || 0),
|
||||
perm: (item.perm as InterfaceListItem['perm']) || 'write',
|
||||
})).filter((item: InterfaceListItem) => item.id);
|
||||
setInterfaces(normalized);
|
||||
@@ -116,8 +167,18 @@ export default function InterfaceList({ onLoad, onSelect, onEdit, onNewPlot, onE
|
||||
}
|
||||
|
||||
function renderPanel(item: InterfaceListItem) {
|
||||
const drag = writable && item.perm === 'write';
|
||||
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}>
|
||||
{item.name || item.id}
|
||||
{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).
|
||||
function renderFolder(folder: Folder) {
|
||||
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 canDrop = folder.perm === 'write';
|
||||
return (
|
||||
<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)}>
|
||||
{open ? '▾' : '▸'} {folder.name}
|
||||
</span>
|
||||
@@ -162,7 +229,7 @@ export default function InterfaceList({ onLoad, onSelect, onEdit, onNewPlot, onE
|
||||
}
|
||||
|
||||
const rootFolders = folders.filter(f => !f.parent);
|
||||
const rootPanels = interfaces.filter(i => !i.folder);
|
||||
const rootPanels = panelsIn('');
|
||||
|
||||
return (
|
||||
<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 ? (
|
||||
<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)}
|
||||
{rootPanels.map(renderPanel)}
|
||||
</ul>
|
||||
|
||||
@@ -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
@@ -2,7 +2,7 @@ import { h, Fragment } from 'preact';
|
||||
import { useState, useEffect, useRef } from 'preact/hooks';
|
||||
import SearchableSelect from './SearchableSelect';
|
||||
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 SignalInfo { name: string; }
|
||||
@@ -17,6 +17,8 @@ function splitRef(ref: string): { ds: string; name: string } {
|
||||
interface Props {
|
||||
graph: LogicGraph;
|
||||
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.
|
||||
statevars?: StateVar[];
|
||||
// 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.timer', label: 'Timer', 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: '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.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.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> = {
|
||||
@@ -65,6 +73,8 @@ const KIND_LABEL: Record<LogicNodeKind, string> = {
|
||||
'trigger.change': 'On change',
|
||||
'trigger.timer': 'Timer',
|
||||
'trigger.loop': 'Panel loop',
|
||||
'trigger.start': 'On open',
|
||||
'trigger.stop': 'On close',
|
||||
'gate.and': 'AND gate',
|
||||
'flow.if': 'If / else',
|
||||
'flow.loop': 'Loop',
|
||||
@@ -74,6 +84,10 @@ const KIND_LABEL: Record<LogicNodeKind, string> = {
|
||||
'action.export': 'Export CSV',
|
||||
'action.clear': 'Clear array',
|
||||
'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;
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
export default function LogicEditor({ graph, onChange, statevars, onStateVarsChange }: Props) {
|
||||
export default function LogicEditor({ graph, onChange, widgets = [], statevars, onStateVarsChange }: Props) {
|
||||
const nodes = graph.nodes;
|
||||
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') {
|
||||
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]);
|
||||
|
||||
// ── History ────────────────────────────────────────────────────────────────
|
||||
@@ -632,21 +653,13 @@ export default function LogicEditor({ graph, onChange, statevars, onStateVarsCha
|
||||
)}
|
||||
|
||||
{selected.kind === 'action.export' && (
|
||||
<Fragment>
|
||||
<div class="wizard-field">
|
||||
<label>Array name</label>
|
||||
<input class="prop-input" value={selected.params.array ?? ''} list="flow-array-names"
|
||||
placeholder="e.g. data"
|
||||
onInput={(e) => patchParams(selected.id, { array: (e.target as HTMLInputElement).value })} />
|
||||
</div>
|
||||
<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>
|
||||
<ExportEditor
|
||||
columns={parseExportColumns(selected)}
|
||||
align={selected.params.align ?? 'common'}
|
||||
filename={selected.params.filename ?? ''}
|
||||
onColumns={(cols) => patchParams(selected.id, { columns: JSON.stringify(cols) })}
|
||||
onAlign={(a) => patchParams(selected.id, { align: a })}
|
||||
onFilename={(f) => patchParams(selected.id, { filename: f })} />
|
||||
)}
|
||||
|
||||
{selected.kind === 'action.clear' && (
|
||||
@@ -675,6 +688,92 @@ export default function LogicEditor({ graph, onChange, statevars, onStateVarsCha
|
||||
</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>
|
||||
</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.
|
||||
function nodeSummary(n: LogicNode): string {
|
||||
switch (n.kind) {
|
||||
@@ -736,6 +1013,8 @@ function nodeSummary(n: LogicNode): string {
|
||||
case 'trigger.change': return `Δ ${n.params.signal || '?'}`;
|
||||
case 'trigger.timer': return `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 'flow.if': return n.params.cond || '(no condition)';
|
||||
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.delay': return `wait ${n.params.ms || '0'} ms`;
|
||||
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.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 '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { h, Fragment } from 'preact';
|
||||
import { useState, useEffect, useRef, useMemo } from 'preact/hooks';
|
||||
import type { SignalRef, StateVar } from './lib/types';
|
||||
import SyntheticWizard from './SyntheticWizard';
|
||||
import SyntheticEditor from './SyntheticEditor';
|
||||
import SyntheticGraphEditor from './SyntheticGraphEditor';
|
||||
import GroupsTree from './GroupsTree';
|
||||
import ChannelFinderModal from './ChannelFinderModal';
|
||||
|
||||
@@ -499,7 +499,7 @@ export default function SignalTree({ onDragStart, width, panelId, statevars, onS
|
||||
)}
|
||||
|
||||
{editSynthetic && (
|
||||
<SyntheticEditor
|
||||
<SyntheticGraphEditor
|
||||
name={editSynthetic}
|
||||
onClose={() => setEditSynthetic(null)}
|
||||
onSaved={loadAllSignals}
|
||||
|
||||
@@ -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 (1–8)', key: 'order', type: 'number', default: '1' },
|
||||
]},
|
||||
{ type: 'derivative', label: 'Derivative', params: [] },
|
||||
{ type: 'integrate', label: 'Integrate', params: [] },
|
||||
{ type: 'clamp', label: 'Clamp', params: [{ label: 'Min', key: 'min', type: 'number', default: '0' }, { label: 'Max', key: 'max', type: 'number', default: '100' }] },
|
||||
{ type: 'expr', label: 'Formula', params: [{ label: 'Expression (a,b,c,d = inputs)', key: 'expr', type: 'text', default: 'a * 1.0' }] },
|
||||
{ type: 'lua', label: 'Lua Script', params: [{ label: 'Script (a,b,c,d = inputs)', key: 'script', type: 'lua', default: 'return a' }] },
|
||||
];
|
||||
|
||||
interface 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>
|
||||
);
|
||||
}
|
||||
@@ -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 (a−b)', 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 (1–8)', key: 'order', type: 'number', default: '1' },
|
||||
]},
|
||||
{ type: 'derivative', label: 'Derivative', params: [] },
|
||||
{ type: 'integrate', label: 'Integrate', params: [] },
|
||||
{ type: 'clamp', label: 'Clamp', params: [{ label: 'Min', key: 'min', type: 'number', default: '0' }, { label: 'Max', key: 'max', type: 'number', default: '100' }] },
|
||||
{ type: '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>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import type { Interface } from './lib/types';
|
||||
import ContextualHelp from './ContextualHelp';
|
||||
import HelpModal from './HelpModal';
|
||||
import ZoomControl from './ZoomControl';
|
||||
import ControlLogicEditor from './ControlLogicEditor';
|
||||
|
||||
interface Props {
|
||||
onEdit?: (iface?: Interface) => void;
|
||||
@@ -31,6 +32,7 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) {
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
const [helpSection, setHelpSection] = useState('start');
|
||||
const [showTimeNav, setShowTimeNav] = useState(false);
|
||||
const [showControlLogic, setShowControlLogic] = useState(false);
|
||||
const [leftW, setLeftW] = useState(220);
|
||||
const [listCollapsed, setListCollapsed] = useState(false);
|
||||
const me = useAuth();
|
||||
@@ -155,6 +157,15 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) {
|
||||
📖
|
||||
</button>
|
||||
<ZoomControl />
|
||||
{writable && me.canEditLogic && (
|
||||
<button
|
||||
class="toolbar-btn"
|
||||
onClick={() => setShowControlLogic(true)}
|
||||
title="Open server-side control logic"
|
||||
>
|
||||
⚙ Control logic
|
||||
</button>
|
||||
)}
|
||||
{writable && (
|
||||
<button
|
||||
class="btn-edit"
|
||||
@@ -242,6 +253,10 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) {
|
||||
{showHelp && (
|
||||
<HelpModal initialSection={helpSection} onClose={() => setShowHelp(false)} />
|
||||
)}
|
||||
|
||||
{showControlLogic && (
|
||||
<ControlLogicEditor onClose={() => setShowControlLogic(false)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+2
-1
@@ -4,7 +4,7 @@ import type { Me, AccessLevel } from './types';
|
||||
|
||||
// Default identity used before /api/v1/me resolves (or if it fails): assume a
|
||||
// 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
|
||||
// current user throughout the app.
|
||||
@@ -36,6 +36,7 @@ export async function fetchMe(): Promise<Me> {
|
||||
user: typeof data.user === 'string' ? data.user : '',
|
||||
level: (data.level as AccessLevel) ?? 'write',
|
||||
groups: Array.isArray(data.groups) ? data.groups : [],
|
||||
canEditLogic: data.canEditLogic !== false,
|
||||
};
|
||||
} catch {
|
||||
return DEFAULT_ME;
|
||||
|
||||
+270
-8
@@ -26,9 +26,38 @@
|
||||
|
||||
import { wsClient } from './ws';
|
||||
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 { 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).
|
||||
const MAX_STEPS = 100000;
|
||||
const MAX_LOOP = 100000;
|
||||
@@ -57,6 +86,36 @@ function refKey(ds: string, name: string): string {
|
||||
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 }
|
||||
// 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
|
||||
@@ -132,10 +191,26 @@ class LogicEngine {
|
||||
// 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. */
|
||||
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 {} }
|
||||
this.cleanups = [];
|
||||
this.graph = { nodes: [], wires: [] };
|
||||
@@ -149,6 +224,9 @@ class LogicEngine {
|
||||
this.watchers = new Map();
|
||||
this.arrays = 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. */
|
||||
@@ -188,6 +266,17 @@ class LogicEngine {
|
||||
case 'action.write':
|
||||
case 'action.accumulate':
|
||||
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': {
|
||||
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);
|
||||
return;
|
||||
}
|
||||
@@ -338,19 +427,119 @@ class LogicEngine {
|
||||
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:
|
||||
// A trigger reached mid-walk (unusual): just pass through.
|
||||
await this.follow(node.id, 'out', ctx);
|
||||
}
|
||||
}
|
||||
|
||||
// Dump a named array to a CSV file the browser downloads (timestamp + value).
|
||||
private exportArray(name: string, filename: string): void {
|
||||
if (!name) return;
|
||||
const arr = this.arrays.get(name) ?? [];
|
||||
const rows = arr.map(p => `${p.t},${new Date(p.t).toISOString()},${p.v}`);
|
||||
const csv = 'timestamp_ms,iso,value\n' + rows.join('\n') + '\n';
|
||||
const safe = (filename || name).replace(/[^a-z0-9_.-]/gi, '_');
|
||||
// The columns an action.export node should emit. Reads the JSON `columns`
|
||||
// param (a list of { array, label }); falls back to the legacy single `array`.
|
||||
private exportColumns(node: LogicNode): Array<{ array: string; label: string }> {
|
||||
const raw = (node.params.columns ?? '').trim();
|
||||
if (raw) {
|
||||
try {
|
||||
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 blob = new Blob([csv], { type: 'text/csv' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -361,6 +550,79 @@ class LogicEngine {
|
||||
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
|
||||
// activating trigger counts as satisfied, and level triggers (threshold) use
|
||||
// their current truth. Momentary triggers that are not firing now are false.
|
||||
|
||||
+33
-3
@@ -84,6 +84,8 @@ export interface StateVar {
|
||||
// (params: interval).
|
||||
// trigger.change — fired whenever a signal's value changes
|
||||
// (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:
|
||||
// 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.accumulate — evaluate `expr` and append the value (with a timestamp)
|
||||
// to the named in-memory data array (params: array, expr).
|
||||
// action.export — download the named array as a CSV file (params: array,
|
||||
// filename).
|
||||
// action.export — download one or more named arrays as a CSV file, one
|
||||
// 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.log — evaluate `expr` and log it to the browser console for
|
||||
// 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-
|
||||
// local state variables as bare identifiers. See lib/expr.ts.
|
||||
@@ -115,6 +135,8 @@ export type LogicNodeKind =
|
||||
| 'trigger.timer'
|
||||
| 'trigger.loop'
|
||||
| 'trigger.change'
|
||||
| 'trigger.start'
|
||||
| 'trigger.stop'
|
||||
| 'gate.and'
|
||||
| 'flow.if'
|
||||
| 'flow.loop'
|
||||
@@ -123,7 +145,11 @@ export type LogicNodeKind =
|
||||
| 'action.accumulate'
|
||||
| 'action.export'
|
||||
| '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.
|
||||
export interface LogicNode {
|
||||
@@ -180,6 +206,9 @@ export interface Me {
|
||||
user: string;
|
||||
level: AccessLevel;
|
||||
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.
|
||||
@@ -224,6 +253,7 @@ export interface InterfaceListItem {
|
||||
version: number;
|
||||
owner?: string;
|
||||
folder?: string;
|
||||
order?: number;
|
||||
perm: Perm;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -399,6 +399,16 @@ body {
|
||||
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 {
|
||||
@@ -1014,6 +1024,13 @@ body {
|
||||
.chart-area .u-legend { color: #94a3b8; font-size: 0.75rem; }
|
||||
.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 {
|
||||
@@ -1361,6 +1378,44 @@ body {
|
||||
}
|
||||
.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 {
|
||||
@@ -1923,6 +1978,22 @@ body {
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -2235,6 +2306,18 @@ body {
|
||||
|
||||
.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:hover { background: rgba(239, 68, 68, 0.15) !important; }
|
||||
|
||||
@@ -2511,6 +2594,16 @@ body {
|
||||
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 */
|
||||
.help-cards {
|
||||
display: flex;
|
||||
@@ -3329,3 +3422,238 @@ kbd {
|
||||
}
|
||||
.plot-pane-btn:hover { color: #e2e8f0; border-color: #4a9eff; }
|
||||
.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; }
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import uPlot from 'uplot';
|
||||
import * as echarts from 'echarts';
|
||||
import { getSignalStore } from '../lib/stores';
|
||||
import { getWidgetCmdStore } from '../lib/widgetCommands';
|
||||
import { wsClient } from '../lib/ws';
|
||||
import { formatValue } from '../lib/format';
|
||||
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.
|
||||
const timeRangeKey = timeRange ? `${timeRange.start}|${timeRange.end}` : 'live';
|
||||
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(',');
|
||||
|
||||
useEffect(() => {
|
||||
@@ -374,8 +376,24 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
|
||||
// ── Live streaming mode ────────────────────────────────────────────────
|
||||
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) => {
|
||||
const unsub = getSignalStore(sig).subscribe(sv => {
|
||||
if (paused) return;
|
||||
if (sv.value === null || sv.value === undefined || sv.ts === null) return;
|
||||
const ts = new Date(sv.ts).getTime() / 1000;
|
||||
|
||||
@@ -419,7 +437,7 @@ export default function PlotWidget({ widget, onContextMenu, timeRange }: Props)
|
||||
|
||||
return (
|
||||
<div
|
||||
class="plot-widget"
|
||||
class={`plot-widget legend-${legendPos}`}
|
||||
ref={outerRef}
|
||||
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
|
||||
onContextMenu={onContextMenu}
|
||||
|
||||
Reference in New Issue
Block a user