Add control logic engine, panel logic dialogs, logic-edit restriction
Introduce server-side control-logic flow graphs (cron/alarm triggers, Lua blocks) with CRUD endpoints, panel-logic lifecycle triggers and user-interaction dialog nodes, and a synthetic node-graph editor. Add an optional logic-editor allowlist (server.logic_editors) gating who may add/edit panel logic and control logic, surfaced via /api/v1/me and enforced in the API; hide logic affordances in the UI accordingly. Update README, example config, and functional/technical specs to cover all current features (plot panels, panel/control logic, local variables, access control) and refresh the in-app manual and contextual help. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -10,12 +10,17 @@ uopi runs as a **single portable binary** with no runtime dependencies. Point an
|
|||||||
|
|
||||||
| Feature | Details |
|
| Feature | Details |
|
||||||
|---------|---------|
|
|---------|---------|
|
||||||
| **Live data** | EPICS Channel Access (CA) and user-defined synthetic signals |
|
| **Live data** | EPICS Channel Access (CA) / PVAccess and user-defined synthetic signals |
|
||||||
| **HMI editor** | Drag-and-drop widgets, resize, align/distribute, undo/redo, saved as XML |
|
| **HMI editor** | Drag-and-drop widgets, resize, align/distribute, undo/redo, saved as XML |
|
||||||
| **Widget library** | Text view, gauge, LED, bar, set-point control, button, plots |
|
| **Widget library** | Text view/label, gauge, LED, multi-LED, bar, set-point control, button, image, link, plots |
|
||||||
| **Plot types** | Time-series (uPlot), FFT, waterfall, histogram, bar chart, logic analyser |
|
| **Plot types** | Time-series (uPlot), FFT, waterfall, histogram, bar chart, logic analyser |
|
||||||
|
| **Plot panels** | Dedicated chart panels with a recursive split layout (tmux/IDE-style) where plots fill the viewport |
|
||||||
|
| **Panel logic** | In-editor node-graph flow editor (triggers → actions, dialogs) that runs client-side in view mode |
|
||||||
|
| **Control logic** | Server-side always-on flow graphs with cron/alarm triggers and Lua blocks |
|
||||||
|
| **Local variables** | Panel-scoped state variables for set-points, toggles and counters used by logic |
|
||||||
| **Historical data** | EPICS Archive Appliance integration via REST; time range picker in UI |
|
| **Historical data** | EPICS Archive Appliance integration via REST; time range picker in UI |
|
||||||
| **Synthetic signals** | Compose, filter, and transform signals with a DSP pipeline (gain, offset, moving average, lowpass, highpass, derivative, integral, clamp, formula) |
|
| **Synthetic signals** | Compose, filter, and transform signals via a wizard or visual node-graph editor (gain, offset, moving average, lowpass, highpass, derivative, integral, clamp, formula); panel/user/global visibility scopes |
|
||||||
|
| **Access control** | Identity via trusted proxy header; per-user global level (write/read-only/no-access); per-panel ownership + sharing; nested folders; optional logic-editor allowlist |
|
||||||
| **Multi-client** | Subscriptions are multiplexed — N clients share one upstream connection per signal |
|
| **Multi-client** | Subscriptions are multiplexed — N clients share one upstream connection per signal |
|
||||||
| **Signal discovery** | Filter/search, CSV import, manual add, Channel Finder proxy |
|
| **Signal discovery** | Filter/search, CSV import, manual add, Channel Finder proxy |
|
||||||
| **Observability** | Prometheus-format metrics at `/metrics` |
|
| **Observability** | Prometheus-format metrics at `/metrics` |
|
||||||
@@ -64,7 +69,20 @@ Create `uopi.toml` (all fields are optional — shown values are defaults):
|
|||||||
```toml
|
```toml
|
||||||
[server]
|
[server]
|
||||||
listen = ":8080"
|
listen = ":8080"
|
||||||
storage_dir = "./data" # where interface XML files and synthetic.json are stored
|
storage_dir = "./interfaces" # where interface XML, ACL and logic data are stored
|
||||||
|
|
||||||
|
# Identity & access control (all optional)
|
||||||
|
trusted_user_header = "" # HTTP header carrying the proxy-authenticated user
|
||||||
|
default_user = "" # identity used when the header is absent (LAN/dev)
|
||||||
|
# logic_editors = [] # restrict who may edit panel/control logic (users or groups)
|
||||||
|
|
||||||
|
# [[server.blacklist]] # downgrade a user: level = "readonly" or "noaccess"
|
||||||
|
# user = "guest"
|
||||||
|
# level = "readonly"
|
||||||
|
|
||||||
|
# [[groups]] # named user sets, referenced by panel sharing
|
||||||
|
# name = "operators"
|
||||||
|
# members = ["alice", "bob"]
|
||||||
|
|
||||||
[datasource.epics]
|
[datasource.epics]
|
||||||
enabled = false # requires build with -tags epics
|
enabled = false # requires build with -tags epics
|
||||||
@@ -118,7 +136,16 @@ The REST API is available under `/api/v1`. Key endpoints:
|
|||||||
| `GET/POST` | `/api/v1/interfaces` | List or create HMI interfaces |
|
| `GET/POST` | `/api/v1/interfaces` | List or create HMI interfaces |
|
||||||
| `GET/PUT/DELETE` | `/api/v1/interfaces/{id}` | Read, update, or delete an interface |
|
| `GET/PUT/DELETE` | `/api/v1/interfaces/{id}` | Read, update, or delete an interface |
|
||||||
| `POST` | `/api/v1/interfaces/{id}/clone` | Duplicate an interface |
|
| `POST` | `/api/v1/interfaces/{id}/clone` | Duplicate an interface |
|
||||||
|
| `POST` | `/api/v1/interfaces/reorder` | Reorder panels / move them between folders |
|
||||||
|
| `GET/PUT` | `/api/v1/interfaces/{id}/acl` | Read or set a panel's sharing rules |
|
||||||
|
| `GET` | `/api/v1/interfaces/{id}/versions` | List saved versions of a panel |
|
||||||
|
| `GET/POST` | `/api/v1/folders` | List or create panel folders |
|
||||||
|
| `PUT/DELETE` | `/api/v1/folders/{id}` | Rename/reparent or delete a folder |
|
||||||
| `GET/POST/DELETE` | `/api/v1/synthetic` | Manage synthetic signal definitions |
|
| `GET/POST/DELETE` | `/api/v1/synthetic` | Manage synthetic signal definitions |
|
||||||
|
| `GET/POST` | `/api/v1/controllogic` | List or create server-side control-logic graphs |
|
||||||
|
| `GET/PUT/DELETE` | `/api/v1/controllogic/{id}` | Read, update, or delete a control-logic graph |
|
||||||
|
| `GET` | `/api/v1/me` | Caller identity, access level, groups, logic-edit permission |
|
||||||
|
| `GET` | `/api/v1/usergroups` | List configured users and groups (for sharing) |
|
||||||
| `GET` | `/metrics` | Prometheus-format server metrics |
|
| `GET` | `/metrics` | Prometheus-format server metrics |
|
||||||
| `GET` | `/healthz` | Health check |
|
| `GET` | `/healthz` | Health check |
|
||||||
|
|
||||||
|
|||||||
+14
-2
@@ -14,6 +14,7 @@ import (
|
|||||||
"github.com/uopi/uopi/internal/access"
|
"github.com/uopi/uopi/internal/access"
|
||||||
"github.com/uopi/uopi/internal/broker"
|
"github.com/uopi/uopi/internal/broker"
|
||||||
"github.com/uopi/uopi/internal/config"
|
"github.com/uopi/uopi/internal/config"
|
||||||
|
"github.com/uopi/uopi/internal/controllogic"
|
||||||
"github.com/uopi/uopi/internal/datasource/epics"
|
"github.com/uopi/uopi/internal/datasource/epics"
|
||||||
"github.com/uopi/uopi/internal/datasource/pva"
|
"github.com/uopi/uopi/internal/datasource/pva"
|
||||||
"github.com/uopi/uopi/internal/datasource/stub"
|
"github.com/uopi/uopi/internal/datasource/stub"
|
||||||
@@ -132,9 +133,20 @@ func main() {
|
|||||||
for _, g := range cfg.Groups {
|
for _, g := range cfg.Groups {
|
||||||
groups[g.Name] = g.Members
|
groups[g.Name] = g.Members
|
||||||
}
|
}
|
||||||
policy := access.New(cfg.Server.DefaultUser, blacklist, groups)
|
policy := access.New(cfg.Server.DefaultUser, blacklist, groups, cfg.Server.LogicEditors)
|
||||||
|
|
||||||
srv := server.New(cfg.Server.Listen, webFS, brk, synthDS, store, policy, aclStore, cfg.Datasource.EPICS.ChannelFinderURL, cfg.Datasource.EPICS.ArchiveURL, cfg.Server.TrustedUserHeader, log)
|
// Server-side control logic: flow graphs that run continuously under the root
|
||||||
|
// context, independent of any panel. The store is loaded from disk and the
|
||||||
|
// engine starts all enabled graphs.
|
||||||
|
ctrlStore, err := controllogic.NewStore(cfg.Server.StorageDir)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("failed to open control logic store", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
ctrlEngine := controllogic.NewEngine(ctx, brk, ctrlStore, log)
|
||||||
|
ctrlEngine.Reload()
|
||||||
|
|
||||||
|
srv := server.New(cfg.Server.Listen, webFS, brk, synthDS, store, policy, aclStore, ctrlStore, ctrlEngine, cfg.Datasource.EPICS.ChannelFinderURL, cfg.Datasource.EPICS.ArchiveURL, cfg.Server.TrustedUserHeader, log)
|
||||||
|
|
||||||
if err := srv.Start(ctx); err != nil {
|
if err := srv.Start(ctx); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "server error: %v\n", err)
|
fmt.Fprintf(os.Stderr, "server error: %v\n", err)
|
||||||
|
|||||||
+133
-46
@@ -8,13 +8,23 @@ uopi is a web-based HMI (Human-Machine Interface) for monitoring and controlling
|
|||||||
|
|
||||||
## 2. Users and Roles
|
## 2. Users and Roles
|
||||||
|
|
||||||
| Role | Description |
|
User identity is established from a header set by a trusted authenticating reverse
|
||||||
|------|-------------|
|
proxy (`server.trusted_user_header`), with a configurable `default_user` fallback for
|
||||||
| Operator | Uses interfaces in View mode; can interact with controls but cannot edit layouts |
|
unproxied/LAN deployments. There is no login page inside the application.
|
||||||
| Engineer | Creates and edits interfaces in Edit mode; manages signal lists |
|
|
||||||
| Administrator | Manages server configuration, data sources, and saved interfaces |
|
|
||||||
|
|
||||||
In the initial version all users share the same access level. Role-based access control is deferred to a future release.
|
**Global access levels.** Every user is trusted with full **write** access by default. A
|
||||||
|
configuration blacklist can downgrade specific users to **read-only** (view only, no
|
||||||
|
writes) or **no access** (denied). Named **groups** are defined in configuration and
|
||||||
|
referenced by per-panel sharing.
|
||||||
|
|
||||||
|
**Per-panel access.** Panels are owned by their creator and private by default. Owners
|
||||||
|
share a panel with specific users/groups (read or write) or make it public; the owner's
|
||||||
|
global level always caps the per-panel permission. Panels are organised into nested
|
||||||
|
folders whose permissions inherit down the chain. See §9.
|
||||||
|
|
||||||
|
**Logic-edit restriction.** Adding/editing panel logic and server-side control logic can
|
||||||
|
optionally be limited to an allowlist of users/groups (`server.logic_editors`); when
|
||||||
|
unset, any writer may edit logic.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -30,27 +40,27 @@ The default mode when opening the application.
|
|||||||
- "New interface" button opens Edit mode with a blank canvas.
|
- "New interface" button opens Edit mode with a blank canvas.
|
||||||
- The pane width can be adjusted by dragging the resize handle on its right edge.
|
- The pane width can be adjusted by dragging the resize handle on its right edge.
|
||||||
|
|
||||||
**Tabs**
|
|
||||||
- **HMI tab**: the live widget canvas (described below).
|
|
||||||
- **Plot tab**: a dedicated live multi-signal plot panel (see §3.3).
|
|
||||||
- **Info tab**: signal info and metadata display for the last right-clicked signal.
|
|
||||||
|
|
||||||
**HMI canvas (center)**
|
**HMI canvas (center)**
|
||||||
- Renders the selected interface as a live, interactive panel.
|
- Renders the selected interface as a live, interactive panel.
|
||||||
- Widgets display real-time data; controls (set-value, buttons) are active.
|
- Widgets display real-time data; controls (set-value, buttons) are active.
|
||||||
|
- Panel logic (if any) runs while the panel is open (see §6).
|
||||||
- No drag, resize, or layout operations are possible in this mode.
|
- No drag, resize, or layout operations are possible in this mode.
|
||||||
- Right-clicking any widget opens a context menu:
|
- Right-clicking any widget opens a context menu:
|
||||||
- **Signal info** — switches to the Info tab showing DS name, type, unit, range, current value and timestamp.
|
- **Signal info** — shows DS name, type, unit, range, current value and timestamp.
|
||||||
- **Copy signal name** — copies the signal identifier to the clipboard.
|
- **Copy signal name** — copies the signal identifier to the clipboard.
|
||||||
- **Export data to CSV** — downloads buffered data for the signal(s) used by the widget.
|
- **Export data to CSV** — downloads buffered data for the signal(s) used by the widget.
|
||||||
- **Plot** — adds the signal(s) to the Plot tab.
|
|
||||||
|
> Dedicated multi-signal plotting is provided by **plot panels** — a special interface
|
||||||
|
> kind whose plots fill the viewport (see §3.3) — rather than a separate live "Plot tab".
|
||||||
|
|
||||||
**Top toolbar**
|
**Top toolbar**
|
||||||
- Show/hide interface list pane.
|
- Show/hide interface list pane.
|
||||||
- **⏱ History** button: toggle historical time navigation bar.
|
- **⏱ History** button: toggle historical time navigation bar.
|
||||||
|
- **⚙ Control logic** button: open the server-side control-logic editor (see §7).
|
||||||
|
Shown only to users permitted to edit logic.
|
||||||
- Zoom control (A− / % / A+): adjust the UI scale (see §3.4).
|
- Zoom control (A− / % / A+): adjust the UI scale (see §3.4).
|
||||||
- Edit button: switch to Edit mode for the current interface.
|
- Edit button: switch to Edit mode for the current interface.
|
||||||
- Connection status indicator.
|
- Connection status indicator and signed-in user chip.
|
||||||
|
|
||||||
**Historical time navigation bar** (shown when History is active)
|
**Historical time navigation bar** (shown when History is active)
|
||||||
- Date/time range pickers (start and end).
|
- Date/time range pickers (start and end).
|
||||||
@@ -71,9 +81,17 @@ Activated via the "New interface" button or by clicking Edit in the toolbar.
|
|||||||
- Filter/search box to narrow the list.
|
- Filter/search box to narrow the list.
|
||||||
- Synthetic signals show an edit (✎) button to reopen the wizard.
|
- Synthetic signals show an edit (✎) button to reopen the wizard.
|
||||||
|
|
||||||
**Widget canvas (center)**
|
**Center area — Layout / Logic tabs**
|
||||||
- Free-form canvas where widgets can be placed at arbitrary pixel positions.
|
- **Layout**: free-form canvas where widgets can be placed at arbitrary pixel positions,
|
||||||
- Background grid with snap-to-grid.
|
over a background grid with snap-to-grid. (For plot panels this is replaced by the
|
||||||
|
split-layout editor; see §3.3.)
|
||||||
|
- **Logic**: a node-graph flow editor for panel behaviour (see §6). Hidden when the user
|
||||||
|
is not permitted to edit logic.
|
||||||
|
|
||||||
|
**Local variables**
|
||||||
|
- A panel may declare panel-scoped variables (data source `local`) with initial values.
|
||||||
|
- They are written by set-value widgets, buttons and logic actions, and referenced
|
||||||
|
anywhere a signal is. Added from the signal tree's *Local* group / the Logic palette.
|
||||||
|
|
||||||
**Properties pane (right, resizable and collapsible)**
|
**Properties pane (right, resizable and collapsible)**
|
||||||
- Appears when one or more widgets are selected.
|
- Appears when one or more widgets are selected.
|
||||||
@@ -88,25 +106,27 @@ Activated via the "New interface" button or by clicking Edit in the toolbar.
|
|||||||
- Close (return to View mode).
|
- Close (return to View mode).
|
||||||
- Zoom control (A− / % / A+).
|
- Zoom control (A− / % / A+).
|
||||||
|
|
||||||
### 3.3 Plot Tab (Live Multi-Signal Plot)
|
### 3.3 Plot Panels (Split Layout)
|
||||||
|
|
||||||
A dedicated panel for live time-series plotting of any signals, independent of the interface layout.
|
A **plot panel** is a special interface kind dedicated to charts. Rather than free-form
|
||||||
|
widget boxes, plots **fill the viewport** and the user divides the space between them with
|
||||||
|
a recursive split layout (tmux/IDE-style tiling). Created via **+ Plot** in the interface
|
||||||
|
list, opening with a single full-viewport empty plot.
|
||||||
|
|
||||||
**Adding signals:** right-click any widget in the HMI tab → **Plot**. The signal is added to the Plot tab immediately.
|
**Editing (Edit mode):**
|
||||||
|
- Hover a pane and use its split buttons (**⬌** vertical / **⬍** horizontal) to divide it;
|
||||||
|
a new empty plot fills the freed half. Nesting is unlimited.
|
||||||
|
- Drag the divider between two panes to resize them.
|
||||||
|
- Click a pane to select it and configure its plot in the Properties pane (plot sub-type,
|
||||||
|
Y range, time window, legend, per-signal colour). Drag a signal onto a pane to add it.
|
||||||
|
- A pane's **✕** removes it; the layout collapses onto its sibling.
|
||||||
|
|
||||||
**Time window selector:** 10s / 30s / 1m / 5m / 15m / 1h buttons control how much history is displayed.
|
**View mode:** the saved split layout fills the screen with live, streaming plots; the
|
||||||
|
per-widget right-click menu (signal info / copy / CSV) and historical time navigation
|
||||||
|
apply as for any time-series plot.
|
||||||
|
|
||||||
**Per-signal legend:**
|
Per-plot configuration reuses the standard Plot widget (§4.4), so all plot sub-types and
|
||||||
- Color swatch and signal name.
|
options are available within a pane.
|
||||||
- Statistics table: Last / Min / Max / Mean over the current window.
|
|
||||||
- ✎ button opens an inline style editor:
|
|
||||||
- **Color**: color picker.
|
|
||||||
- **Width**: line width (none, 1, 1.5, 2, 3 px).
|
|
||||||
- **Line**: solid / dashed / dotted.
|
|
||||||
- **Markers**: none, S (3 px), M (5 px), L (8 px).
|
|
||||||
- ✕ button removes the signal from the plot.
|
|
||||||
|
|
||||||
**Chart area:** rendered with uPlot; auto-scaled Y axis; time axis tracks the rolling window in real time.
|
|
||||||
|
|
||||||
### 3.4 UI Zoom
|
### 3.4 UI Zoom
|
||||||
|
|
||||||
@@ -210,16 +230,18 @@ When the signal's metadata reports enum strings (e.g. EPICS mbbi/mbbo records):
|
|||||||
|
|
||||||
A signal defined by composing one or more input signals through a chain of processing nodes.
|
A signal defined by composing one or more input signals through a chain of processing nodes.
|
||||||
|
|
||||||
**New Synthetic Signal Wizard:**
|
**Two authoring surfaces:**
|
||||||
1. Click "New synthetic signal" in the signal tree.
|
- **Wizard** — for the common single-input case: name the signal, pick an input and a
|
||||||
2. Name the signal and optionally set a unit.
|
processing node, set parameters, Create.
|
||||||
3. Add processing nodes from the node type dropdown and connect them.
|
- **Node-graph editor** — a visual editor that wires one or more inputs through a chain of
|
||||||
4. Configure each node's parameters inline.
|
DSP blocks, for multi-input pipelines. It compiles to the same inputs + pipeline model.
|
||||||
5. Click Create — the signal appears in the tree and updates live.
|
|
||||||
|
|
||||||
The wizard dialog is resizable (drag its corner) and defaults to 600 px wide to accommodate the Lua editor.
|
**Visibility scope:** each synthetic signal is scoped as *panel* (visible only to the panel
|
||||||
|
that created it), *user*, or *global* (shared with everyone).
|
||||||
|
|
||||||
**Editing an existing synthetic signal:** click the ✎ button next to the signal in the tree to reopen the wizard.
|
The dialogs are resizable and default to a width that accommodates the Lua editor.
|
||||||
|
|
||||||
|
**Editing an existing synthetic signal:** click the ✎ button next to the signal in the tree to reopen the editor.
|
||||||
|
|
||||||
**Built-in node types:**
|
**Built-in node types:**
|
||||||
|
|
||||||
@@ -237,15 +259,80 @@ The wizard dialog is resizable (drag its corner) and defaults to 600 px wide to
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. Interface Persistence
|
## 6. Panel Logic
|
||||||
|
|
||||||
- Interfaces are saved to the server in XML format and are available to all connected clients.
|
The **Logic** tab in the panel editor is a node-graph (Node-RED-style) flow editor that
|
||||||
- Export/Import allows local file exchange of XML files.
|
gives a panel interactive behaviour. The flow runs **client-side** while the panel is open
|
||||||
- The XML schema records: widget type, position, size, signal bindings, and all property values.
|
in View mode; it is saved as part of the interface XML. The tab and any logic editing are
|
||||||
|
gated by the logic-edit restriction (§2).
|
||||||
|
|
||||||
|
**Authoring:** drag blocks from the palette (or click to add), connect output ports to
|
||||||
|
input ports, and edit the selected node in the inspector. Expression fields reference a
|
||||||
|
signal as `{ds:name}` and a panel-local variable by its bare name, and support arithmetic,
|
||||||
|
comparisons, boolean logic and common math functions. The editor has its own undo/redo and
|
||||||
|
copy/paste.
|
||||||
|
|
||||||
|
**Node categories:**
|
||||||
|
|
||||||
|
| Category | Nodes |
|
||||||
|
|----------|-------|
|
||||||
|
| Triggers | Button press, threshold crossing, value change, timer/interval, panel loop, On-open / On-close lifecycle |
|
||||||
|
| Logic | AND gate, If (then/else), Loop (count or while) |
|
||||||
|
| Actions | Write to signal/variable, Delay, Log; Accumulate / Export-CSV / Clear for in-memory data arrays |
|
||||||
|
| Dialogs | Info and Error pop-ups; Set-point prompt (asks the user for a number and writes it) |
|
||||||
|
|
||||||
|
**System helpers in expressions:** `{sys:time}` (epoch seconds) and `{sys:dt}` (seconds
|
||||||
|
since the firing trigger last fired).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. Historical Data Navigation
|
## 7. Control Logic
|
||||||
|
|
||||||
|
**Control logic** is server-side automation: flow graphs that run continuously on the
|
||||||
|
server, independent of any connected client (unlike panel logic, which only runs while a
|
||||||
|
panel is open). Opened with the **⚙ Control logic** button in the View-mode toolbar and
|
||||||
|
managed through the REST API.
|
||||||
|
|
||||||
|
- Triggers include *cron* schedules and signal *alarm*/threshold conditions.
|
||||||
|
- A **Lua** block provides custom logic; results are written back to signals.
|
||||||
|
- Each graph can be enabled/disabled independently; saving reloads the engine live.
|
||||||
|
- Editing is gated by the logic-edit restriction (§2).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Interface Persistence
|
||||||
|
|
||||||
|
- Interfaces are saved to the server in XML format and are available to all connected clients.
|
||||||
|
- Per-panel access rules and panel-folder placement are stored server-side (sidecar JSON).
|
||||||
|
- Saved versions are retained; a panel's version history can be listed, tagged and promoted.
|
||||||
|
- Export/Import allows local file exchange of XML files.
|
||||||
|
- The XML schema records: interface kind (panel/plot) and split layout, widget type,
|
||||||
|
position, size, signal bindings, all property values, local variables, and panel logic.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Access Control, Sharing & Folders
|
||||||
|
|
||||||
|
**Identity** is resolved per request from the trusted proxy header, falling back to
|
||||||
|
`default_user`. The `/api/v1/me` endpoint reports the caller's identity, global level,
|
||||||
|
group memberships and whether they may edit logic; the UI hides affordances accordingly.
|
||||||
|
|
||||||
|
**Global levels** (§2): write (default), read-only, or no-access via the config blacklist.
|
||||||
|
|
||||||
|
**Per-panel sharing:** the **Share** dialog on a panel grants read or write to specific
|
||||||
|
users or groups, or marks the panel public. New panels are private to their owner. The
|
||||||
|
owner's global level caps any per-panel grant.
|
||||||
|
|
||||||
|
**Folders:** panels are organised into nested folders; permissions inherit down the folder
|
||||||
|
chain. Panels can be dragged to reorder or move between folders.
|
||||||
|
|
||||||
|
**Logic-editor allowlist:** when `server.logic_editors` is set, only the listed users/
|
||||||
|
groups may add or change panel logic and control logic; everyone else keeps full access to
|
||||||
|
the rest of the application.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Historical Data Navigation
|
||||||
|
|
||||||
When the server has archive access configured:
|
When the server has archive access configured:
|
||||||
- The **⏱ History** button in the View mode toolbar reveals a time range bar.
|
- The **⏱ History** button in the View mode toolbar reveals a time range bar.
|
||||||
@@ -257,7 +344,7 @@ When the server has archive access configured:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. Non-functional Requirements
|
## 11. Non-functional Requirements
|
||||||
|
|
||||||
| Requirement | Target |
|
| Requirement | Target |
|
||||||
|-------------|--------|
|
|-------------|--------|
|
||||||
|
|||||||
+102
-21
@@ -187,16 +187,34 @@ Framing: JSON messages over a single persistent WebSocket connection per client.
|
|||||||
Base path: `/api/v1`
|
Base path: `/api/v1`
|
||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
| ------ | ------------------------- | -------------------------------------------- |
|
| --------------- | ------------------------------------- | ------------------------------------------------- |
|
||||||
|
| GET | `/me` | Caller identity, global level, groups, `canEditLogic` |
|
||||||
| GET | `/datasources` | List connected data sources and their status |
|
| GET | `/datasources` | List connected data sources and their status |
|
||||||
| GET | `/signals?ds=epics` | List signals for a data source |
|
| GET | `/signals?ds=epics` | List signals for a data source |
|
||||||
| GET | `/signals/:ds/:name/meta` | Get full metadata for a signal |
|
| GET | `/signals/search?q=` | Search signals across all sources |
|
||||||
| GET | `/interfaces` | List saved interfaces |
|
| GET | `/channel-finder?q=` | Proxy to EPICS Channel Finder |
|
||||||
| POST | `/interfaces` | Create a new interface (body: XML) |
|
| GET | `/archiver/search?q=` | Search the EPICS archiver for PV names |
|
||||||
| GET | `/interfaces/:id` | Download interface XML |
|
| GET, POST | `/interfaces` | List saved interfaces / create one (body: XML) |
|
||||||
| PUT | `/interfaces/:id` | Update interface XML |
|
| POST | `/interfaces/reorder` | Reorder panels / move between folders |
|
||||||
| DELETE | `/interfaces/:id` | Delete interface |
|
| GET, PUT, DELETE| `/interfaces/{id}` | Download, update, or delete an interface |
|
||||||
| POST | `/interfaces/:id/clone` | Clone an interface |
|
| POST | `/interfaces/{id}/clone` | Clone an interface |
|
||||||
|
| GET | `/interfaces/{id}/versions` | List saved versions; `…/{version}` to fetch one |
|
||||||
|
| PUT | `/interfaces/{id}/versions/{v}/tag` | Tag a version |
|
||||||
|
| POST | `/interfaces/{id}/versions/{v}/promote` | Promote a version to current |
|
||||||
|
| POST | `/interfaces/{id}/versions/{v}/fork` | Fork a version into a new panel |
|
||||||
|
| GET, PUT | `/interfaces/{id}/acl` | Read or set a panel's sharing rules |
|
||||||
|
| GET, POST | `/folders` | List or create panel folders |
|
||||||
|
| PUT, DELETE | `/folders/{id}` | Rename/reparent or delete a folder |
|
||||||
|
| GET | `/usergroups` | List configured users and groups (for sharing) |
|
||||||
|
| GET, PUT | `/groups` | Read or set group definitions |
|
||||||
|
| GET, POST | `/synthetic` | List or create synthetic signal definitions |
|
||||||
|
| GET, PUT, DELETE| `/synthetic/{name}` | Read, update, or delete a synthetic definition |
|
||||||
|
| GET, POST | `/controllogic` | List or create server-side control-logic graphs |
|
||||||
|
| GET, PUT, DELETE| `/controllogic/{id}` | Read, update, or delete a control-logic graph |
|
||||||
|
|
||||||
|
Mutating requests are gated by the access middleware (§8): global level for writes,
|
||||||
|
per-panel ACL for interface endpoints, and the logic-editor allowlist for control-logic
|
||||||
|
endpoints and for any change to a panel's `<logic>` block.
|
||||||
|
|
||||||
### 3.5 EPICS Data Source
|
### 3.5 EPICS Data Source
|
||||||
|
|
||||||
@@ -232,7 +250,15 @@ Base path: `/api/v1`
|
|||||||
|
|
||||||
### 3.7 Interface Storage
|
### 3.7 Interface Storage
|
||||||
|
|
||||||
Interfaces are stored as XML files in a configurable directory on the server.
|
Interfaces are stored as XML files in a configurable directory on the server. The
|
||||||
|
`<interface>` element carries an optional `kind` attribute (`panel` default, or `plot`);
|
||||||
|
plot panels add a nested `<layout>` split tree referencing plot widgets by id. Beyond
|
||||||
|
widgets, an interface may also contain panel-local variables and a `<logic>` flow graph
|
||||||
|
(`<node>` + `<param>` + `<wire>`), all round-tripping through the same XML.
|
||||||
|
|
||||||
|
Per-panel access rules and folder placement are kept in a sidecar `acl.json` in the same
|
||||||
|
directory (not in the interface XML), and prior versions of each panel are retained for the
|
||||||
|
version history endpoints.
|
||||||
|
|
||||||
```xml
|
```xml
|
||||||
<interface name="My Panel" version="1" created="2026-04-24T12:00:00Z">
|
<interface name="My Panel" version="1" created="2026-04-24T12:00:00Z">
|
||||||
@@ -255,6 +281,39 @@ Interfaces are stored as XML files in a configurable directory on the server.
|
|||||||
</interface>
|
</interface>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 3.8 Panel Logic Engine
|
||||||
|
|
||||||
|
Panel logic is a client-side flow engine (`web/src/lib/logic.ts`, singleton `logicEngine`).
|
||||||
|
The graph is built/edited in the panel editor's Logic tab (`LogicEditor.tsx`) and serialized
|
||||||
|
into the interface XML. In View mode, `Canvas` calls `logicEngine.load(iface.logic)` on
|
||||||
|
mount and `clear()` on unmount. The engine subscribes to every referenced signal into a live
|
||||||
|
cache, then on each trigger activation walks the wired graph (gates, if/loop, actions),
|
||||||
|
evaluating expression fields via a small safe recursive-descent evaluator (no `eval`).
|
||||||
|
Triggers include button/threshold/change/timer/loop and On-open/On-close lifecycle; actions
|
||||||
|
include signal writes, delay, log, in-memory data arrays (accumulate/export-CSV/clear) and
|
||||||
|
user dialogs (info/error/set-point). The engine runs entirely in the browser — no backend
|
||||||
|
component.
|
||||||
|
|
||||||
|
### 3.9 Control Logic Engine
|
||||||
|
|
||||||
|
Control logic is server-side (`internal/controllogic`): always-on flow graphs that run under
|
||||||
|
the root context, independent of any client. The engine subscribes to the broker, fires on
|
||||||
|
cron schedules and signal alarm/threshold conditions, executes a Lua block for custom logic,
|
||||||
|
and writes results back to signals. Graphs are persisted by a store and managed via
|
||||||
|
`/api/v1/controllogic`; each mutation calls `Engine.Reload()` to apply changes live. Graphs
|
||||||
|
can be individually enabled/disabled.
|
||||||
|
|
||||||
|
### 3.10 Access Control
|
||||||
|
|
||||||
|
Identity and global policy live in `internal/access` (`Policy`, `Level`). The user identity
|
||||||
|
is read per request from `server.trusted_user_header` (with a `default_user` fallback) and
|
||||||
|
stored on the request context. `accessMiddleware` gates mutating HTTP methods by the user's
|
||||||
|
global level. Per-panel ownership and ACL evaluation (with folder inheritance) live in
|
||||||
|
`internal/panelacl`, backed by the `acl.json` sidecar. `Policy.CanEditLogic(user)` enforces
|
||||||
|
the optional `server.logic_editors` allowlist over control-logic endpoints and over any
|
||||||
|
change to a panel's `<logic>` block; it is surfaced to the frontend through `/api/v1/me`
|
||||||
|
(`canEditLogic`) so the UI can hide logic-editing affordances.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Frontend Architecture
|
## 4. Frontend Architecture
|
||||||
@@ -298,16 +357,17 @@ View mode renders widgets as absolutely positioned Preact components on a scroll
|
|||||||
- Plot widgets maintain a rolling ring buffer of 200,000 samples per signal for smooth long-window display.
|
- Plot widgets maintain a rolling ring buffer of 200,000 samples per signal for smooth long-window display.
|
||||||
- Step-hold interpolation: when multiple signals at different update rates share a plot, the most recent value is carried forward to fill the shared time axis correctly.
|
- Step-hold interpolation: when multiple signals at different update rates share a plot, the most recent value is carried forward to fill the shared time axis correctly.
|
||||||
|
|
||||||
### 4.5 Plot Panel (`PlotPanel.tsx`)
|
### 4.5 Plot Panels (`SplitLayout.tsx`, `PlotPanelCanvas.tsx`)
|
||||||
|
|
||||||
A live multi-signal plot panel accessible from the "Plot" tab in View mode:
|
Plot panels are interfaces of `kind === 'plot'` whose plots fill the viewport in a recursive
|
||||||
|
split layout (`lib/plotLayout.ts` holds the pure tree helpers; `SplitLayout.tsx` is the
|
||||||
|
shared presentational renderer with draggable dividers):
|
||||||
|
|
||||||
- Signals are added by right-clicking any widget and choosing "Plot".
|
- In View mode, `Canvas` renders the saved layout read-only with live `PlotWidget`s.
|
||||||
- Supports configurable time window (10s to 1h).
|
- In Edit mode, `PlotPanelCanvas.tsx` adds per-pane overlays: split (⬌/⬍), close (✕), click
|
||||||
- Per-signal style editor: color picker, line width (0 = hidden), line dash (solid/dashed/dotted), marker size (none/S/M/L).
|
to select, and signal-drop to add a signal to that pane's plot.
|
||||||
- Statistics table per signal: last, min, max, mean over the current time window.
|
- Each pane reuses the standard `PlotWidget`, so all plot sub-types and options apply.
|
||||||
- Uses a `requestAnimationFrame` loop limited to ≤1 redraw/second when data is not changing.
|
- Layout edits (split/close/resize) participate in the editor's undo/redo.
|
||||||
- Chart fills its container; `ResizeObserver` keeps the uPlot canvas sized correctly.
|
|
||||||
|
|
||||||
### 4.6 Resizable Panels
|
### 4.6 Resizable Panels
|
||||||
|
|
||||||
@@ -407,17 +467,31 @@ Server is configured via a TOML file (default: `uopi.toml`, overridable via `--c
|
|||||||
listen = ":8080"
|
listen = ":8080"
|
||||||
storage_dir = "./interfaces"
|
storage_dir = "./interfaces"
|
||||||
|
|
||||||
|
# Access control (all optional)
|
||||||
|
trusted_user_header = "" # header carrying the proxy-authenticated user
|
||||||
|
default_user = "" # identity when the header is absent (LAN/dev)
|
||||||
|
logic_editors = [] # users/groups allowed to edit panel & control logic
|
||||||
|
|
||||||
|
# [[server.blacklist]] # downgrade users: level = "readonly" | "noaccess"
|
||||||
|
# user = "guest"
|
||||||
|
# level = "readonly"
|
||||||
|
|
||||||
|
# [[groups]] # named user sets for per-panel sharing
|
||||||
|
# name = "operators"
|
||||||
|
# members = ["alice", "bob"]
|
||||||
|
|
||||||
[datasource.epics]
|
[datasource.epics]
|
||||||
enabled = true
|
enabled = true
|
||||||
ca_addr_list = "" # EPICS_CA_ADDR_LIST override
|
ca_addr_list = "" # EPICS_CA_ADDR_LIST override
|
||||||
archive_url = "" # EPICS Archive Appliance URL
|
archive_url = "" # EPICS Archive Appliance URL
|
||||||
|
channel_finder_url = "" # EPICS Channel Finder URL
|
||||||
|
|
||||||
[datasource.synthetic]
|
[datasource.synthetic]
|
||||||
enabled = true
|
enabled = true
|
||||||
definitions_file = "./synthetic.toml"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
All settings can also be overridden with `UOPI_*` environment variables (e.g. `UOPI_SERVER_LISTEN`, `UOPI_EPICS_CA_ADDR_LIST`).
|
All settings can also be overridden with `UOPI_*` environment variables (e.g.
|
||||||
|
`UOPI_SERVER_LISTEN`, `UOPI_SERVER_LOGIC_EDITORS`, `UOPI_EPICS_CA_ADDR_LIST`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -440,13 +514,20 @@ All settings can also be overridden with `UOPI_*` environment variables (e.g. `U
|
|||||||
- Lua sandbox: disable `os`, `io`, `package`, `debug` libraries; restrict `math` and `string` to safe subsets.
|
- Lua sandbox: disable `os`, `io`, `package`, `debug` libraries; restrict `math` and `string` to safe subsets.
|
||||||
- WebSocket write operations: validate that the target signal is writable before forwarding to the data source.
|
- WebSocket write operations: validate that the target signal is writable before forwarding to the data source.
|
||||||
- Interface XML parsing: use strict schema validation to prevent XXE.
|
- Interface XML parsing: use strict schema validation to prevent XXE.
|
||||||
- No authentication in v1; intended for trusted LAN / SSH-tunnel deployment.
|
- **Identity & access control:** the end-user identity is taken from a header set by a
|
||||||
|
trusted authenticating reverse proxy (`trusted_user_header`), never from client-supplied
|
||||||
|
values — the proxy MUST strip any inbound copy of that header or it can be spoofed. A
|
||||||
|
global blacklist downgrades users (read-only/no-access); per-panel ACLs and the optional
|
||||||
|
`logic_editors` allowlist provide finer control. An unidentified caller (no header, no
|
||||||
|
`default_user`) is treated as a trusted-LAN user with full write access, preserving the
|
||||||
|
unproxied/SSH-tunnel deployment model.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9. Non-goals (v1)
|
## 9. Non-goals (v1)
|
||||||
|
|
||||||
- User authentication and authorisation.
|
- Built-in user authentication (a login page / credential store) — identity is delegated to
|
||||||
|
the front-end reverse proxy; authorisation (levels, ACLs, logic allowlist) is implemented.
|
||||||
- TLS termination (expected to be handled by SSH tunnel or a reverse proxy).
|
- TLS termination (expected to be handled by SSH tunnel or a reverse proxy).
|
||||||
- Windows or macOS server binary.
|
- Windows or macOS server binary.
|
||||||
- Mobile-optimised frontend layout.
|
- Mobile-optimised frontend layout.
|
||||||
|
|||||||
@@ -60,15 +60,25 @@ type Policy struct {
|
|||||||
blacklist map[string]Level // user → downgraded level
|
blacklist map[string]Level // user → downgraded level
|
||||||
userGroups map[string][]string // user → groups they belong to
|
userGroups map[string][]string // user → groups they belong to
|
||||||
groupNames []string // all configured group names (sorted)
|
groupNames []string // all configured group names (sorted)
|
||||||
|
logicEditors map[string]bool // users + group names allowed to edit logic
|
||||||
}
|
}
|
||||||
|
|
||||||
// New builds a Policy. blacklist maps a username to a config level string;
|
// New builds a Policy. blacklist maps a username to a config level string;
|
||||||
// groups maps a group name to its member usernames.
|
// groups maps a group name to its member usernames. logicEditors optionally
|
||||||
func New(defaultUser string, blacklist map[string]string, groups map[string][]string) *Policy {
|
// restricts who may edit panel/control logic (usernames or group names); empty
|
||||||
|
// means no restriction.
|
||||||
|
func New(defaultUser string, blacklist map[string]string, groups map[string][]string, logicEditors []string) *Policy {
|
||||||
p := &Policy{
|
p := &Policy{
|
||||||
defaultUser: strings.TrimSpace(defaultUser),
|
defaultUser: strings.TrimSpace(defaultUser),
|
||||||
blacklist: make(map[string]Level),
|
blacklist: make(map[string]Level),
|
||||||
userGroups: make(map[string][]string),
|
userGroups: make(map[string][]string),
|
||||||
|
logicEditors: make(map[string]bool),
|
||||||
|
}
|
||||||
|
for _, e := range logicEditors {
|
||||||
|
e = strings.TrimSpace(e)
|
||||||
|
if e != "" {
|
||||||
|
p.logicEditors[e] = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for user, lvl := range blacklist {
|
for user, lvl := range blacklist {
|
||||||
u := strings.TrimSpace(user)
|
u := strings.TrimSpace(user)
|
||||||
@@ -126,6 +136,35 @@ func (p *Policy) Level(user string) Level {
|
|||||||
return LevelWrite
|
return LevelWrite
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LogicRestricted reports whether a logic-editor allowlist is configured. When
|
||||||
|
// false, any write-capable user may edit panel/control logic.
|
||||||
|
func (p *Policy) LogicRestricted() bool {
|
||||||
|
return len(p.logicEditors) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// CanEditLogic reports whether a user may add or edit panel logic and
|
||||||
|
// server-side control logic. When no allowlist is configured everyone with
|
||||||
|
// write access qualifies; otherwise the user (or one of their groups) must be
|
||||||
|
// listed. Anonymous/trusted-LAN callers (user=="") are always permitted.
|
||||||
|
func (p *Policy) CanEditLogic(user string) bool {
|
||||||
|
user = strings.TrimSpace(user)
|
||||||
|
if user == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if !p.LogicRestricted() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if p.logicEditors[user] {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, g := range p.userGroups[user] {
|
||||||
|
if p.logicEditors[g] {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// GroupsOf returns a copy of the groups a user belongs to.
|
// GroupsOf returns a copy of the groups a user belongs to.
|
||||||
func (p *Policy) GroupsOf(user string) []string {
|
func (p *Policy) GroupsOf(user string) []string {
|
||||||
src := p.userGroups[strings.TrimSpace(user)]
|
src := p.userGroups[strings.TrimSpace(user)]
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package access
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestCanEditLogic(t *testing.T) {
|
||||||
|
groups := map[string][]string{"ops": {"carol"}}
|
||||||
|
|
||||||
|
// No allowlist configured: everyone with write access may edit logic.
|
||||||
|
open := New("", nil, groups, nil)
|
||||||
|
for _, u := range []string{"", "alice", "carol"} {
|
||||||
|
if !open.CanEditLogic(u) {
|
||||||
|
t.Errorf("unrestricted: CanEditLogic(%q) = false, want true", u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if open.LogicRestricted() {
|
||||||
|
t.Error("LogicRestricted() = true with no allowlist")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allowlist by username and by group name.
|
||||||
|
p := New("", nil, groups, []string{"alice", "ops"})
|
||||||
|
if !p.LogicRestricted() {
|
||||||
|
t.Error("LogicRestricted() = false with allowlist set")
|
||||||
|
}
|
||||||
|
cases := map[string]bool{
|
||||||
|
"": true, // anonymous / trusted LAN
|
||||||
|
"alice": true, // listed user
|
||||||
|
"carol": true, // member of listed group "ops"
|
||||||
|
"bob": false, // not listed
|
||||||
|
}
|
||||||
|
for u, want := range cases {
|
||||||
|
if got := p.CanEditLogic(u); got != want {
|
||||||
|
t.Errorf("CanEditLogic(%q) = %v, want %v", u, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+181
-1
@@ -15,6 +15,7 @@ import (
|
|||||||
|
|
||||||
"github.com/uopi/uopi/internal/access"
|
"github.com/uopi/uopi/internal/access"
|
||||||
"github.com/uopi/uopi/internal/broker"
|
"github.com/uopi/uopi/internal/broker"
|
||||||
|
"github.com/uopi/uopi/internal/controllogic"
|
||||||
"github.com/uopi/uopi/internal/datasource"
|
"github.com/uopi/uopi/internal/datasource"
|
||||||
"github.com/uopi/uopi/internal/datasource/synthetic"
|
"github.com/uopi/uopi/internal/datasource/synthetic"
|
||||||
"github.com/uopi/uopi/internal/panelacl"
|
"github.com/uopi/uopi/internal/panelacl"
|
||||||
@@ -28,19 +29,23 @@ type Handler struct {
|
|||||||
store *storage.Store
|
store *storage.Store
|
||||||
policy *access.Policy
|
policy *access.Policy
|
||||||
acl *panelacl.Store
|
acl *panelacl.Store
|
||||||
|
ctrlLogic *controllogic.Store
|
||||||
|
ctrlEngine *controllogic.Engine
|
||||||
channelFinderURL string // empty if not configured
|
channelFinderURL string // empty if not configured
|
||||||
archiverURL string // empty if not configured
|
archiverURL string // empty if not configured
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates an API Handler. synth may be nil if the synthetic DS is disabled.
|
// New creates an API Handler. synth may be nil if the synthetic DS is disabled.
|
||||||
func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, policy *access.Policy, acl *panelacl.Store, channelFinderURL, archiverURL string, log *slog.Logger) *Handler {
|
func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, policy *access.Policy, acl *panelacl.Store, ctrlLogic *controllogic.Store, ctrlEngine *controllogic.Engine, channelFinderURL, archiverURL string, log *slog.Logger) *Handler {
|
||||||
return &Handler{
|
return &Handler{
|
||||||
broker: b,
|
broker: b,
|
||||||
synthetic: synth,
|
synthetic: synth,
|
||||||
store: store,
|
store: store,
|
||||||
policy: policy,
|
policy: policy,
|
||||||
acl: acl,
|
acl: acl,
|
||||||
|
ctrlLogic: ctrlLogic,
|
||||||
|
ctrlEngine: ctrlEngine,
|
||||||
channelFinderURL: channelFinderURL,
|
channelFinderURL: channelFinderURL,
|
||||||
archiverURL: archiverURL,
|
archiverURL: archiverURL,
|
||||||
log: log,
|
log: log,
|
||||||
@@ -87,6 +92,13 @@ func (h *Handler) Register(mux *http.ServeMux, prefix string) {
|
|||||||
mux.HandleFunc("GET "+prefix+"/synthetic/{name}", h.getSynthetic)
|
mux.HandleFunc("GET "+prefix+"/synthetic/{name}", h.getSynthetic)
|
||||||
mux.HandleFunc("PUT "+prefix+"/synthetic/{name}", h.updateSynthetic)
|
mux.HandleFunc("PUT "+prefix+"/synthetic/{name}", h.updateSynthetic)
|
||||||
mux.HandleFunc("DELETE "+prefix+"/synthetic/{name}", h.deleteSynthetic)
|
mux.HandleFunc("DELETE "+prefix+"/synthetic/{name}", h.deleteSynthetic)
|
||||||
|
// Server-side control logic CRUD (mutations are write-gated by the access
|
||||||
|
// middleware; each mutation reloads the running engine).
|
||||||
|
mux.HandleFunc("GET "+prefix+"/controllogic", h.listControlLogic)
|
||||||
|
mux.HandleFunc("POST "+prefix+"/controllogic", h.createControlLogic)
|
||||||
|
mux.HandleFunc("GET "+prefix+"/controllogic/{id}", h.getControlLogic)
|
||||||
|
mux.HandleFunc("PUT "+prefix+"/controllogic/{id}", h.updateControlLogic)
|
||||||
|
mux.HandleFunc("DELETE "+prefix+"/controllogic/{id}", h.deleteControlLogic)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── /me ─────────────────────────────────────────────────────────────────────
|
// ── /me ─────────────────────────────────────────────────────────────────────
|
||||||
@@ -105,6 +117,7 @@ func (h *Handler) getMe(w http.ResponseWriter, r *http.Request) {
|
|||||||
"user": user,
|
"user": user,
|
||||||
"level": h.policy.Level(user).String(),
|
"level": h.policy.Level(user).String(),
|
||||||
"groups": groups,
|
"groups": groups,
|
||||||
|
"canEditLogic": h.policy.CanEditLogic(user),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -422,6 +435,7 @@ type interfaceListItem struct {
|
|||||||
storage.InterfaceMeta
|
storage.InterfaceMeta
|
||||||
Owner string `json:"owner,omitempty"`
|
Owner string `json:"owner,omitempty"`
|
||||||
Folder string `json:"folder,omitempty"`
|
Folder string `json:"folder,omitempty"`
|
||||||
|
Order float64 `json:"order,omitempty"`
|
||||||
Perm string `json:"perm"`
|
Perm string `json:"perm"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -442,6 +456,7 @@ func (h *Handler) listInterfaces(w http.ResponseWriter, r *http.Request) {
|
|||||||
if acl := h.acl.GetPanel(m.ID); acl != nil {
|
if acl := h.acl.GetPanel(m.ID); acl != nil {
|
||||||
item.Owner = acl.Owner
|
item.Owner = acl.Owner
|
||||||
item.Folder = acl.Folder
|
item.Folder = acl.Folder
|
||||||
|
item.Order = acl.Order
|
||||||
}
|
}
|
||||||
out = append(out, item)
|
out = append(out, item)
|
||||||
}
|
}
|
||||||
@@ -455,6 +470,12 @@ func (h *Handler) createInterface(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonError(w, http.StatusBadRequest, "read body: "+err.Error())
|
jsonError(w, http.StatusBadRequest, "read body: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Editing panel logic may be restricted to an allowlist. A brand-new panel
|
||||||
|
// carrying a non-empty <logic> block requires that permission.
|
||||||
|
if !h.policy.CanEditLogic(caller(r)) && extractLogicBlock(body) != "" {
|
||||||
|
jsonError(w, http.StatusForbidden, "you are not permitted to edit panel logic")
|
||||||
|
return
|
||||||
|
}
|
||||||
id, err := h.store.Create(body, r.URL.Query().Get("tag"))
|
id, err := h.store.Create(body, r.URL.Query().Get("tag"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.log.Error("create interface", "err", err)
|
h.log.Error("create interface", "err", err)
|
||||||
@@ -474,6 +495,44 @@ func (h *Handler) createInterface(w http.ResponseWriter, r *http.Request) {
|
|||||||
_ = json.NewEncoder(w).Encode(map[string]string{"id": id})
|
_ = json.NewEncoder(w).Encode(map[string]string{"id": id})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// reorderInterfaces places a set of panels into a folder (or the root when folder
|
||||||
|
// is empty) in the given order, stamping each id with order=index. The caller must
|
||||||
|
// have write access to the destination folder (if any) and to each panel.
|
||||||
|
func (h *Handler) reorderInterfaces(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Folder string `json:"folder"`
|
||||||
|
IDs []string `json:"ids"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil {
|
||||||
|
jsonError(w, http.StatusBadRequest, "decode body: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Folder != "" {
|
||||||
|
if _, err := h.acl.GetFolder(req.Folder); err != nil {
|
||||||
|
jsonError(w, http.StatusNotFound, "folder not found: "+req.Folder)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if h.folderPerm(r, req.Folder) < panelacl.PermWrite {
|
||||||
|
jsonError(w, http.StatusForbidden, "you do not have write access to this folder")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, id := range req.IDs {
|
||||||
|
if h.panelPerm(r, id) < panelacl.PermWrite {
|
||||||
|
jsonError(w, http.StatusForbidden, "you do not have write access to panel: "+id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i, id := range req.IDs {
|
||||||
|
if err := h.acl.PlacePanel(id, req.Folder, float64(i)); err != nil {
|
||||||
|
h.log.Error("reorder interface", "id", id, "err", err)
|
||||||
|
jsonError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) getInterface(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) getInterface(w http.ResponseWriter, r *http.Request) {
|
||||||
id := r.PathValue("id")
|
id := r.PathValue("id")
|
||||||
if h.panelPerm(r, id) < panelacl.PermRead {
|
if h.panelPerm(r, id) < panelacl.PermRead {
|
||||||
@@ -504,6 +563,14 @@ func (h *Handler) updateInterface(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonError(w, http.StatusBadRequest, "read body: "+err.Error())
|
jsonError(w, http.StatusBadRequest, "read body: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Editing panel logic may be restricted to an allowlist. Permit unrestricted
|
||||||
|
// edits to the rest of the panel as long as the <logic> block is unchanged.
|
||||||
|
if !h.policy.CanEditLogic(caller(r)) {
|
||||||
|
if cur, err := h.store.Get(id); err != nil || extractLogicBlock(body) != extractLogicBlock(cur) {
|
||||||
|
jsonError(w, http.StatusForbidden, "you are not permitted to edit panel logic")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
if err := h.store.Update(id, body, r.URL.Query().Get("tag")); err != nil {
|
if err := h.store.Update(id, body, r.URL.Query().Get("tag")); err != nil {
|
||||||
if errors.Is(err, storage.ErrNotFound) {
|
if errors.Is(err, storage.ErrNotFound) {
|
||||||
jsonError(w, http.StatusNotFound, "interface not found: "+id)
|
jsonError(w, http.StatusNotFound, "interface not found: "+id)
|
||||||
@@ -1009,8 +1076,121 @@ func (h *Handler) deleteSynthetic(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Control logic ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (h *Handler) listControlLogic(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
if h.ctrlLogic == nil {
|
||||||
|
jsonOK(w, []any{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
graphs := h.ctrlLogic.List()
|
||||||
|
if graphs == nil {
|
||||||
|
graphs = []controllogic.Graph{}
|
||||||
|
}
|
||||||
|
jsonOK(w, graphs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) getControlLogic(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if h.ctrlLogic == nil {
|
||||||
|
jsonError(w, http.StatusServiceUnavailable, "control logic not enabled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
g, err := h.ctrlLogic.Get(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, http.StatusNotFound, "control logic graph not found: "+r.PathValue("id"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonOK(w, g)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) createControlLogic(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if h.ctrlLogic == nil {
|
||||||
|
jsonError(w, http.StatusServiceUnavailable, "control logic not enabled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !h.policy.CanEditLogic(caller(r)) {
|
||||||
|
jsonError(w, http.StatusForbidden, "you are not permitted to edit logic")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var g controllogic.Graph
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&g); err != nil {
|
||||||
|
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
g.ID = genID("cl")
|
||||||
|
if err := h.ctrlLogic.Save(g); err != nil {
|
||||||
|
jsonError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.ctrlEngine.Reload()
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
jsonOK(w, g)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) updateControlLogic(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if h.ctrlLogic == nil {
|
||||||
|
jsonError(w, http.StatusServiceUnavailable, "control logic not enabled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !h.policy.CanEditLogic(caller(r)) {
|
||||||
|
jsonError(w, http.StatusForbidden, "you are not permitted to edit logic")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id := r.PathValue("id")
|
||||||
|
if _, err := h.ctrlLogic.Get(id); err != nil {
|
||||||
|
jsonError(w, http.StatusNotFound, "control logic graph not found: "+id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var g controllogic.Graph
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&g); err != nil {
|
||||||
|
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
g.ID = id
|
||||||
|
if err := h.ctrlLogic.Save(g); err != nil {
|
||||||
|
jsonError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.ctrlEngine.Reload()
|
||||||
|
jsonOK(w, g)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) deleteControlLogic(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if h.ctrlLogic == nil {
|
||||||
|
jsonError(w, http.StatusServiceUnavailable, "control logic not enabled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !h.policy.CanEditLogic(caller(r)) {
|
||||||
|
jsonError(w, http.StatusForbidden, "you are not permitted to edit logic")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.ctrlLogic.Delete(r.PathValue("id")); err != nil {
|
||||||
|
jsonError(w, http.StatusNotFound, "control logic graph not found: "+r.PathValue("id"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.ctrlEngine.Reload()
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// extractLogicBlock returns the verbatim <logic>…</logic> section of an
|
||||||
|
// interface XML document, or "" when absent. The frontend serializes the block
|
||||||
|
// only when it holds nodes/wires and uses a stable format, so comparing the
|
||||||
|
// extracted substrings reliably detects whether the panel logic changed.
|
||||||
|
func extractLogicBlock(xml []byte) string {
|
||||||
|
s := string(xml)
|
||||||
|
start := strings.Index(s, "<logic>")
|
||||||
|
if start < 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
end := strings.Index(s[start:], "</logic>")
|
||||||
|
if end < 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return s[start : start+end+len("</logic>")]
|
||||||
|
}
|
||||||
|
|
||||||
func metaToSignalInfo(m datasource.Metadata) signalInfo {
|
func metaToSignalInfo(m datasource.Metadata) signalInfo {
|
||||||
return signalInfo{
|
return signalInfo{
|
||||||
Name: m.Name,
|
Name: m.Name,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"github.com/uopi/uopi/internal/access"
|
"github.com/uopi/uopi/internal/access"
|
||||||
"github.com/uopi/uopi/internal/api"
|
"github.com/uopi/uopi/internal/api"
|
||||||
"github.com/uopi/uopi/internal/broker"
|
"github.com/uopi/uopi/internal/broker"
|
||||||
|
"github.com/uopi/uopi/internal/controllogic"
|
||||||
"github.com/uopi/uopi/internal/datasource/stub"
|
"github.com/uopi/uopi/internal/datasource/stub"
|
||||||
"github.com/uopi/uopi/internal/panelacl"
|
"github.com/uopi/uopi/internal/panelacl"
|
||||||
"github.com/uopi/uopi/internal/storage"
|
"github.com/uopi/uopi/internal/storage"
|
||||||
@@ -45,8 +46,14 @@ func setup(t *testing.T) (*httptest.Server, func()) {
|
|||||||
t.Fatal("panelacl.New:", err)
|
t.Fatal("panelacl.New:", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clStore, err := controllogic.NewStore(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal("controllogic.NewStore:", err)
|
||||||
|
}
|
||||||
|
clEngine := controllogic.NewEngine(ctx, brk, clStore, log)
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
api.New(brk, nil, store, access.New("", nil, nil), acl, "", "", log).Register(mux, "/api/v1")
|
api.New(brk, nil, store, access.New("", nil, nil, nil), acl, clStore, clEngine, "", "", log).Register(mux, "/api/v1")
|
||||||
|
|
||||||
srv := httptest.NewServer(mux)
|
srv := httptest.NewServer(mux)
|
||||||
return srv, func() {
|
return srv, func() {
|
||||||
|
|||||||
@@ -50,6 +50,13 @@ type ServerConfig struct {
|
|||||||
// Blacklist downgrades specific users' global access level. Everyone not
|
// Blacklist downgrades specific users' global access level. Everyone not
|
||||||
// listed is trusted with full write access.
|
// listed is trusted with full write access.
|
||||||
Blacklist []BlacklistEntry `toml:"blacklist"`
|
Blacklist []BlacklistEntry `toml:"blacklist"`
|
||||||
|
|
||||||
|
// LogicEditors optionally restricts who may add or edit panel logic (the
|
||||||
|
// <logic> block of interfaces) and server-side control logic. Entries are
|
||||||
|
// usernames or group names. When empty, no restriction applies (any user
|
||||||
|
// with write access may edit logic). Anonymous/trusted-LAN callers are
|
||||||
|
// always permitted.
|
||||||
|
LogicEditors []string `toml:"logic_editors"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DatasourceConfig struct {
|
type DatasourceConfig struct {
|
||||||
@@ -131,6 +138,9 @@ func applyEnv(cfg *Config) {
|
|||||||
if v := env("UOPI_SERVER_DEFAULT_USER"); v != "" {
|
if v := env("UOPI_SERVER_DEFAULT_USER"); v != "" {
|
||||||
cfg.Server.DefaultUser = v
|
cfg.Server.DefaultUser = v
|
||||||
}
|
}
|
||||||
|
if v := env("UOPI_SERVER_LOGIC_EDITORS"); v != "" {
|
||||||
|
cfg.Server.LogicEditors = strings.Fields(v)
|
||||||
|
}
|
||||||
if v := env("UOPI_EPICS_CA_ADDR_LIST"); v != "" {
|
if v := env("UOPI_EPICS_CA_ADDR_LIST"); v != "" {
|
||||||
cfg.Datasource.EPICS.CAAddrList = v
|
cfg.Datasource.EPICS.CAAddrList = v
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/access"
|
||||||
"github.com/uopi/uopi/internal/api"
|
"github.com/uopi/uopi/internal/api"
|
||||||
"github.com/uopi/uopi/internal/broker"
|
"github.com/uopi/uopi/internal/broker"
|
||||||
|
"github.com/uopi/uopi/internal/controllogic"
|
||||||
"github.com/uopi/uopi/internal/datasource/synthetic"
|
"github.com/uopi/uopi/internal/datasource/synthetic"
|
||||||
"github.com/uopi/uopi/internal/metrics"
|
"github.com/uopi/uopi/internal/metrics"
|
||||||
"github.com/uopi/uopi/internal/panelacl"
|
"github.com/uopi/uopi/internal/panelacl"
|
||||||
@@ -26,7 +27,7 @@ type Server struct {
|
|||||||
|
|
||||||
// New creates the HTTP server, registers all routes, and returns a ready-to-start Server.
|
// New creates the HTTP server, registers all routes, and returns a ready-to-start Server.
|
||||||
// synth may be nil if the synthetic data source is not enabled.
|
// synth may be nil if the synthetic data source is not enabled.
|
||||||
func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, policy *access.Policy, acl *panelacl.Store, channelFinderURL, archiverURL, trustedUserHeader string, log *slog.Logger) *Server {
|
func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, policy *access.Policy, acl *panelacl.Store, ctrlLogic *controllogic.Store, ctrlEngine *controllogic.Engine, channelFinderURL, archiverURL, trustedUserHeader string, log *slog.Logger) *Server {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
// Health check
|
// Health check
|
||||||
@@ -44,7 +45,7 @@ func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Syntheti
|
|||||||
// REST API — registered on a dedicated mux so it can be wrapped with the
|
// REST API — registered on a dedicated mux so it can be wrapped with the
|
||||||
// access-control middleware (identity resolution + global level enforcement).
|
// access-control middleware (identity resolution + global level enforcement).
|
||||||
apiMux := http.NewServeMux()
|
apiMux := http.NewServeMux()
|
||||||
api.New(brk, synth, store, policy, acl, channelFinderURL, archiverURL, log).Register(apiMux, apiPrefix)
|
api.New(brk, synth, store, policy, acl, ctrlLogic, ctrlEngine, channelFinderURL, archiverURL, log).Register(apiMux, apiPrefix)
|
||||||
mux.Handle(apiPrefix+"/", accessMiddleware(policy, trustedUserHeader, apiMux))
|
mux.Handle(apiPrefix+"/", accessMiddleware(policy, trustedUserHeader, apiMux))
|
||||||
|
|
||||||
// Embedded frontend — must be last (catch-all)
|
// Embedded frontend — must be last (catch-all)
|
||||||
|
|||||||
+7
-2
@@ -18,11 +18,17 @@ default_user = ""
|
|||||||
# user = "guest"
|
# user = "guest"
|
||||||
# level = "readonly"
|
# level = "readonly"
|
||||||
|
|
||||||
# Named sets of users, referenced by per-panel sharing rules (future phases).
|
# Named sets of users, referenced by per-panel sharing rules.
|
||||||
# [[groups]]
|
# [[groups]]
|
||||||
# name = "operators"
|
# name = "operators"
|
||||||
# members = ["alice", "bob"]
|
# members = ["alice", "bob"]
|
||||||
|
|
||||||
|
# Optional: restrict who may add or edit panel logic (the <logic> block of
|
||||||
|
# interfaces) AND server-side control logic. Entries are usernames or group
|
||||||
|
# names. Empty/unset = no restriction (any writer may edit logic). Also settable
|
||||||
|
# via UOPI_SERVER_LOGIC_EDITORS (space-separated).
|
||||||
|
# logic_editors = ["operators", "alice"]
|
||||||
|
|
||||||
[datasource.epics]
|
[datasource.epics]
|
||||||
enabled = true
|
enabled = true
|
||||||
ca_addr_list = "" # overrides EPICS_CA_ADDR_LIST if set
|
ca_addr_list = "" # overrides EPICS_CA_ADDR_LIST if set
|
||||||
@@ -32,4 +38,3 @@ auto_sync_filter = "" # e.g. area=StorageRing or tags=production
|
|||||||
|
|
||||||
[datasource.synthetic]
|
[datasource.synthetic]
|
||||||
enabled = true
|
enabled = true
|
||||||
definitions_file = "./synthetic.json"
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import LinkWidget from './widgets/LinkWidget';
|
|||||||
import ContextMenu from './ContextMenu';
|
import ContextMenu from './ContextMenu';
|
||||||
import InfoPanel from './InfoPanel';
|
import InfoPanel from './InfoPanel';
|
||||||
import SplitLayout from './SplitLayout';
|
import SplitLayout from './SplitLayout';
|
||||||
|
import LogicDialogs from './LogicDialogs';
|
||||||
|
|
||||||
const COMPONENTS: Record<string, any> = {
|
const COMPONENTS: Record<string, any> = {
|
||||||
textview: TextView,
|
textview: TextView,
|
||||||
@@ -119,6 +120,8 @@ export default function Canvas({ iface, onNavigate, timeRange }: Props) {
|
|||||||
{infoSignal && (
|
{infoSignal && (
|
||||||
<InfoPanel signal={infoSignal} onClose={() => setInfoSignal(null)} />
|
<InfoPanel signal={infoSignal} onClose={() => setInfoSignal(null)} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<LogicDialogs />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -159,6 +162,8 @@ export default function Canvas({ iface, onNavigate, timeRange }: Props) {
|
|||||||
{infoSignal && (
|
{infoSignal && (
|
||||||
<InfoPanel signal={infoSignal} onClose={() => setInfoSignal(null)} />
|
<InfoPanel signal={infoSignal} onClose={() => setInfoSignal(null)} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<LogicDialogs />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ const TIPS: Record<string, Array<{ text: string; section?: string }>> = {
|
|||||||
{ text: 'Right-click a widget to view signal info or export data as CSV.', section: 'view' },
|
{ text: 'Right-click a widget to view signal info or export data as CSV.', section: 'view' },
|
||||||
{ text: 'The ● Live button shows live streaming is active. Use the date pickers to load historical data.', section: 'history' },
|
{ text: 'The ● Live button shows live streaming is active. Use the date pickers to load historical data.', section: 'history' },
|
||||||
{ text: 'Click Edit (top-right) to open the drag-and-drop panel builder.', section: 'edit' },
|
{ text: 'Click Edit (top-right) to open the drag-and-drop panel builder.', section: 'edit' },
|
||||||
|
{ text: 'Use the Share button on a panel to grant access to users or groups.', section: 'sharing' },
|
||||||
|
{ text: 'The ⚙ Control logic button opens server-side automation that runs even with no panel open.', section: 'control' },
|
||||||
{ text: 'The dot in the status chip turns green when the WebSocket is connected.', section: 'view' },
|
{ text: 'The dot in the status chip turns green when the WebSocket is connected.', section: 'view' },
|
||||||
],
|
],
|
||||||
edit: [
|
edit: [
|
||||||
@@ -20,6 +22,8 @@ const TIPS: Record<string, Array<{ text: string; section?: string }>> = {
|
|||||||
{ text: 'Ctrl+click to select multiple widgets; then use the align toolbar.', section: 'edit' },
|
{ text: 'Ctrl+click to select multiple widgets; then use the align toolbar.', section: 'edit' },
|
||||||
{ text: 'Ctrl+Z to undo, Ctrl+Y to redo. Up to 50 steps are remembered.', section: 'shortcuts' },
|
{ text: 'Ctrl+Z to undo, Ctrl+Y to redo. Up to 50 steps are remembered.', section: 'shortcuts' },
|
||||||
{ text: 'Use "+ Synthetic" in the signal tree to create computed/filtered signals.', section: 'signals' },
|
{ text: 'Use "+ Synthetic" in the signal tree to create computed/filtered signals.', section: 'signals' },
|
||||||
|
{ text: 'Switch to the Logic tab to add interactive behaviour with a node-graph flow editor.', section: 'logic' },
|
||||||
|
{ text: 'Add Local variables for set-points and counters that live inside the panel.', section: 'edit' },
|
||||||
{ text: 'Arrow keys nudge the selected widget by 1 px (Shift for grid size).', section: 'shortcuts' },
|
{ text: 'Arrow keys nudge the selected widget by 1 px (Shift for grid size).', section: 'shortcuts' },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 '';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -88,7 +88,8 @@ const STATIC_WIDGET_TYPES = [
|
|||||||
|
|
||||||
export default function EditMode({ initial, onDone }: Props) {
|
export default function EditMode({ initial, onDone }: Props) {
|
||||||
const [iface, setIface] = useState<Interface>(initial ?? blankInterface());
|
const [iface, setIface] = useState<Interface>(initial ?? blankInterface());
|
||||||
const writable = canWrite(useAuth().level);
|
const me = useAuth();
|
||||||
|
const writable = canWrite(me.level);
|
||||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||||
const [dirty, setDirty] = useState(false);
|
const [dirty, setDirty] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -670,12 +671,14 @@ export default function EditMode({ initial, onDone }: Props) {
|
|||||||
class={`center-tab${centerTab === 'layout' ? ' center-tab-active' : ''}`}
|
class={`center-tab${centerTab === 'layout' ? ' center-tab-active' : ''}`}
|
||||||
onClick={() => setCenterTab('layout')}
|
onClick={() => setCenterTab('layout')}
|
||||||
>Layout</button>
|
>Layout</button>
|
||||||
|
{me.canEditLogic && (
|
||||||
<button
|
<button
|
||||||
class={`center-tab${centerTab === 'logic' ? ' center-tab-active' : ''}`}
|
class={`center-tab${centerTab === 'logic' ? ' center-tab-active' : ''}`}
|
||||||
onClick={() => setCenterTab('logic')}
|
onClick={() => setCenterTab('logic')}
|
||||||
>Logic{iface.logic && iface.logic.nodes.length > 0 ? ` (${iface.logic.nodes.length})` : ''}</button>
|
>Logic{iface.logic && iface.logic.nodes.length > 0 ? ` (${iface.logic.nodes.length})` : ''}</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{centerTab === 'logic' ? (
|
{centerTab === 'logic' && me.canEditLogic ? (
|
||||||
<LogicEditor
|
<LogicEditor
|
||||||
graph={iface.logic ?? { nodes: [], wires: [] }}
|
graph={iface.logic ?? { nodes: [], wires: [] }}
|
||||||
onChange={handleLogicChange}
|
onChange={handleLogicChange}
|
||||||
|
|||||||
+180
-6
@@ -406,7 +406,11 @@ const SECTIONS = [
|
|||||||
{ id: 'view', label: 'View Mode' },
|
{ id: 'view', label: 'View Mode' },
|
||||||
{ id: 'edit', label: 'Edit Mode' },
|
{ id: 'edit', label: 'Edit Mode' },
|
||||||
{ id: 'widgets', label: 'Widgets' },
|
{ id: 'widgets', label: 'Widgets' },
|
||||||
|
{ id: 'plots', label: 'Plot Panels' },
|
||||||
{ id: 'signals', label: 'Signals' },
|
{ id: 'signals', label: 'Signals' },
|
||||||
|
{ id: 'logic', label: 'Panel Logic' },
|
||||||
|
{ id: 'control', label: 'Control Logic' },
|
||||||
|
{ id: 'sharing', label: 'Sharing & Access' },
|
||||||
{ id: 'history', label: 'Historical Data' },
|
{ id: 'history', label: 'Historical Data' },
|
||||||
{ id: 'shortcuts', label: 'Keyboard Shortcuts' },
|
{ id: 'shortcuts', label: 'Keyboard Shortcuts' },
|
||||||
];
|
];
|
||||||
@@ -510,6 +514,24 @@ function SectionEdit() {
|
|||||||
<h3 class="help-h3">Align & distribute</h3>
|
<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>
|
<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>
|
<h3 class="help-h3">Saving</h3>
|
||||||
<ul class="help-list">
|
<ul class="help-list">
|
||||||
<li><strong>Save</strong> — stores the panel on the server (XML). A <span style="color:#f59e0b">●</span> indicates unsaved changes.</li>
|
<li><strong>Save</strong> — stores the panel on the server (XML). A <span style="color:#f59e0b">●</span> indicates unsaved changes.</li>
|
||||||
@@ -520,6 +542,141 @@ function SectionEdit() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SectionPlots() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p class="help-lead">
|
||||||
|
A <strong>plot panel</strong> is a special interface dedicated to charts. Instead of
|
||||||
|
free-form boxes, plots <em>fill the viewport</em> and you split the space between them like
|
||||||
|
panes in a tiling window manager (tmux / IDE style).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 class="help-h3">Creating one</h3>
|
||||||
|
<ul class="help-list">
|
||||||
|
<li>Click <strong>+ Plot</strong> in the interface list to create a plot panel — it opens
|
||||||
|
with one full-viewport empty plot.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 class="help-h3">Splitting & 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() {
|
function SectionWidgets() {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -602,7 +759,19 @@ function SectionSignals() {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h3 class="help-h3">Synthetic signals</h3>
|
<h3 class="help-h3">Synthetic signals</h3>
|
||||||
<p>Click <strong>+ Synthetic</strong> to open the wizard. Choose an input signal, a processing node (gain, moving average, lowpass filter, …), and optional metadata. The new signal appears in the tree immediately.</p>
|
<p>Click <strong>+ Synthetic</strong> to compose a new signal from existing ones. A quick
|
||||||
|
<em>wizard</em> covers the common case (pick an input + a processing node such as gain,
|
||||||
|
offset, moving average, lowpass/highpass, derivative, integral, clamp or a custom
|
||||||
|
formula). For multi-input pipelines, the <strong>node-graph editor</strong> lets you wire
|
||||||
|
inputs through a chain of DSP blocks visually. The new signal appears in the tree
|
||||||
|
immediately.</p>
|
||||||
|
<p>Each synthetic signal has a <strong>visibility scope</strong>: <em>panel</em> (only the
|
||||||
|
panel that created it), <em>user</em>, or <em>global</em> (shared with everyone).</p>
|
||||||
|
|
||||||
|
<h3 class="help-h3">Local variables</h3>
|
||||||
|
<p>The <strong>Local</strong> group holds panel-local variables — values stored in the
|
||||||
|
panel itself with no data source. Use them for set-points, toggles and counters driven by
|
||||||
|
panel logic. They are added in the editor and saved with the panel.</p>
|
||||||
</div>
|
</div>
|
||||||
<DiagramSignalTree />
|
<DiagramSignalTree />
|
||||||
</div>
|
</div>
|
||||||
@@ -676,14 +845,15 @@ function SectionShortcuts() {
|
|||||||
|
|
||||||
<h3 class="help-h3">API & Metrics</h3>
|
<h3 class="help-h3">API & Metrics</h3>
|
||||||
<ul class="help-list">
|
<ul class="help-list">
|
||||||
<li>REST API: <code>/api/v1/datasources</code>, <code>/api/v1/signals</code>, <code>/api/v1/interfaces</code>, …</li>
|
<li>Signals/data: <code>/api/v1/datasources</code>, <code>/api/v1/signals</code>, <code>/api/v1/synthetic</code></li>
|
||||||
<li>Prometheus metrics: <code>/metrics</code></li>
|
<li>Panels: <code>/api/v1/interfaces</code>, <code>/api/v1/folders</code>, <code>/api/v1/interfaces/{'{id}'}/acl</code></li>
|
||||||
<li>Health check: <code>/healthz</code></li>
|
<li>Identity & groups: <code>/api/v1/me</code>, <code>/api/v1/usergroups</code></li>
|
||||||
<li>WebSocket: <code>ws://<host>/ws</code></li>
|
<li>Control logic: <code>/api/v1/controllogic</code></li>
|
||||||
|
<li>Prometheus metrics: <code>/metrics</code> · Health check: <code>/healthz</code> · WebSocket: <code>ws://<host>/ws</code></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h3 class="help-h3">Configuration file</h3>
|
<h3 class="help-h3">Configuration file</h3>
|
||||||
<pre class="help-pre">[server]{'\n'}listen = ":8080"{'\n'}storage_dir = "./data"{'\n\n'}[datasource.epics]{'\n'}enabled = true{'\n'}archive_url = "http://archiver:17665"{'\n\n'}[datasource.synthetic]{'\n'}enabled = true</pre>
|
<pre class="help-pre">[server]{'\n'}listen = ":8080"{'\n'}storage_dir = "./interfaces"{'\n'}# optional: restrict who may edit panel/control logic{'\n'}# logic_editors = ["operators"]{'\n\n'}[datasource.epics]{'\n'}enabled = true{'\n'}archive_url = "http://archiver:17665"{'\n\n'}[datasource.synthetic]{'\n'}enabled = true</pre>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -693,7 +863,11 @@ const SECTION_COMPONENTS: Record<string, () => JSX.Element> = {
|
|||||||
view: SectionView,
|
view: SectionView,
|
||||||
edit: SectionEdit,
|
edit: SectionEdit,
|
||||||
widgets: SectionWidgets,
|
widgets: SectionWidgets,
|
||||||
|
plots: SectionPlots,
|
||||||
signals: SectionSignals,
|
signals: SectionSignals,
|
||||||
|
logic: SectionLogic,
|
||||||
|
control: SectionControl,
|
||||||
|
sharing: SectionSharing,
|
||||||
history: SectionHistory,
|
history: SectionHistory,
|
||||||
shortcuts: SectionShortcuts,
|
shortcuts: SectionShortcuts,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,10 +21,60 @@ export default function InterfaceList({ onLoad, onSelect, onEdit, onNewPlot, onE
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [share, setShare] = useState<{ id: string; name: string } | null>(null);
|
const [share, setShare] = useState<{ id: string; name: string } | null>(null);
|
||||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
||||||
|
const [dropTarget, setDropTarget] = useState<string | null>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const dragId = useRef<string | null>(null);
|
||||||
const me = useAuth();
|
const me = useAuth();
|
||||||
const writable = canWrite(me.level);
|
const writable = canWrite(me.level);
|
||||||
|
|
||||||
|
// Panels in a folder ('' = root), sorted by their stored order then name.
|
||||||
|
function panelsIn(folder: string): InterfaceListItem[] {
|
||||||
|
return interfaces
|
||||||
|
.filter(i => (i.folder || '') === folder)
|
||||||
|
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0) || (a.name || a.id).localeCompare(b.name || b.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist a new ordering for a folder's panels (also moves panels between folders).
|
||||||
|
async function reorder(folder: string, ids: string[]) {
|
||||||
|
const res = await fetch('/api/v1/interfaces/reorder', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ folder, ids }),
|
||||||
|
});
|
||||||
|
if (res.ok) refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
function endDrag() {
|
||||||
|
dragId.current = null;
|
||||||
|
setDropTarget(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop dragged panel just before `target` within target's folder.
|
||||||
|
function dropOnPanel(target: InterfaceListItem, e: DragEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const id = dragId.current;
|
||||||
|
if (!id || id === target.id) { endDrag(); return; }
|
||||||
|
const dest = target.folder || '';
|
||||||
|
const ids = panelsIn(dest).map(p => p.id).filter(pid => pid !== id);
|
||||||
|
const idx = ids.indexOf(target.id);
|
||||||
|
ids.splice(idx < 0 ? ids.length : idx, 0, id);
|
||||||
|
reorder(dest, ids);
|
||||||
|
endDrag();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop dragged panel at the end of `folder` ('' = root).
|
||||||
|
function dropInFolder(folder: string, e: DragEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const id = dragId.current;
|
||||||
|
if (!id) { endDrag(); return; }
|
||||||
|
const ids = panelsIn(folder).map(p => p.id).filter(pid => pid !== id);
|
||||||
|
ids.push(id);
|
||||||
|
reorder(folder, ids);
|
||||||
|
endDrag();
|
||||||
|
}
|
||||||
|
|
||||||
function refresh() {
|
function refresh() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
Promise.all([
|
Promise.all([
|
||||||
@@ -39,6 +89,7 @@ export default function InterfaceList({ onLoad, onSelect, onEdit, onNewPlot, onE
|
|||||||
version: Number(item.version || item.Version || 0),
|
version: Number(item.version || item.Version || 0),
|
||||||
owner: item.owner ? String(item.owner) : '',
|
owner: item.owner ? String(item.owner) : '',
|
||||||
folder: item.folder ? String(item.folder) : '',
|
folder: item.folder ? String(item.folder) : '',
|
||||||
|
order: Number(item.order || 0),
|
||||||
perm: (item.perm as InterfaceListItem['perm']) || 'write',
|
perm: (item.perm as InterfaceListItem['perm']) || 'write',
|
||||||
})).filter((item: InterfaceListItem) => item.id);
|
})).filter((item: InterfaceListItem) => item.id);
|
||||||
setInterfaces(normalized);
|
setInterfaces(normalized);
|
||||||
@@ -116,8 +167,18 @@ export default function InterfaceList({ onLoad, onSelect, onEdit, onNewPlot, onE
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderPanel(item: InterfaceListItem) {
|
function renderPanel(item: InterfaceListItem) {
|
||||||
|
const drag = writable && item.perm === 'write';
|
||||||
return (
|
return (
|
||||||
<li key={item.id} class="iface-item">
|
<li
|
||||||
|
key={item.id}
|
||||||
|
class={`iface-item${dropTarget === `p:${item.id}` ? ' drop-target' : ''}`}
|
||||||
|
draggable={drag}
|
||||||
|
onDragStart={drag ? (e) => { dragId.current = item.id; e.dataTransfer!.effectAllowed = 'move'; } : undefined}
|
||||||
|
onDragEnd={endDrag}
|
||||||
|
onDragOver={(e) => { if (dragId.current) { e.preventDefault(); e.stopPropagation(); setDropTarget(`p:${item.id}`); } }}
|
||||||
|
onDragLeave={() => setDropTarget(t => t === `p:${item.id}` ? null : t)}
|
||||||
|
onDrop={(e) => dropOnPanel(item, e)}
|
||||||
|
>
|
||||||
<span class="iface-item-name" onClick={() => onSelect?.(item.id)} title={item.owner ? `Owner: ${item.owner}` : undefined}>
|
<span class="iface-item-name" onClick={() => onSelect?.(item.id)} title={item.owner ? `Owner: ${item.owner}` : undefined}>
|
||||||
{item.name || item.id}
|
{item.name || item.id}
|
||||||
{item.perm === 'read' && <span class="iface-badge" title="Read-only">ro</span>}
|
{item.perm === 'read' && <span class="iface-badge" title="Read-only">ro</span>}
|
||||||
@@ -136,11 +197,17 @@ export default function InterfaceList({ onLoad, onSelect, onEdit, onNewPlot, onE
|
|||||||
// Render a folder and everything beneath it (subfolders first, then panels).
|
// Render a folder and everything beneath it (subfolders first, then panels).
|
||||||
function renderFolder(folder: Folder) {
|
function renderFolder(folder: Folder) {
|
||||||
const children = folders.filter(f => f.parent === folder.id);
|
const children = folders.filter(f => f.parent === folder.id);
|
||||||
const panels = interfaces.filter(i => i.folder === folder.id);
|
const panels = panelsIn(folder.id);
|
||||||
const open = expanded[folder.id] !== false; // default expanded
|
const open = expanded[folder.id] !== false; // default expanded
|
||||||
|
const canDrop = folder.perm === 'write';
|
||||||
return (
|
return (
|
||||||
<li key={folder.id} class="iface-folder">
|
<li key={folder.id} class="iface-folder">
|
||||||
<div class="iface-folder-header">
|
<div
|
||||||
|
class={`iface-folder-header${dropTarget === `f:${folder.id}` ? ' drop-target' : ''}`}
|
||||||
|
onDragOver={canDrop ? (e) => { if (dragId.current) { e.preventDefault(); e.stopPropagation(); setDropTarget(`f:${folder.id}`); } } : undefined}
|
||||||
|
onDragLeave={() => setDropTarget(t => t === `f:${folder.id}` ? null : t)}
|
||||||
|
onDrop={canDrop ? (e) => dropInFolder(folder.id, e) : undefined}
|
||||||
|
>
|
||||||
<span class="iface-folder-name" onClick={() => toggle(folder.id)}>
|
<span class="iface-folder-name" onClick={() => toggle(folder.id)}>
|
||||||
{open ? '▾' : '▸'} {folder.name}
|
{open ? '▾' : '▸'} {folder.name}
|
||||||
</span>
|
</span>
|
||||||
@@ -162,7 +229,7 @@ export default function InterfaceList({ onLoad, onSelect, onEdit, onNewPlot, onE
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rootFolders = folders.filter(f => !f.parent);
|
const rootFolders = folders.filter(f => !f.parent);
|
||||||
const rootPanels = interfaces.filter(i => !i.folder);
|
const rootPanels = panelsIn('');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside class={`panel${collapsed ? ' collapsed' : ''}`} style={!collapsed && width ? `width:${width}px;min-width:${width}px;` : undefined}>
|
<aside class={`panel${collapsed ? ' collapsed' : ''}`} style={!collapsed && width ? `width:${width}px;min-width:${width}px;` : undefined}>
|
||||||
@@ -201,7 +268,12 @@ export default function InterfaceList({ onLoad, onSelect, onEdit, onNewPlot, onE
|
|||||||
) : interfaces.length === 0 && folders.length === 0 ? (
|
) : interfaces.length === 0 && folders.length === 0 ? (
|
||||||
<p class="hint">No interfaces yet. Create one or import XML.</p>
|
<p class="hint">No interfaces yet. Create one or import XML.</p>
|
||||||
) : (
|
) : (
|
||||||
<ul class="iface-list">
|
<ul
|
||||||
|
class={`iface-list${dropTarget === 'f:' ? ' drop-target' : ''}`}
|
||||||
|
onDragOver={(e) => { if (dragId.current) { e.preventDefault(); setDropTarget('f:'); } }}
|
||||||
|
onDragLeave={() => setDropTarget(t => t === 'f:' ? null : t)}
|
||||||
|
onDrop={(e) => dropInFolder('', e)}
|
||||||
|
>
|
||||||
{rootFolders.map(renderFolder)}
|
{rootFolders.map(renderFolder)}
|
||||||
{rootPanels.map(renderPanel)}
|
{rootPanels.map(renderPanel)}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
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 [val, setVal] = useState(req.defaultValue ?? '');
|
||||||
|
const isSetpoint = req.kind === 'setpoint';
|
||||||
|
|
||||||
|
function ok() { req.resolve(isSetpoint ? val : ''); }
|
||||||
|
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>}
|
||||||
|
{isSetpoint && (
|
||||||
|
<input class="prop-input logic-dialog-input" type="number" autoFocus
|
||||||
|
value={val}
|
||||||
|
onInput={(e) => setVal((e.target as HTMLInputElement).value)}
|
||||||
|
onKeyDown={onKey} />
|
||||||
|
)}
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+79
-1
@@ -48,6 +48,8 @@ const PALETTE: PaletteEntry[] = [
|
|||||||
{ kind: 'trigger.change', label: 'On change', params: { signal: '' } },
|
{ kind: 'trigger.change', label: 'On change', params: { signal: '' } },
|
||||||
{ kind: 'trigger.timer', label: 'Timer', params: { interval: '1000' } },
|
{ kind: 'trigger.timer', label: 'Timer', params: { interval: '1000' } },
|
||||||
{ kind: 'trigger.loop', label: 'Panel loop', params: { interval: '1000' } },
|
{ kind: 'trigger.loop', label: 'Panel loop', params: { interval: '1000' } },
|
||||||
|
{ kind: 'trigger.start', label: 'On open', params: {} },
|
||||||
|
{ kind: 'trigger.stop', label: 'On close', params: {} },
|
||||||
{ kind: 'gate.and', label: 'AND gate', params: {} },
|
{ kind: 'gate.and', label: 'AND gate', params: {} },
|
||||||
{ kind: 'flow.if', label: 'If / else', params: { cond: '' } },
|
{ kind: 'flow.if', label: 'If / else', params: { cond: '' } },
|
||||||
{ kind: 'flow.loop', label: 'Loop', params: { mode: 'count', count: '3', cond: '' } },
|
{ kind: 'flow.loop', label: 'Loop', params: { mode: 'count', count: '3', cond: '' } },
|
||||||
@@ -57,6 +59,9 @@ const PALETTE: PaletteEntry[] = [
|
|||||||
{ kind: 'action.export', label: 'Export CSV', params: { array: 'data', filename: '' } },
|
{ kind: 'action.export', label: 'Export CSV', params: { array: 'data', filename: '' } },
|
||||||
{ kind: 'action.clear', label: 'Clear array', params: { array: 'data' } },
|
{ kind: 'action.clear', label: 'Clear array', params: { array: 'data' } },
|
||||||
{ kind: 'action.log', label: 'Log', params: { expr: '', label: '' } },
|
{ kind: 'action.log', label: 'Log', params: { expr: '', label: '' } },
|
||||||
|
{ kind: 'action.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: { target: '', title: 'Set value', message: '', default: '' } },
|
||||||
];
|
];
|
||||||
|
|
||||||
const KIND_LABEL: Record<LogicNodeKind, string> = {
|
const KIND_LABEL: Record<LogicNodeKind, string> = {
|
||||||
@@ -65,6 +70,8 @@ const KIND_LABEL: Record<LogicNodeKind, string> = {
|
|||||||
'trigger.change': 'On change',
|
'trigger.change': 'On change',
|
||||||
'trigger.timer': 'Timer',
|
'trigger.timer': 'Timer',
|
||||||
'trigger.loop': 'Panel loop',
|
'trigger.loop': 'Panel loop',
|
||||||
|
'trigger.start': 'On open',
|
||||||
|
'trigger.stop': 'On close',
|
||||||
'gate.and': 'AND gate',
|
'gate.and': 'AND gate',
|
||||||
'flow.if': 'If / else',
|
'flow.if': 'If / else',
|
||||||
'flow.loop': 'Loop',
|
'flow.loop': 'Loop',
|
||||||
@@ -74,6 +81,9 @@ const KIND_LABEL: Record<LogicNodeKind, string> = {
|
|||||||
'action.export': 'Export CSV',
|
'action.export': 'Export CSV',
|
||||||
'action.clear': 'Clear array',
|
'action.clear': 'Clear array',
|
||||||
'action.log': 'Log',
|
'action.log': 'Log',
|
||||||
|
'action.dialog.info': 'Info dialog',
|
||||||
|
'action.dialog.error': 'Error dialog',
|
||||||
|
'action.dialog.setpoint': 'Set-point dialog',
|
||||||
};
|
};
|
||||||
|
|
||||||
const THRESHOLD_OPS = ['>', '<', '>=', '<=', '==', '!='] as const;
|
const THRESHOLD_OPS = ['>', '<', '>=', '<=', '==', '!='] as const;
|
||||||
@@ -172,7 +182,9 @@ export default function LogicEditor({ graph, onChange, statevars, onStateVarsCha
|
|||||||
if (selected?.kind === 'trigger.threshold' || selected?.kind === 'trigger.change') {
|
if (selected?.kind === 'trigger.threshold' || selected?.kind === 'trigger.change') {
|
||||||
loadSignals(splitRef(selected.params.signal ?? '').ds);
|
loadSignals(splitRef(selected.params.signal ?? '').ds);
|
||||||
}
|
}
|
||||||
if (selected?.kind === 'action.write') loadSignals(splitRef(selected.params.target ?? '').ds);
|
if (selected?.kind === 'action.write' || selected?.kind === 'action.dialog.setpoint') {
|
||||||
|
loadSignals(splitRef(selected.params.target ?? '').ds);
|
||||||
|
}
|
||||||
}, [selectedNode, selected?.kind]);
|
}, [selectedNode, selected?.kind]);
|
||||||
|
|
||||||
// ── History ────────────────────────────────────────────────────────────────
|
// ── History ────────────────────────────────────────────────────────────────
|
||||||
@@ -675,6 +687,67 @@ export default function LogicEditor({ graph, onChange, statevars, onStateVarsCha
|
|||||||
</Fragment>
|
</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>
|
||||||
|
</Fragment>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selected.kind === 'action.dialog.setpoint' && (() => {
|
||||||
|
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>
|
||||||
|
<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 input. Embed values as {ds:name}."
|
||||||
|
onInput={(e) => patchParams(selected.id, { message: (e.target as HTMLTextAreaElement).value })} />
|
||||||
|
</div>
|
||||||
|
<ExprField label="Default value (expression)" value={selected.params.default ?? ''}
|
||||||
|
onChange={(v) => patchParams(selected.id, { default: v })}
|
||||||
|
onInsert={(ds2, sig) => insertRef(selected.id, 'default', ds2, sig)}
|
||||||
|
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals}
|
||||||
|
hint="Pre-fills the input (e.g. the current set-point {local:sp}). The operator can edit it; OK writes the entered number to the target, Cancel aborts the flow." />
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
<button class="panel-btn" style="margin-top:1rem;" onClick={() => deleteNode(selected.id)}>Delete node</button>
|
<button class="panel-btn" style="margin-top:1rem;" onClick={() => deleteNode(selected.id)}>Delete node</button>
|
||||||
</Fragment>
|
</Fragment>
|
||||||
)}
|
)}
|
||||||
@@ -736,6 +809,8 @@ function nodeSummary(n: LogicNode): string {
|
|||||||
case 'trigger.change': return `Δ ${n.params.signal || '?'}`;
|
case 'trigger.change': return `Δ ${n.params.signal || '?'}`;
|
||||||
case 'trigger.timer': return `every ${n.params.interval || '?'} ms`;
|
case 'trigger.timer': return `every ${n.params.interval || '?'} ms`;
|
||||||
case 'trigger.loop': return `load + every ${n.params.interval || '?'} ms`;
|
case 'trigger.loop': return `load + every ${n.params.interval || '?'} ms`;
|
||||||
|
case 'trigger.start': return 'on panel open';
|
||||||
|
case 'trigger.stop': return 'on panel close';
|
||||||
case 'gate.and': return 'all inputs';
|
case 'gate.and': return 'all inputs';
|
||||||
case 'flow.if': return n.params.cond || '(no condition)';
|
case 'flow.if': return n.params.cond || '(no condition)';
|
||||||
case 'flow.loop': return (n.params.mode ?? 'count') === 'count'
|
case 'flow.loop': return (n.params.mode ?? 'count') === 'count'
|
||||||
@@ -747,6 +822,9 @@ function nodeSummary(n: LogicNode): string {
|
|||||||
case 'action.export': return `export ${n.params.array || '?'} → csv`;
|
case 'action.export': return `export ${n.params.array || '?'} → csv`;
|
||||||
case 'action.clear': return `clear ${n.params.array || '?'}`;
|
case 'action.clear': return `clear ${n.params.array || '?'}`;
|
||||||
case 'action.log': return `log ${n.params.label ? n.params.label + ': ' : ''}${n.params.expr || ''}`;
|
case 'action.log': return `log ${n.params.label ? n.params.label + ': ' : ''}${n.params.expr || ''}`;
|
||||||
|
case 'action.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': return `set ${n.params.target || '?'} (ask)`;
|
||||||
default: return '';
|
default: return '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { h, Fragment } from 'preact';
|
|||||||
import { useState, useEffect, useRef, useMemo } from 'preact/hooks';
|
import { useState, useEffect, useRef, useMemo } from 'preact/hooks';
|
||||||
import type { SignalRef, StateVar } from './lib/types';
|
import type { SignalRef, StateVar } from './lib/types';
|
||||||
import SyntheticWizard from './SyntheticWizard';
|
import SyntheticWizard from './SyntheticWizard';
|
||||||
import SyntheticEditor from './SyntheticEditor';
|
import SyntheticGraphEditor from './SyntheticGraphEditor';
|
||||||
import GroupsTree from './GroupsTree';
|
import GroupsTree from './GroupsTree';
|
||||||
import ChannelFinderModal from './ChannelFinderModal';
|
import ChannelFinderModal from './ChannelFinderModal';
|
||||||
|
|
||||||
@@ -499,7 +499,7 @@ export default function SignalTree({ onDragStart, width, panelId, statevars, onS
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{editSynthetic && (
|
{editSynthetic && (
|
||||||
<SyntheticEditor
|
<SyntheticGraphEditor
|
||||||
name={editSynthetic}
|
name={editSynthetic}
|
||||||
onClose={() => setEditSynthetic(null)}
|
onClose={() => setEditSynthetic(null)}
|
||||||
onSaved={loadAllSignals}
|
onSaved={loadAllSignals}
|
||||||
|
|||||||
@@ -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 ContextualHelp from './ContextualHelp';
|
||||||
import HelpModal from './HelpModal';
|
import HelpModal from './HelpModal';
|
||||||
import ZoomControl from './ZoomControl';
|
import ZoomControl from './ZoomControl';
|
||||||
|
import ControlLogicEditor from './ControlLogicEditor';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onEdit?: (iface?: Interface) => void;
|
onEdit?: (iface?: Interface) => void;
|
||||||
@@ -31,6 +32,7 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) {
|
|||||||
const [showHelp, setShowHelp] = useState(false);
|
const [showHelp, setShowHelp] = useState(false);
|
||||||
const [helpSection, setHelpSection] = useState('start');
|
const [helpSection, setHelpSection] = useState('start');
|
||||||
const [showTimeNav, setShowTimeNav] = useState(false);
|
const [showTimeNav, setShowTimeNav] = useState(false);
|
||||||
|
const [showControlLogic, setShowControlLogic] = useState(false);
|
||||||
const [leftW, setLeftW] = useState(220);
|
const [leftW, setLeftW] = useState(220);
|
||||||
const [listCollapsed, setListCollapsed] = useState(false);
|
const [listCollapsed, setListCollapsed] = useState(false);
|
||||||
const me = useAuth();
|
const me = useAuth();
|
||||||
@@ -155,6 +157,15 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) {
|
|||||||
📖
|
📖
|
||||||
</button>
|
</button>
|
||||||
<ZoomControl />
|
<ZoomControl />
|
||||||
|
{writable && me.canEditLogic && (
|
||||||
|
<button
|
||||||
|
class="toolbar-btn"
|
||||||
|
onClick={() => setShowControlLogic(true)}
|
||||||
|
title="Open server-side control logic"
|
||||||
|
>
|
||||||
|
⚙ Control logic
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{writable && (
|
{writable && (
|
||||||
<button
|
<button
|
||||||
class="btn-edit"
|
class="btn-edit"
|
||||||
@@ -242,6 +253,10 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) {
|
|||||||
{showHelp && (
|
{showHelp && (
|
||||||
<HelpModal initialSection={helpSection} onClose={() => setShowHelp(false)} />
|
<HelpModal initialSection={helpSection} onClose={() => setShowHelp(false)} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showControlLogic && (
|
||||||
|
<ControlLogicEditor onClose={() => setShowControlLogic(false)} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -4,7 +4,7 @@ import type { Me, AccessLevel } from './types';
|
|||||||
|
|
||||||
// Default identity used before /api/v1/me resolves (or if it fails): assume a
|
// Default identity used before /api/v1/me resolves (or if it fails): assume a
|
||||||
// trusted LAN user with full access, matching the backend default.
|
// trusted LAN user with full access, matching the backend default.
|
||||||
export const DEFAULT_ME: Me = { user: '', level: 'write', groups: [] };
|
export const DEFAULT_ME: Me = { user: '', level: 'write', groups: [], canEditLogic: true };
|
||||||
|
|
||||||
// AuthContext carries the resolved identity + global access level for the
|
// AuthContext carries the resolved identity + global access level for the
|
||||||
// current user throughout the app.
|
// current user throughout the app.
|
||||||
@@ -36,6 +36,7 @@ export async function fetchMe(): Promise<Me> {
|
|||||||
user: typeof data.user === 'string' ? data.user : '',
|
user: typeof data.user === 'string' ? data.user : '',
|
||||||
level: (data.level as AccessLevel) ?? 'write',
|
level: (data.level as AccessLevel) ?? 'write',
|
||||||
groups: Array.isArray(data.groups) ? data.groups : [],
|
groups: Array.isArray(data.groups) ? data.groups : [],
|
||||||
|
canEditLogic: data.canEditLogic !== false,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return DEFAULT_ME;
|
return DEFAULT_ME;
|
||||||
|
|||||||
@@ -26,9 +26,26 @@
|
|||||||
|
|
||||||
import { wsClient } from './ws';
|
import { wsClient } from './ws';
|
||||||
import { getSignalStore } from './stores';
|
import { getSignalStore } from './stores';
|
||||||
|
import { writable } from './store';
|
||||||
import type { SignalRef, SignalValue, LogicGraph, LogicNode } from './types';
|
import type { SignalRef, SignalValue, LogicGraph, LogicNode } from './types';
|
||||||
import { evalExpr, evalBool, collectRefs, type Resolver } from './expr';
|
import { evalExpr, evalBool, collectRefs, type Resolver } from './expr';
|
||||||
|
|
||||||
|
// A 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` (the entered value for
|
||||||
|
// a set-point dialog, '' for info/error OK, or null for Cancel).
|
||||||
|
export interface DialogRequest {
|
||||||
|
id: string;
|
||||||
|
kind: 'info' | 'error' | 'setpoint';
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
defaultValue?: string;
|
||||||
|
resolve: (result: string | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Active dialogs, rendered by <LogicDialogs/>. Cleared when the panel unmounts.
|
||||||
|
export const logicDialogs = writable<DialogRequest[]>([]);
|
||||||
|
|
||||||
// Guards against runaway flows (cycles / pathological loops).
|
// Guards against runaway flows (cycles / pathological loops).
|
||||||
const MAX_STEPS = 100000;
|
const MAX_STEPS = 100000;
|
||||||
const MAX_LOOP = 100000;
|
const MAX_LOOP = 100000;
|
||||||
@@ -132,10 +149,26 @@ class LogicEngine {
|
|||||||
// button/threshold/change are driven imperatively / by subscriptions.
|
// button/threshold/change are driven imperatively / by subscriptions.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Panel-open lifecycle: fire every "On open" trigger once now that the
|
||||||
|
// graph is wired and subscriptions are live.
|
||||||
|
for (const node of this.graph.nodes) {
|
||||||
|
if (node.kind === 'trigger.start') this.activate(node.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Deactivate all triggers and subscriptions. Called when a panel unmounts. */
|
/** Deactivate all triggers and subscriptions. Called when a panel unmounts. */
|
||||||
clear(): void {
|
clear(): void {
|
||||||
|
// Panel-close lifecycle: fire every "On close" trigger before tearing down,
|
||||||
|
// while the live cache and wiring are still intact (synchronous writes run
|
||||||
|
// immediately; anything past an await is truncated as the graph resets).
|
||||||
|
for (const node of this.graph.nodes) {
|
||||||
|
if (node.kind === 'trigger.stop') this.activate(node.id);
|
||||||
|
}
|
||||||
|
// Dismiss any dialogs left open by the panel being closed (resolve null).
|
||||||
|
for (const d of logicDialogs.get()) { try { d.resolve(null); } catch {} }
|
||||||
|
logicDialogs.set([]);
|
||||||
|
|
||||||
for (const c of this.cleanups) { try { c(); } catch {} }
|
for (const c of this.cleanups) { try { c(); } catch {} }
|
||||||
this.cleanups = [];
|
this.cleanups = [];
|
||||||
this.graph = { nodes: [], wires: [] };
|
this.graph = { nodes: [], wires: [] };
|
||||||
@@ -188,6 +221,14 @@ class LogicEngine {
|
|||||||
case 'action.write':
|
case 'action.write':
|
||||||
case 'action.accumulate':
|
case 'action.accumulate':
|
||||||
case 'action.log': collectRefs(node.params.expr ?? '').forEach(want); break;
|
case 'action.log': collectRefs(node.params.expr ?? '').forEach(want); break;
|
||||||
|
case 'action.dialog.info':
|
||||||
|
case 'action.dialog.error':
|
||||||
|
this.messageRefs(node.params.message ?? '').forEach(want); break;
|
||||||
|
case 'action.dialog.setpoint':
|
||||||
|
this.messageRefs(node.params.message ?? '').forEach(want);
|
||||||
|
this.messageRefs(node.params.title ?? '').forEach(want);
|
||||||
|
collectRefs(node.params.default ?? '').forEach(want);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,6 +379,36 @@ class LogicEngine {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'action.dialog.info':
|
||||||
|
case 'action.dialog.error': {
|
||||||
|
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),
|
||||||
|
});
|
||||||
|
await this.follow(node.id, 'out', ctx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'action.dialog.setpoint': {
|
||||||
|
const ref = parseRef(node.params.target ?? '');
|
||||||
|
const def = (node.params.default ?? '').trim()
|
||||||
|
? String(evalExpr(node.params.default ?? '', ctx.resolve))
|
||||||
|
: '';
|
||||||
|
const result = await this.showDialog({
|
||||||
|
kind: 'setpoint',
|
||||||
|
title: this.interpolate(node.params.title ?? '', ctx.resolve),
|
||||||
|
message: this.interpolate(node.params.message ?? '', ctx.resolve),
|
||||||
|
defaultValue: def,
|
||||||
|
});
|
||||||
|
// Cancel (null) aborts the flow; OK writes the value and continues.
|
||||||
|
if (result === null) return;
|
||||||
|
const num = parseFloat(result);
|
||||||
|
if (ref && !isNaN(num)) wsClient.write(ref, num);
|
||||||
|
await this.follow(node.id, 'out', ctx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// A trigger reached mid-walk (unusual): just pass through.
|
// A trigger reached mid-walk (unusual): just pass through.
|
||||||
await this.follow(node.id, 'out', ctx);
|
await this.follow(node.id, 'out', ctx);
|
||||||
@@ -361,6 +432,40 @@ class LogicEngine {
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Dialogs (user interaction) ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Push a dialog request to the shared store and resolve when the user responds
|
||||||
|
// ('' on info/error OK, the entered text on a set-point OK, null on Cancel).
|
||||||
|
private showDialog(req: Omit<DialogRequest, 'id' | 'resolve'>): Promise<string | null> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const id = 'dlg_' + Math.random().toString(36).slice(2, 9);
|
||||||
|
const full: DialogRequest = {
|
||||||
|
...req,
|
||||||
|
id,
|
||||||
|
resolve: (r) => { logicDialogs.update(list => list.filter(d => d.id !== id)); resolve(r); },
|
||||||
|
};
|
||||||
|
logicDialogs.update(list => [...list, full]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Substitute {ds:name} (and {sys:*}) tokens in dialog text with live values.
|
||||||
|
private interpolate(text: string, resolve: Resolver): string {
|
||||||
|
return (text ?? '').replace(/\{([^}]*)\}/g, (_m, inner) => {
|
||||||
|
const v = evalExpr('{' + inner + '}', resolve);
|
||||||
|
return isNaN(v) ? '?' : String(v);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signals referenced by {…} tokens in dialog message/title text.
|
||||||
|
private messageRefs(text: string): SignalRef[] {
|
||||||
|
const out: SignalRef[] = [];
|
||||||
|
for (const m of (text ?? '').matchAll(/\{([^}]*)\}/g)) {
|
||||||
|
const r = parseRef(m[1]);
|
||||||
|
if (r) out.push(r);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
// An AND-gate fires when every incoming trigger is currently satisfied: the
|
// An AND-gate fires when every incoming trigger is currently satisfied: the
|
||||||
// activating trigger counts as satisfied, and level triggers (threshold) use
|
// activating trigger counts as satisfied, and level triggers (threshold) use
|
||||||
// their current truth. Momentary triggers that are not firing now are false.
|
// their current truth. Momentary triggers that are not firing now are false.
|
||||||
|
|||||||
+20
-1
@@ -84,6 +84,8 @@ export interface StateVar {
|
|||||||
// (params: interval).
|
// (params: interval).
|
||||||
// trigger.change — fired whenever a signal's value changes
|
// trigger.change — fired whenever a signal's value changes
|
||||||
// (params: signal).
|
// (params: signal).
|
||||||
|
// trigger.start — fired once when the panel is opened (no params).
|
||||||
|
// trigger.stop — fired once when the panel is closed (no params).
|
||||||
//
|
//
|
||||||
// Gates combine/guard flow:
|
// Gates combine/guard flow:
|
||||||
// gate.and — passes to its output only when ALL incoming triggers
|
// gate.and — passes to its output only when ALL incoming triggers
|
||||||
@@ -106,6 +108,14 @@ export interface StateVar {
|
|||||||
// action.clear — empty the named array (params: array).
|
// action.clear — empty the named array (params: array).
|
||||||
// action.log — evaluate `expr` and log it to the browser console for
|
// action.log — evaluate `expr` and log it to the browser console for
|
||||||
// debugging a flow (params: expr, label).
|
// debugging a flow (params: expr, label).
|
||||||
|
// action.dialog.info — show an info dialog and wait for the user to dismiss
|
||||||
|
// it before continuing (params: title, message).
|
||||||
|
// action.dialog.error — like dialog.info but styled as an error (params:
|
||||||
|
// title, message).
|
||||||
|
// action.dialog.setpoint — prompt the user for a value; on OK write it to
|
||||||
|
// `target` and continue, on Cancel abort the flow
|
||||||
|
// (params: target, title, message, default).
|
||||||
|
// Dialog `message`/`title` text may embed signal values as {ds:name} tokens.
|
||||||
//
|
//
|
||||||
// Expressions (cond/expr) may reference signals inline as {ds:name} and panel-
|
// Expressions (cond/expr) may reference signals inline as {ds:name} and panel-
|
||||||
// local state variables as bare identifiers. See lib/expr.ts.
|
// local state variables as bare identifiers. See lib/expr.ts.
|
||||||
@@ -115,6 +125,8 @@ export type LogicNodeKind =
|
|||||||
| 'trigger.timer'
|
| 'trigger.timer'
|
||||||
| 'trigger.loop'
|
| 'trigger.loop'
|
||||||
| 'trigger.change'
|
| 'trigger.change'
|
||||||
|
| 'trigger.start'
|
||||||
|
| 'trigger.stop'
|
||||||
| 'gate.and'
|
| 'gate.and'
|
||||||
| 'flow.if'
|
| 'flow.if'
|
||||||
| 'flow.loop'
|
| 'flow.loop'
|
||||||
@@ -123,7 +135,10 @@ export type LogicNodeKind =
|
|||||||
| 'action.accumulate'
|
| 'action.accumulate'
|
||||||
| 'action.export'
|
| 'action.export'
|
||||||
| 'action.clear'
|
| 'action.clear'
|
||||||
| 'action.log';
|
| 'action.log'
|
||||||
|
| 'action.dialog.info'
|
||||||
|
| 'action.dialog.error'
|
||||||
|
| 'action.dialog.setpoint';
|
||||||
|
|
||||||
// A block on the flow canvas. `params` holds kind-specific fields as strings.
|
// A block on the flow canvas. `params` holds kind-specific fields as strings.
|
||||||
export interface LogicNode {
|
export interface LogicNode {
|
||||||
@@ -180,6 +195,9 @@ export interface Me {
|
|||||||
user: string;
|
user: string;
|
||||||
level: AccessLevel;
|
level: AccessLevel;
|
||||||
groups: string[];
|
groups: string[];
|
||||||
|
// Whether the user may add/edit panel logic and server-side control logic.
|
||||||
|
// False only when a logic-editors allowlist is configured and excludes them.
|
||||||
|
canEditLogic: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Effective permission a user holds on a single panel or folder.
|
// Effective permission a user holds on a single panel or folder.
|
||||||
@@ -224,6 +242,7 @@ export interface InterfaceListItem {
|
|||||||
version: number;
|
version: number;
|
||||||
owner?: string;
|
owner?: string;
|
||||||
folder?: string;
|
folder?: string;
|
||||||
|
order?: number;
|
||||||
perm: Perm;
|
perm: Perm;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1923,6 +1923,22 @@ body {
|
|||||||
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Synthetic-signal node editor: a large modal hosting the shared .flow-editor
|
||||||
|
layout (palette | canvas | inspector). */
|
||||||
|
.synth-graph {
|
||||||
|
background: #1e2535;
|
||||||
|
border: 1px solid #2d3748;
|
||||||
|
border-radius: 8px;
|
||||||
|
width: 92vw;
|
||||||
|
height: 88vh;
|
||||||
|
max-width: 95vw;
|
||||||
|
max-height: 92vh;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
||||||
|
}
|
||||||
|
|
||||||
.wizard-header {
|
.wizard-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -2235,6 +2251,18 @@ body {
|
|||||||
|
|
||||||
.iface-item:hover .iface-item-actions { display: flex; }
|
.iface-item:hover .iface-item-actions { display: flex; }
|
||||||
|
|
||||||
|
.iface-item[draggable="true"] .iface-item-name { cursor: grab; }
|
||||||
|
|
||||||
|
/* Drag-and-drop reorder / move-to-folder affordances */
|
||||||
|
.iface-item.drop-target {
|
||||||
|
box-shadow: inset 0 2px 0 0 #3b82f6;
|
||||||
|
}
|
||||||
|
.iface-folder-header.drop-target,
|
||||||
|
.iface-list.drop-target {
|
||||||
|
background: rgba(59, 130, 246, 0.15);
|
||||||
|
outline: 1px dashed #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
.iface-delete { color: #ef4444 !important; }
|
.iface-delete { color: #ef4444 !important; }
|
||||||
.iface-delete:hover { background: rgba(239, 68, 68, 0.15) !important; }
|
.iface-delete:hover { background: rgba(239, 68, 68, 0.15) !important; }
|
||||||
|
|
||||||
@@ -2511,6 +2539,16 @@ body {
|
|||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.help-callout {
|
||||||
|
border-left: 3px solid #2563eb;
|
||||||
|
background: #1a2236;
|
||||||
|
padding: 0.6rem 0.85rem;
|
||||||
|
margin: 0.85rem 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
/* Getting started cards */
|
/* Getting started cards */
|
||||||
.help-cards {
|
.help-cards {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -3329,3 +3367,220 @@ kbd {
|
|||||||
}
|
}
|
||||||
.plot-pane-btn:hover { color: #e2e8f0; border-color: #4a9eff; }
|
.plot-pane-btn:hover { color: #e2e8f0; border-color: #4a9eff; }
|
||||||
.plot-pane-btn:disabled { opacity: 0.35; cursor: default; }
|
.plot-pane-btn:disabled { opacity: 0.35; cursor: default; }
|
||||||
|
|
||||||
|
/* ── Control logic editor (server-side flows) ──────────────────────────────── */
|
||||||
|
.cl-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.65);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cl-modal {
|
||||||
|
background: #1a1f2e;
|
||||||
|
border: 1px solid #2d3748;
|
||||||
|
border-radius: 10px;
|
||||||
|
width: min(1200px, 97vw);
|
||||||
|
height: min(820px, 95vh);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.6);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cl-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
border-bottom: 1px solid #2d3748;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cl-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cl-subtitle { font-size: 0.8rem; }
|
||||||
|
|
||||||
|
.cl-header-actions {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cl-dirty {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cl-error {
|
||||||
|
padding: 0.5rem 1.25rem;
|
||||||
|
background: #3b1d1d;
|
||||||
|
color: #fca5a5;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
border-bottom: 1px solid #5b2a2a;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cl-body {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cl-list {
|
||||||
|
width: 230px;
|
||||||
|
min-width: 230px;
|
||||||
|
border-right: 1px solid #2d3748;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cl-list-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-bottom: 1px solid #2d3748;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cl-list-empty { padding: 0.75rem; font-size: 0.8rem; }
|
||||||
|
|
||||||
|
.cl-list-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.45rem 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 1px solid #232a3a;
|
||||||
|
}
|
||||||
|
.cl-list-item:hover { background: #232a3a; }
|
||||||
|
.cl-list-item-active { background: #2a3450; }
|
||||||
|
|
||||||
|
.cl-status-dot {
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #475569;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.cl-status-on { background: #34d399; box-shadow: 0 0 5px #34d399; }
|
||||||
|
|
||||||
|
.cl-list-name {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cl-mini-btn {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.cl-mini-btn:hover { color: #e2e8f0; background: #1a1f2e; }
|
||||||
|
|
||||||
|
.cl-editor-wrap {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cl-empty {
|
||||||
|
padding: 1.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cl-graph-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.6rem 0.9rem;
|
||||||
|
border-bottom: 1px solid #2d3748;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cl-name-input { width: 240px; }
|
||||||
|
|
||||||
|
.cl-enable {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
color: #cbd5e1;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Logic dialogs (action.dialog.* user interaction) ──────────────────────── */
|
||||||
|
.logic-dialog-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logic-dialog {
|
||||||
|
background: #1a1f2e;
|
||||||
|
border: 1px solid #2d3748;
|
||||||
|
border-top: 3px solid #4a9eff;
|
||||||
|
border-radius: 8px;
|
||||||
|
min-width: 320px;
|
||||||
|
max-width: 460px;
|
||||||
|
padding: 1rem 1.25rem 0.9rem;
|
||||||
|
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
.logic-dialog-error { border-top-color: #ef4444; }
|
||||||
|
.logic-dialog-setpoint { border-top-color: #34d399; }
|
||||||
|
|
||||||
|
.logic-dialog-header {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #e2e8f0;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.logic-dialog-error .logic-dialog-header { color: #fca5a5; }
|
||||||
|
|
||||||
|
.logic-dialog-message {
|
||||||
|
color: #cbd5e1;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logic-dialog-input {
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user